diff --git a/.env.example b/.env.example index 098f3f6..bb0e42b 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,16 @@ -# You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview -# Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys -OPENAI_API_KEY=XXXXXXXX +# Get your OpenAI API Key here: https://platform.openai.com/account/api-keys +OPENAI_API_KEY=**** # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` -AUTH_SECRET=XXXXXXXX +AUTH_SECRET=**** -# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and -KV_URL=XXXXXXXX -KV_REST_API_URL=XXXXXXXX -KV_REST_API_TOKEN=XXXXXXXX -KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX \ No newline at end of file +/* + * 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=**** + +# Instructions to create a database here: https://vercel.com/docs/storage/vercel-postgres/quickstart +POSTGRES_URL=**** diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..5f4cbd8 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,41 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["import", "tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off", + "import/order": [ + "error", + { + "groups": [ + "builtin", + "external", + "internal", + ["parent", "sibling"], + "index", + "object", + "type" + ], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + }, + "settings": { + "import/resolver": { + "typescript": { + "alwaysTryTypes": true + } + } + }, + "ignorePatterns": ["**/components/ui/**"] +} diff --git a/README.md b/README.md index 79a6701..d55369c 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/ai/custom-middleware.ts b/ai/custom-middleware.ts new file mode 100644 index 0000000..ce3c43f --- /dev/null +++ b/ai/custom-middleware.ts @@ -0,0 +1,3 @@ +import { Experimental_LanguageModelV1Middleware } from "ai"; + +export const customMiddleware: Experimental_LanguageModelV1Middleware = {}; diff --git a/ai/index.ts b/ai/index.ts new file mode 100644 index 0000000..0d84e4f --- /dev/null +++ b/ai/index.ts @@ -0,0 +1,8 @@ +import { openai } from "@ai-sdk/openai"; +import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai"; +import { customMiddleware } from "./custom-middleware"; + +export const customModel = wrapLanguageModel({ + model: openai("gpt-4o"), + middleware: customMiddleware, +}); diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000..d2e26bc --- /dev/null +++ b/app/(auth)/actions.ts @@ -0,0 +1,85 @@ +"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" | "invalid_data"; +} + +export const login = async ( + _: LoginActionState, + formData: FormData, +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("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" }; + } +}; + +export interface RegisterActionState { + status: + | "idle" + | "in_progress" + | "success" + | "failed" + | "user_exists" + | "invalid_data"; +} + +export const register = async ( + _: RegisterActionState, + formData: FormData, +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + 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)/api/auth/[...nextauth]/route.ts b/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..d104b65 --- /dev/null +++ b/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/(auth)/auth"; diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts new file mode 100644 index 0000000..7e27788 --- /dev/null +++ b/app/(auth)/auth.config.ts @@ -0,0 +1,39 @@ +import { NextAuthConfig } from "next-auth"; + +export const authConfig = { + pages: { + signIn: "/login", + newUser: "/", + }, + providers: [ + // added later in auth.ts since it requires bcrypt which is only compatible with Node.js + // while this file is also used in non-Node.js environments + ], + callbacks: { + authorized({ auth, request: { nextUrl } }) { + let isLoggedIn = !!auth?.user; + let isOnChat = nextUrl.pathname.startsWith("/"); + let isOnRegister = nextUrl.pathname.startsWith("/register"); + let isOnLogin = nextUrl.pathname.startsWith("/login"); + + if (isLoggedIn && (isOnLogin || isOnRegister)) { + return Response.redirect(new URL("/", nextUrl)); + } + + if (isOnRegister || isOnLogin) { + return true; // Always allow access to register and login pages + } + + if (isOnChat) { + if (isLoggedIn) return true; + return false; // Redirect unauthenticated users to login page + } + + if (isLoggedIn) { + return Response.redirect(new URL("/", nextUrl)); + } + + return true; + }, + }, +} satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts new file mode 100644 index 0000000..e7a25e1 --- /dev/null +++ b/app/(auth)/auth.ts @@ -0,0 +1,53 @@ +import { compare } from "bcrypt-ts"; +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; +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + credentials: {}, + async authorize({ email, password }: any) { + let users = await getUser(email); + if (users.length === 0) return null; + let passwordsMatch = await compare(password, users[0].password!); + if (passwordsMatch) return users[0] as any; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + } + + return token; + }, + async session({ + session, + token, + }: { + session: ExtendedSession; + token: any; + }) { + if (session.user) { + session.user.id = token.id as string; + } + + return session; + }, + }, +}); diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..f3a1cb4 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; +import { toast } from "sonner"; + +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, + { + status: "idle", + }, + ); + + 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 ( +
+
+
+

Sign In

+

+ Use your email and password to sign in +

+
+ + Sign in +

+ {"Don't have an account? "} + + Sign up + + {" for free."} +

+
+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..6ce4083 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; +import { toast } from "sonner"; + +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, + { + status: "idle", + }, + ); + + useEffect(() => { + if (state.status === "user_exists") { + 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 ( +
+
+
+

Sign Up

+

+ Create an account with your email and password +

+
+ + Sign Up +

+ {"Already have an account? "} + + Sign in + + {" instead."} +

+
+
+
+ ); +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts new file mode 100644 index 0000000..4e2c088 --- /dev/null +++ b/app/(chat)/api/chat/route.ts @@ -0,0 +1,94 @@ +import { convertToCoreMessages, Message, streamText } from "ai"; +import { z } from "zod"; + +import { customModel } from "@/ai"; +import { auth } from "@/app/(auth)/auth"; +import { deleteChatById, getChatById, saveChat } from "@/db/queries"; + +export async function POST(request: Request) { + const { id, messages }: { id: string; messages: Array } = + await request.json(); + + const session = await auth(); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const coreMessages = convertToCoreMessages(messages); + + const result = await streamText({ + model: customModel, + system: + "you are a friendly assistant! keep your responses concise and helpful.", + messages: coreMessages, + maxSteps: 5, + tools: { + getWeather: { + description: "Get the current weather at a location", + parameters: z.object({ + latitude: z.number(), + longitude: z.number(), + }), + execute: async ({ latitude, longitude }) => { + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`, + ); + + const weatherData = await response.json(); + return weatherData; + }, + }, + }, + onFinish: async ({ responseMessages }) => { + if (session.user && session.user.id) { + try { + await saveChat({ + id, + messages: [...coreMessages, ...responseMessages], + userId: session.user.id, + }); + } catch (error) { + console.error("Failed to save chat"); + } + } + }, + experimental_telemetry: { + isEnabled: true, + functionId: "stream-text", + }, + }); + + return result.toDataStreamResponse({}); +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new Response("Not Found", { status: 404 }); + } + + const session = await auth(); + + if (!session || !session.user) { + return new Response("Unauthorized", { status: 401 }); + } + + try { + const chat = await getChatById({ id }); + + if (chat.userId !== session.user.id) { + return new Response("Unauthorized", { status: 401 }); + } + + await deleteChatById({ id }); + + return new Response("Chat deleted", { status: 200 }); + } catch (error) { + return new Response("An error occurred while processing your request", { + status: 500, + }); + } +} diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts new file mode 100644 index 0000000..868e448 --- /dev/null +++ b/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,69 @@ +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(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (request.body === null) { + return new Response("Request body is empty", { status: 400 }); + } + + try { + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + 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(); + + try { + const data = await put(`${filename}`, fileBuffer, { + access: "public", + }); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } + } catch (error) { + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 }, + ); + } +} diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts new file mode 100644 index 0000000..e1b8e07 --- /dev/null +++ b/app/(chat)/api/history/route.ts @@ -0,0 +1,13 @@ +import { auth } from "@/app/(auth)/auth"; +import { getChatsByUserId } from "@/db/queries"; + +export async function GET() { + const session = await auth(); + + if (!session || !session.user) { + return Response.json("Unauthorized!", { status: 401 }); + } + + const chats = await getChatsByUserId({ id: session.user.id! }); + return Response.json(chats); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index bf8775c..bc8d6f6 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -1,65 +1,108 @@ -import { type Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' +import { CoreMessage, CoreToolMessage, Message, ToolInvocation } from "ai"; +import { notFound } from "next/navigation"; -import { auth } from '@/auth' -import { getChat, getMissingKeys } from '@/app/actions' -import { Chat } from '@/components/chat' -import { AI } from '@/lib/chat/actions' -import { Session } from '@/lib/types' +import { auth } from "@/app/(auth)/auth"; +import { Chat as PreviewChat } from "@/components/custom/chat"; +import { getChatById } from "@/db/queries"; +import { Chat } from "@/db/schema"; +import { generateUUID } from "@/lib/utils"; -export interface ChatPageProps { - params: { - id: string - } -} +function addToolMessageToChat({ + toolMessage, + messages, +}: { + toolMessage: CoreToolMessage; + messages: Array; +}): Array { + return messages.map((message) => { + if (message.toolInvocations) { + return { + ...message, + toolInvocations: message.toolInvocations.map((toolInvocation) => { + const toolResult = toolMessage.content.find( + (tool) => tool.toolCallId === toolInvocation.toolCallId, + ); -export async function generateMetadata({ - params -}: ChatPageProps): Promise { - const session = await auth() + if (toolResult) { + return { + ...toolInvocation, + state: "result", + result: toolResult.result, + }; + } - if (!session?.user) { - return {} - } - - const chat = await getChat(params.id, session.user.id) - - if (!chat || 'error' in chat) { - redirect('/') - } else { - return { - title: chat?.title.toString().slice(0, 50) ?? 'Chat' - } - } -} - -export default async function ChatPage({ params }: ChatPageProps) { - const session = (await auth()) as Session - const missingKeys = await getMissingKeys() - - if (!session?.user) { - redirect(`/login?next=/chat/${params.id}`) - } - - const userId = session.user.id as string - const chat = await getChat(params.id, userId) - - if (!chat || 'error' in chat) { - redirect('/') - } else { - if (chat?.userId !== session?.user?.id) { - notFound() + return toolInvocation; + }), + }; } - return ( - - - - ) - } + return message; + }); +} + +function convertToUIMessages(messages: Array): Array { + return messages.reduce((chatMessages: Array, message) => { + if (message.role === "tool") { + return addToolMessageToChat({ + toolMessage: message as CoreToolMessage, + messages: chatMessages, + }); + } + + let textContent = ""; + let toolInvocations: Array = []; + + if (typeof message.content === "string") { + textContent = message.content; + } else if (Array.isArray(message.content)) { + for (const content of message.content) { + if (content.type === "text") { + textContent += content.text; + } else if (content.type === "tool-call") { + toolInvocations.push({ + state: "call", + toolCallId: content.toolCallId, + toolName: content.toolName, + args: content.args, + }); + } + } + } + + chatMessages.push({ + id: generateUUID(), + role: message.role, + content: textContent, + toolInvocations, + }); + + return chatMessages; + }, []); +} + +export default async function Page({ params }: { params: any }) { + const { id } = params; + const chatFromDb = await getChatById({ id }); + + if (!chatFromDb) { + notFound(); + } + + // type casting + const chat: Chat = { + ...chatFromDb, + messages: convertToUIMessages(chatFromDb.messages as Array), + }; + + const session = await auth(); + + if (!session || !session.user) { + return notFound(); + } + + if (session.user.id !== chat.userId) { + return notFound(); + } + + return ; } diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx deleted file mode 100644 index 2825d59..0000000 --- a/app/(chat)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SidebarDesktop } from '@/components/sidebar-desktop' - -interface ChatLayoutProps { - children: React.ReactNode -} - -export default async function ChatLayout({ children }: ChatLayoutProps) { - return ( -
- - {children} -
- ) -} diff --git a/app/(chat)/opengraph-image.png b/app/(chat)/opengraph-image.png new file mode 100644 index 0000000..79fbc0f Binary files /dev/null and b/app/(chat)/opengraph-image.png differ diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 21b79f9..c2af86b 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,22 +1,7 @@ -import { nanoid } from '@/lib/utils' -import { Chat } from '@/components/chat' -import { AI } from '@/lib/chat/actions' -import { auth } from '@/auth' -import { Session } from '@/lib/types' -import { getMissingKeys } from '@/app/actions' +import { Chat } from "@/components/custom/chat"; +import { generateUUID } from "@/lib/utils"; -export const metadata = { - title: 'Next.js AI Chatbot' -} - -export default async function IndexPage() { - const id = nanoid() - const session = (await auth()) as Session - const missingKeys = await getMissingKeys() - - return ( - - - - ) +export default async function Page() { + const id = generateUUID(); + return ; } diff --git a/app/(chat)/twitter-image.png b/app/(chat)/twitter-image.png new file mode 100644 index 0000000..79fbc0f Binary files /dev/null and b/app/(chat)/twitter-image.png differ diff --git a/app/actions.ts b/app/actions.ts deleted file mode 100644 index 1398aca..0000000 --- a/app/actions.ts +++ /dev/null @@ -1,172 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { redirect } from 'next/navigation' -import { kv } from '@vercel/kv' - -import { auth } from '@/auth' -import { type Chat } from '@/lib/types' - -export async function getChats(userId?: string | null) { - const session = await auth() - - if (!userId) { - return [] - } - - if (userId !== session?.user?.id) { - return { - error: 'Unauthorized' - } - } - - try { - const pipeline = kv.pipeline() - const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, { - rev: true - }) - - for (const chat of chats) { - pipeline.hgetall(chat) - } - - const results = await pipeline.exec() - - return results as Chat[] - } catch (error) { - return [] - } -} - -export async function getChat(id: string, userId: string) { - const session = await auth() - - if (userId !== session?.user?.id) { - return { - error: 'Unauthorized' - } - } - - const chat = await kv.hgetall(`chat:${id}`) - - if (!chat || (userId && chat.userId !== userId)) { - return null - } - - return chat -} - -export async function removeChat({ id, path }: { id: string; path: string }) { - const session = await auth() - - if (!session) { - return { - error: 'Unauthorized' - } - } - - // Convert uid to string for consistent comparison with session.user.id - const uid = String(await kv.hget(`chat:${id}`, 'userId')) - - if (uid !== session?.user?.id) { - return { - error: 'Unauthorized' - } - } - - await kv.del(`chat:${id}`) - await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`) - - revalidatePath('/') - return revalidatePath(path) -} - -export async function clearChats() { - const session = await auth() - - if (!session?.user?.id) { - return { - error: 'Unauthorized' - } - } - - const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1) - if (!chats.length) { - return redirect('/') - } - const pipeline = kv.pipeline() - - for (const chat of chats) { - pipeline.del(chat) - pipeline.zrem(`user:chat:${session.user.id}`, chat) - } - - await pipeline.exec() - - revalidatePath('/') - return redirect('/') -} - -export async function getSharedChat(id: string) { - const chat = await kv.hgetall(`chat:${id}`) - - if (!chat || !chat.sharePath) { - return null - } - - return chat -} - -export async function shareChat(id: string) { - const session = await auth() - - if (!session?.user?.id) { - return { - error: 'Unauthorized' - } - } - - const chat = await kv.hgetall(`chat:${id}`) - - if (!chat || chat.userId !== session.user.id) { - return { - error: 'Something went wrong' - } - } - - const payload = { - ...chat, - sharePath: `/share/${chat.id}` - } - - await kv.hmset(`chat:${chat.id}`, payload) - - return payload -} - -export async function saveChat(chat: Chat) { - const session = await auth() - - if (session && session.user) { - const pipeline = kv.pipeline() - pipeline.hmset(`chat:${chat.id}`, chat) - pipeline.zadd(`user:chat:${chat.userId}`, { - score: Date.now(), - member: `chat:${chat.id}` - }) - await pipeline.exec() - } else { - return - } -} - -export async function refreshHistory(path: string) { - redirect(path) -} - -export async function getMissingKeys() { - const keysRequired = ['OPENAI_API_KEY'] - return keysRequired - .map(key => (process.env[key] ? '' : key)) - .filter(key => key !== '') -} diff --git a/public/favicon.ico b/app/favicon.ico similarity index 100% rename from public/favicon.ico rename to app/favicon.ico diff --git a/app/globals.css b/app/globals.css index 0b46ea1..dddbf22 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,76 +1,110 @@ @tailwind base; @tailwind components; @tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; - - --radius: 0.5rem; - } - - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - } +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; } - + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } + + @font-face { + font-family: "geist"; + font-style: normal; + font-weight: 100 900; + src: url(/fonts/geist.woff2) format("woff2"); + } + + @font-face { + font-family: "geist-mono"; + font-style: normal; + font-weight: 100 900; + src: url(/fonts/geist-mono.woff2) format("woff2"); + } +} + +.skeleton { + *[class^="text-"] { + color: transparent; + @apply rounded-md bg-foreground/20 select-none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index a32b223..961c3f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,64 +1,36 @@ -import { GeistSans } from 'geist/font/sans' -import { GeistMono } from 'geist/font/mono' +import { Metadata } from "next"; +import { Toaster } from "sonner"; -import '@/app/globals.css' -import { cn } from '@/lib/utils' -import { TailwindIndicator } from '@/components/tailwind-indicator' -import { Providers } from '@/components/providers' -import { Header } from '@/components/header' -import { Toaster } from '@/components/ui/sonner' +import { Navbar } from "@/components/custom/navbar"; +import { ThemeProvider } from "@/components/custom/theme-provider"; -export const metadata = { - metadataBase: process.env.VERCEL_URL - ? new URL(`https://${process.env.VERCEL_URL}`) - : undefined, - title: { - default: 'Next.js AI Chatbot', - template: `%s - Next.js AI Chatbot` - }, - description: 'An AI-powered chatbot template built with Next.js and Vercel.', - icons: { - icon: '/favicon.ico', - shortcut: '/favicon-16x16.png', - apple: '/apple-touch-icon.png' - } -} +import "./globals.css"; -export const viewport = { - themeColor: [ - { media: '(prefers-color-scheme: light)', color: 'white' }, - { media: '(prefers-color-scheme: dark)', color: 'black' } - ] -} +export const metadata: Metadata = { + metadataBase: new URL("https://chat.vercel.ai"), + title: "Next.js Chatbot Template", + description: "Next.js chatbot template using the AI SDK.", +}; -interface RootLayoutProps { - children: React.ReactNode -} - -export default function RootLayout({ children }: RootLayoutProps) { +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { return ( - - - - + + -
-
-
{children}
-
- -
+ + + {children} + - ) + ); } diff --git a/app/login/actions.ts b/app/login/actions.ts deleted file mode 100644 index f23e220..0000000 --- a/app/login/actions.ts +++ /dev/null @@ -1,71 +0,0 @@ -'use server' - -import { signIn } from '@/auth' -import { User } from '@/lib/types' -import { AuthError } from 'next-auth' -import { z } from 'zod' -import { kv } from '@vercel/kv' -import { ResultCode } from '@/lib/utils' - -export async function getUser(email: string) { - const user = await kv.hgetall(`user:${email}`) - return user -} - -interface Result { - type: string - resultCode: ResultCode -} - -export async function authenticate( - _prevState: Result | undefined, - formData: FormData -): Promise { - try { - const email = formData.get('email') - const password = formData.get('password') - - const parsedCredentials = z - .object({ - email: z.string().email(), - password: z.string().min(6) - }) - .safeParse({ - email, - password - }) - - if (parsedCredentials.success) { - await signIn('credentials', { - email, - password, - redirect: false - }) - - return { - type: 'success', - resultCode: ResultCode.UserLoggedIn - } - } else { - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - } - } catch (error) { - if (error instanceof AuthError) { - switch (error.type) { - case 'CredentialsSignin': - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - default: - return { - type: 'error', - resultCode: ResultCode.UnknownError - } - } - } - } -} diff --git a/app/login/page.tsx b/app/login/page.tsx deleted file mode 100644 index 1fba27b..0000000 --- a/app/login/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { auth } from '@/auth' -import LoginForm from '@/components/login-form' -import { Session } from '@/lib/types' -import { redirect } from 'next/navigation' - -export default async function LoginPage() { - const session = (await auth()) as Session - - if (session) { - redirect('/') - } - - return ( -
- -
- ) -} diff --git a/app/new/page.tsx b/app/new/page.tsx deleted file mode 100644 index d235894..0000000 --- a/app/new/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation' - -export default async function NewPage() { - redirect('/') -} diff --git a/app/opengraph-image.png b/app/opengraph-image.png deleted file mode 100644 index 278b197..0000000 Binary files a/app/opengraph-image.png and /dev/null differ diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx deleted file mode 100644 index d80f0c8..0000000 --- a/app/share/[id]/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { type Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' - -import { formatDate } from '@/lib/utils' -import { getSharedChat } from '@/app/actions' -import { ChatList } from '@/components/chat-list' -import { FooterText } from '@/components/footer' -import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions' - -export const runtime = 'edge' -export const preferredRegion = 'home' - -interface SharePageProps { - params: { - id: string - } -} - -export async function generateMetadata({ - params -}: SharePageProps): Promise { - const chat = await getSharedChat(params.id) - - return { - title: chat?.title.slice(0, 50) ?? 'Chat' - } -} - -export default async function SharePage({ params }: SharePageProps) { - const chat = await getSharedChat(params.id) - - if (!chat || !chat?.sharePath) { - notFound() - } - - const uiState: UIState = getUIStateFromAIState(chat) - - return ( - <> -
-
-
-
-

{chat.title}

-
- {formatDate(chat.createdAt)} · {chat.messages.length} messages -
-
-
-
- - - -
- - - ) -} diff --git a/app/signup/actions.ts b/app/signup/actions.ts deleted file mode 100644 index 492586a..0000000 --- a/app/signup/actions.ts +++ /dev/null @@ -1,111 +0,0 @@ -'use server' - -import { signIn } from '@/auth' -import { ResultCode, getStringFromBuffer } from '@/lib/utils' -import { z } from 'zod' -import { kv } from '@vercel/kv' -import { getUser } from '../login/actions' -import { AuthError } from 'next-auth' - -export async function createUser( - email: string, - hashedPassword: string, - salt: string -) { - const existingUser = await getUser(email) - - if (existingUser) { - return { - type: 'error', - resultCode: ResultCode.UserAlreadyExists - } - } else { - const user = { - id: crypto.randomUUID(), - email, - password: hashedPassword, - salt - } - - await kv.hmset(`user:${email}`, user) - - return { - type: 'success', - resultCode: ResultCode.UserCreated - } - } -} - -interface Result { - type: string - resultCode: ResultCode -} - -export async function signup( - _prevState: Result | undefined, - formData: FormData -): Promise { - const email = formData.get('email') as string - const password = formData.get('password') as string - - const parsedCredentials = z - .object({ - email: z.string().email(), - password: z.string().min(6) - }) - .safeParse({ - email, - password - }) - - if (parsedCredentials.success) { - const salt = crypto.randomUUID() - - const encoder = new TextEncoder() - const saltedPassword = encoder.encode(password + salt) - const hashedPasswordBuffer = await crypto.subtle.digest( - 'SHA-256', - saltedPassword - ) - const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) - - try { - const result = await createUser(email, hashedPassword, salt) - - if (result.resultCode === ResultCode.UserCreated) { - await signIn('credentials', { - email, - password, - redirect: false - }) - } - - return result - } catch (error) { - if (error instanceof AuthError) { - switch (error.type) { - case 'CredentialsSignin': - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - default: - return { - type: 'error', - resultCode: ResultCode.UnknownError - } - } - } else { - return { - type: 'error', - resultCode: ResultCode.UnknownError - } - } - } - } else { - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - } -} diff --git a/app/signup/page.tsx b/app/signup/page.tsx deleted file mode 100644 index dbac964..0000000 --- a/app/signup/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { auth } from '@/auth' -import SignupForm from '@/components/signup-form' -import { Session } from '@/lib/types' -import { redirect } from 'next/navigation' - -export default async function SignupPage() { - const session = (await auth()) as Session - - if (session) { - redirect('/') - } - - return ( -
- -
- ) -} diff --git a/app/twitter-image.png b/app/twitter-image.png deleted file mode 100644 index 278b197..0000000 Binary files a/app/twitter-image.png and /dev/null differ diff --git a/auth.config.ts b/auth.config.ts deleted file mode 100644 index 6e74c18..0000000 --- a/auth.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { NextAuthConfig } from 'next-auth' - -export const authConfig = { - secret: process.env.AUTH_SECRET, - pages: { - signIn: '/login', - newUser: '/signup' - }, - callbacks: { - async authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user - const isOnLoginPage = nextUrl.pathname.startsWith('/login') - const isOnSignupPage = nextUrl.pathname.startsWith('/signup') - - if (isLoggedIn) { - if (isOnLoginPage || isOnSignupPage) { - return Response.redirect(new URL('/', nextUrl)) - } - } - - return true - }, - async jwt({ token, user }) { - if (user) { - token = { ...token, id: user.id } - } - - return token - }, - async session({ session, token }) { - if (token) { - const { id } = token as { id: string } - const { user } = session - - session = { ...session, user: { ...user, id } } - } - - return session - } - }, - providers: [] -} satisfies NextAuthConfig diff --git a/auth.ts b/auth.ts deleted file mode 100644 index 7542992..0000000 --- a/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import NextAuth from 'next-auth' -import Credentials from 'next-auth/providers/credentials' -import { authConfig } from './auth.config' -import { z } from 'zod' -import { getStringFromBuffer } from './lib/utils' -import { getUser } from './app/login/actions' - -export const { auth, signIn, signOut } = NextAuth({ - ...authConfig, - providers: [ - Credentials({ - async authorize(credentials) { - const parsedCredentials = z - .object({ - email: z.string().email(), - password: z.string().min(6) - }) - .safeParse(credentials) - - if (parsedCredentials.success) { - const { email, password } = parsedCredentials.data - const user = await getUser(email) - - if (!user) return null - - const encoder = new TextEncoder() - const saltedPassword = encoder.encode(password + user.salt) - const hashedPasswordBuffer = await crypto.subtle.digest( - 'SHA-256', - saltedPassword - ) - const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) - - if (hashedPassword === user.password) { - return user - } else { - return null - } - } - - return null - } - }) - ] -}) diff --git a/components.json b/components.json index 58b812d..1e961d3 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,9 @@ }, "aliases": { "components": "@/components", - "utils": "@/lib/utils" + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" } } \ No newline at end of file diff --git a/components/button-scroll-to-bottom.tsx b/components/button-scroll-to-bottom.tsx deleted file mode 100644 index 3988e73..0000000 --- a/components/button-scroll-to-bottom.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import * as React from 'react' - -import { cn } from '@/lib/utils' -import { Button, type ButtonProps } from '@/components/ui/button' -import { IconArrowDown } from '@/components/ui/icons' - -interface ButtonScrollToBottomProps extends ButtonProps { - isAtBottom: boolean - scrollToBottom: () => void -} - -export function ButtonScrollToBottom({ - className, - isAtBottom, - scrollToBottom, - ...props -}: ButtonScrollToBottomProps) { - return ( - - ) -} diff --git a/components/chat-history.tsx b/components/chat-history.tsx deleted file mode 100644 index 88b6ff4..0000000 --- a/components/chat-history.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react' - -import Link from 'next/link' - -import { cn } from '@/lib/utils' -import { SidebarList } from '@/components/sidebar-list' -import { buttonVariants } from '@/components/ui/button' -import { IconPlus } from '@/components/ui/icons' - -interface ChatHistoryProps { - userId?: string -} - -export async function ChatHistory({ userId }: ChatHistoryProps) { - return ( -
-
-

Chat History

-
-
- - - New Chat - -
- - {Array.from({ length: 10 }).map((_, i) => ( -
- ))} -
- } - > - {/* @ts-ignore */} - -
-
- ) -} diff --git a/components/chat-list.tsx b/components/chat-list.tsx deleted file mode 100644 index 80821a1..0000000 --- a/components/chat-list.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Separator } from '@/components/ui/separator' -import { UIState } from '@/lib/chat/actions' -import { Session } from '@/lib/types' -import Link from 'next/link' -import { ExclamationTriangleIcon } from '@radix-ui/react-icons' - -export interface ChatList { - messages: UIState - session?: Session - isShared: boolean -} - -export function ChatList({ messages, session, isShared }: ChatList) { - if (!messages.length) { - return null - } - - return ( -
- {!isShared && !session ? ( - <> -
-
- -
-
-

- Please{' '} - - log in - {' '} - or{' '} - - sign up - {' '} - to save and revisit your chat history! -

-
-
- - - ) : null} - - {messages.map((message, index) => ( -
- {message.display} - {index < messages.length - 1 && } -
- ))} -
- ) -} diff --git a/components/chat-message-actions.tsx b/components/chat-message-actions.tsx deleted file mode 100644 index d4e4b40..0000000 --- a/components/chat-message-actions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -import { type Message } from 'ai' - -import { Button } from '@/components/ui/button' -import { IconCheck, IconCopy } from '@/components/ui/icons' -import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' -import { cn } from '@/lib/utils' - -interface ChatMessageActionsProps extends React.ComponentProps<'div'> { - message: Message -} - -export function ChatMessageActions({ - message, - className, - ...props -}: ChatMessageActionsProps) { - const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) - - const onCopy = () => { - if (isCopied) return - copyToClipboard(message.content) - } - - return ( -
- -
- ) -} diff --git a/components/chat-message.tsx b/components/chat-message.tsx deleted file mode 100644 index 32ecd78..0000000 --- a/components/chat-message.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// Inspired by Chatbot-UI and modified to fit the needs of this project -// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx - -import { Message } from 'ai' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' - -import { cn } from '@/lib/utils' -import { CodeBlock } from '@/components/ui/codeblock' -import { MemoizedReactMarkdown } from '@/components/markdown' -import { IconOpenAI, IconUser } from '@/components/ui/icons' -import { ChatMessageActions } from '@/components/chat-message-actions' - -export interface ChatMessageProps { - message: Message -} - -export function ChatMessage({ message, ...props }: ChatMessageProps) { - return ( -
-
- {message.role === 'user' ? : } -
-
- {children}

- }, - code({ node, inline, className, children, ...props }) { - if (children.length) { - if (children[0] == '▍') { - return ( - - ) - } - - children[0] = (children[0] as string).replace('`▍`', '▍') - } - - const match = /language-(\w+)/.exec(className || '') - - if (inline) { - return ( - - {children} - - ) - } - - return ( - - ) - } - }} - > - {message.content} -
- -
-
- ) -} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx deleted file mode 100644 index 48df7a7..0000000 --- a/components/chat-panel.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import * as React from 'react' - -import { shareChat } from '@/app/actions' -import { Button } from '@/components/ui/button' -import { PromptForm } from '@/components/prompt-form' -import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' -import { IconShare } from '@/components/ui/icons' -import { FooterText } from '@/components/footer' -import { ChatShareDialog } from '@/components/chat-share-dialog' -import { useAIState, useActions, useUIState } from 'ai/rsc' -import type { AI } from '@/lib/chat/actions' -import { nanoid } from 'nanoid' -import { UserMessage } from './stocks/message' - -export interface ChatPanelProps { - id?: string - title?: string - input: string - setInput: (value: string) => void - isAtBottom: boolean - scrollToBottom: () => void -} - -export function ChatPanel({ - id, - title, - input, - setInput, - isAtBottom, - scrollToBottom -}: ChatPanelProps) { - const [aiState] = useAIState() - const [messages, setMessages] = useUIState() - const { submitUserMessage } = useActions() - const [shareDialogOpen, setShareDialogOpen] = React.useState(false) - - const exampleMessages = [ - { - heading: 'What are the', - subheading: 'trending memecoins today?', - message: `What are the trending memecoins today?` - }, - { - heading: 'What is the price of', - subheading: '$DOGE right now?', - message: 'What is the price of $DOGE right now?' - }, - { - heading: 'I would like to buy', - subheading: '42 $DOGE', - message: `I would like to buy 42 $DOGE` - }, - { - heading: 'What are some', - subheading: `recent events about $DOGE?`, - message: `What are some recent events about $DOGE?` - } - ] - - return ( -
- - -
-
- {messages.length === 0 && - exampleMessages.map((example, index) => ( -
1 && 'hidden md:block' - }`} - onClick={async () => { - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - display: {example.message} - } - ]) - - const responseMessage = await submitUserMessage( - example.message - ) - - setMessages(currentMessages => [ - ...currentMessages, - responseMessage - ]) - }} - > -
{example.heading}
-
- {example.subheading} -
-
- ))} -
- - {messages?.length >= 2 ? ( -
-
- {id && title ? ( - <> - - setShareDialogOpen(false)} - shareChat={shareChat} - chat={{ - id, - title, - messages: aiState.messages - }} - /> - - ) : null} -
-
- ) : null} - -
- - -
-
-
- ) -} diff --git a/components/chat-share-dialog.tsx b/components/chat-share-dialog.tsx deleted file mode 100644 index 2e5f8e2..0000000 --- a/components/chat-share-dialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client' - -import * as React from 'react' -import { type DialogProps } from '@radix-ui/react-dialog' -import { toast } from 'sonner' - -import { ServerActionResult, type Chat } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { IconSpinner } from '@/components/ui/icons' -import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' - -interface ChatShareDialogProps extends DialogProps { - chat: Pick - shareChat: (id: string) => ServerActionResult - onCopy: () => void -} - -export function ChatShareDialog({ - chat, - shareChat, - onCopy, - ...props -}: ChatShareDialogProps) { - const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) - const [isSharePending, startShareTransition] = React.useTransition() - - const copyShareLink = React.useCallback( - async (chat: Chat) => { - if (!chat.sharePath) { - return toast.error('Could not copy share link to clipboard') - } - - const url = new URL(window.location.href) - url.pathname = chat.sharePath - copyToClipboard(url.toString()) - onCopy() - toast.success('Share link copied to clipboard') - }, - [copyToClipboard, onCopy] - ) - - return ( - - - - Share link to chat - - Anyone with the URL will be able to view the shared chat. - - -
-
{chat.title}
-
- {chat.messages.length} messages -
-
- - - -
-
- ) -} diff --git a/components/chat.tsx b/components/chat.tsx deleted file mode 100644 index c43d42a..0000000 --- a/components/chat.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client' - -import { cn } from '@/lib/utils' -import { ChatList } from '@/components/chat-list' -import { ChatPanel } from '@/components/chat-panel' -import { EmptyScreen } from '@/components/empty-screen' -import { useLocalStorage } from '@/lib/hooks/use-local-storage' -import { useEffect, useState } from 'react' -import { useUIState, useAIState } from 'ai/rsc' -import { Message, Session } from '@/lib/types' -import { usePathname, useRouter } from 'next/navigation' -import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor' -import { toast } from 'sonner' - -export interface ChatProps extends React.ComponentProps<'div'> { - initialMessages?: Message[] - id?: string - session?: Session - missingKeys: string[] -} - -export function Chat({ id, className, session, missingKeys }: ChatProps) { - const router = useRouter() - const path = usePathname() - const [input, setInput] = useState('') - const [messages] = useUIState() - const [aiState] = useAIState() - - const [_, setNewChatId] = useLocalStorage('newChatId', id) - - useEffect(() => { - if (session?.user) { - if (!path.includes('chat') && messages.length === 1) { - window.history.replaceState({}, '', `/chat/${id}`) - } - } - }, [id, path, session?.user, messages]) - - useEffect(() => { - const messagesLength = aiState.messages?.length - if (messagesLength === 2) { - router.refresh() - } - }, [aiState.messages, router]) - - useEffect(() => { - setNewChatId(id) - }) - - useEffect(() => { - missingKeys.map(key => { - toast.error(`Missing ${key} environment variable!`) - }) - }, [missingKeys]) - - const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } = - useScrollAnchor() - - return ( -
-
- {messages.length ? ( - - ) : ( - - )} -
-
- -
- ) -} diff --git a/components/clear-history.tsx b/components/clear-history.tsx deleted file mode 100644 index 69cf70e..0000000 --- a/components/clear-history.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client' - -import * as React from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' - -import { ServerActionResult } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from '@/components/ui/alert-dialog' -import { IconSpinner } from '@/components/ui/icons' - -interface ClearHistoryProps { - isEnabled: boolean - clearChats: () => ServerActionResult -} - -export function ClearHistory({ - isEnabled = false, - clearChats -}: ClearHistoryProps) { - const [open, setOpen] = React.useState(false) - const [isPending, startTransition] = React.useTransition() - const router = useRouter() - - return ( - - - - - - - Are you absolutely sure? - - This will permanently delete your chat history and remove your data - from our servers. - - - - Cancel - { - event.preventDefault() - startTransition(async () => { - const result = await clearChats() - if (result && 'error' in result) { - toast.error(result.error) - return - } - - setOpen(false) - }) - }} - > - {isPending && } - Delete - - - - - ) -} diff --git a/components/custom/auth-form.tsx b/components/custom/auth-form.tsx new file mode 100644 index 0000000..08610ff --- /dev/null +++ b/components/custom/auth-form.tsx @@ -0,0 +1,53 @@ +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +export function AuthForm({ + action, + children, + defaultEmail = "", +}: { + action: any; + children: React.ReactNode; + defaultEmail?: string; +}) { + return ( +
+
+ + + + + + + +
+ + {children} +
+ ); +} diff --git a/components/custom/chat.tsx b/components/custom/chat.tsx new file mode 100644 index 0000000..bdd8424 --- /dev/null +++ b/components/custom/chat.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Attachment, Message } from "ai"; +import { useChat } from "ai/react"; +import { useState } from "react"; + +import { Message as PreviewMessage } from "@/components/custom/message"; +import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom"; + +import { MultimodalInput } from "./multimodal-input"; +import { Overview } from "./overview"; + +export function Chat({ + id, + initialMessages, +}: { + id: string; + initialMessages: Array; +}) { + const { messages, handleSubmit, input, setInput, append, isLoading, stop } = + useChat({ + body: { id }, + initialMessages, + onFinish: () => { + window.history.replaceState({}, "", `/chat/${id}`); + }, + }); + + const [messagesContainerRef, messagesEndRef] = + useScrollToBottom(); + + const [attachments, setAttachments] = useState>([]); + + return ( +
+
+
+ {messages.length === 0 && } + + {messages.map((message, index) => ( + + ))} +
+
+ +
+ + +
+
+ ); +} diff --git a/components/custom/history.tsx b/components/custom/history.tsx new file mode 100644 index 0000000..a0845b6 --- /dev/null +++ b/components/custom/history.tsx @@ -0,0 +1,241 @@ +"use client"; + +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import cx from "classnames"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { User } from "next-auth"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; + +import { Chat } from "@/db/schema"; +import { fetcher } from "@/lib/utils"; + +import { + InfoIcon, + MenuIcon, + MoreHorizontalIcon, + PencilEditIcon, + TrashIcon, +} from "./icons"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../ui/sheet"; + +export const History = ({ user }: { user: User | undefined }) => { + const { id } = useParams(); + const pathname = usePathname(); + + const [isHistoryVisible, setIsHistoryVisible] = useState(false); + const { + data: history, + isLoading, + mutate, + } = useSWR>(user ? "/api/history" : null, fetcher, { + fallbackData: [], + }); + + useEffect(() => { + mutate(); + }, [pathname, mutate]); + + const [deleteId, setDeleteId] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDelete = async () => { + const deletePromise = fetch(`/api/chat?id=${deleteId}`, { + method: "DELETE", + }); + + toast.promise(deletePromise, { + loading: "Deleting chat...", + success: () => { + mutate((history) => { + if (history) { + return history.filter((h) => h.id !== id); + } + }); + return "Chat deleted successfully"; + }, + error: "Failed to delete chat", + }); + + setShowDeleteDialog(false); + }; + + return ( + <> + + + { + setIsHistoryVisible(state); + }} + > + + + + History + + {history === undefined ? "loading" : history.length} chats + + + + +
+
+
History
+ +
+ {history === undefined ? "loading" : history.length} chats +
+
+
+ +
+ {user && ( + + )} + +
+ {!user ? ( +
+ +
Login to save and revisit previous chats!
+
+ ) : null} + + {!isLoading && history?.length === 0 && user ? ( +
+ +
No chats found
+
+ ) : null} + + {isLoading && user ? ( +
+ {[44, 32, 28, 52].map((item) => ( +
+
+
+ ))} +
+ ) : null} + + {history && + history.map((chat) => ( +
+ + + + + + + + + + + + +
+ ))} +
+
+ + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + chat and remove it from our servers. + + + + Cancel + + Continue + + + + + + ); +}; diff --git a/components/custom/icons.tsx b/components/custom/icons.tsx new file mode 100644 index 0000000..c267c88 --- /dev/null +++ b/components/custom/icons.tsx @@ -0,0 +1,611 @@ +export const BotIcon = () => { + return ( + + + + ); +}; + +export const UserIcon = () => { + return ( + + + + ); +}; + +export const AttachmentIcon = () => { + return ( + + + + ); +}; + +export const VercelIcon = ({ size = 17 }) => { + return ( + + + + ); +}; + +export const GitIcon = () => { + return ( + + + + + + + + + + + ); +}; + +export const BoxIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const HomeIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const GPSIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const InvoiceIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const LogoOpenAI = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const LogoGoogle = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + + ); +}; + +export const LogoAnthropic = () => { + return ( + + + + ); +}; + +export const RouteIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const FileIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const LoaderIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export const UploadIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MenuIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const PencilEditIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const CheckedSquare = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const UncheckedSquare = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MoreIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const TrashIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const InfoIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const ArrowUpIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const StopIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const PaperclipIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MoreHorizontalIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MessageIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; diff --git a/components/custom/markdown.tsx b/components/custom/markdown.tsx new file mode 100644 index 0000000..df7e194 --- /dev/null +++ b/components/custom/markdown.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; +import React, { memo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +const NonMemoizedMarkdown = ({ children }: { children: string }) => { + const components = { + code: ({ node, inline, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + return !inline && match ? ( +
+          {children}
+        
+ ) : ( + + {children} + + ); + }, + ol: ({ node, children, ...props }: any) => { + return ( +
    + {children} +
+ ); + }, + li: ({ node, children, ...props }: any) => { + return ( +
  • + {children} +
  • + ); + }, + ul: ({ node, children, ...props }: any) => { + return ( +
      + {children} +
    + ); + }, + strong: ({ node, children, ...props }: any) => { + return ( + + {children} + + ); + }, + a: ({ node, children, ...props }: any) => { + return ( + + {children} + + ); + }, + }; + + return ( + + {children} + + ); +}; + +export const Markdown = memo( + NonMemoizedMarkdown, + (prevProps, nextProps) => prevProps.children === nextProps.children, +); diff --git a/components/custom/message.tsx b/components/custom/message.tsx new file mode 100644 index 0000000..ae17659 --- /dev/null +++ b/components/custom/message.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Attachment, ToolInvocation } from "ai"; +import { motion } from "framer-motion"; +import { ReactNode } from "react"; + +import { BotIcon, UserIcon } from "./icons"; +import { Markdown } from "./markdown"; +import { PreviewAttachment } from "./preview-attachment"; +import { Weather } from "./weather"; + +export const Message = ({ + role, + content, + toolInvocations, + attachments, +}: { + role: string; + content: string | ReactNode; + toolInvocations: Array | undefined; + attachments?: Array; +}) => { + return ( + +
    + {role === "assistant" ? : } +
    + +
    + {content && ( +
    + {content as string} +
    + )} + + {toolInvocations && ( +
    + {toolInvocations.map((toolInvocation) => { + const { toolName, toolCallId, state } = toolInvocation; + + if (state === "result") { + const { result } = toolInvocation; + + return ( +
    + {toolName === "getWeather" ? ( + + ) : null} +
    + ); + } else { + return ( +
    + {toolName === "getWeather" ? : null} +
    + ); + } + })} +
    + )} + + {attachments && ( +
    + {attachments.map((attachment) => ( + + ))} +
    + )} +
    +
    + ); +}; diff --git a/components/custom/multimodal-input.tsx b/components/custom/multimodal-input.tsx new file mode 100644 index 0000000..1503968 --- /dev/null +++ b/components/custom/multimodal-input.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai"; +import { motion } from "framer-motion"; +import React, { + useRef, + useEffect, + useState, + useCallback, + Dispatch, + SetStateAction, + ChangeEvent, +} from "react"; +import { toast } from "sonner"; + +import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; +import { PreviewAttachment } from "./preview-attachment"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; + +const suggestedActions = [ + { + title: "What is", + label: "the meaning of life?", + action: "what is the meaning of life?", + }, + { + title: "Why do", + label: "developers use Next.js?", + action: "why do developers use Next.js?", + }, +]; + +export function MultimodalInput({ + input, + setInput, + isLoading, + stop, + attachments, + setAttachments, + messages, + append, + handleSubmit, +}: { + input: string; + setInput: (value: string) => void; + isLoading: boolean; + stop: () => void; + attachments: Array; + setAttachments: Dispatch>>; + messages: Array; + append: ( + message: Message | CreateMessage, + chatRequestOptions?: ChatRequestOptions, + ) => Promise; + handleSubmit: ( + event?: { + preventDefault?: () => void; + }, + chatRequestOptions?: ChatRequestOptions, + ) => void; +}) { + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current) { + adjustHeight(); + } + }, []); + + const adjustHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; + } + }; + + const handleInput = (event: React.ChangeEvent) => { + setInput(event.target.value); + adjustHeight(); + }; + + const fileInputRef = useRef(null); + const [uploadQueue, setUploadQueue] = useState>([]); + + const submitForm = useCallback(() => { + handleSubmit(undefined, { + experimental_attachments: attachments, + }); + + setAttachments([]); + }, [attachments, handleSubmit, setAttachments]); + + const uploadFile = async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch(`/api/files/upload`, { + method: "POST", + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + const { url, pathname, contentType } = data; + + return { + url, + name: pathname, + contentType: contentType, + }; + } else { + const { error } = await response.json(); + toast.error(error); + } + } catch (error) { + toast.error("Failed to upload file, please try again!"); + } + }; + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + + setUploadQueue(files.map((file) => file.name)); + + try { + const uploadPromises = files.map((file) => uploadFile(file)); + const uploadedAttachments = await Promise.all(uploadPromises); + const successfullyUploadedAttachments = uploadedAttachments.filter( + (attachment) => attachment !== undefined, + ); + + setAttachments((currentAttachments) => [ + ...currentAttachments, + ...successfullyUploadedAttachments, + ]); + } catch (error) { + console.error("Error uploading files!", error); + } finally { + setUploadQueue([]); + } + }, + [setAttachments], + ); + + return ( +
    + {messages.length === 0 && + attachments.length === 0 && + uploadQueue.length === 0 && ( +
    + {suggestedActions.map((suggestedAction, index) => ( + 1 ? "hidden sm:block" : "block"} + > + + + ))} +
    + )} + + + + {(attachments.length > 0 || uploadQueue.length > 0) && ( +
    + {attachments.map((attachment) => ( + + ))} + + {uploadQueue.map((filename) => ( + + ))} +
    + )} + +