diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..6a40fdd61a94e06acf0747d0385114b9d59e9adc --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# 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 + +# Get your xAI API Key here for chat and image models: https://console.x.ai/ +XAI_API_KEY=**** + +# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob +BLOB_READ_WRITE_TOKEN=**** + +# Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart +POSTGRES_URL=**** + + +# Instructions to create a Redis store here: +# https://vercel.com/docs/redis +REDIS_URL=**** diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..a44aff84ac5ce70c29748de56a9adfddfa34ad2f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off" + }, + "settings": { + "import/resolver": { + "typescript": { + "alwaysTryTypes": true, + "project": "./tsconfig.json" + } + } + }, + "ignorePatterns": ["**/components/ui/**"] +} diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..0437453b0e127df202752375d169d5987d80694a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +app/(chat)/opengraph-image.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..54b31f9fcfd3f5ec40cf48a83763d0e6179cc78f --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.env +.vercel +.env*.local + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000000000000000000000000000000..699ed73319bc2151d50e5b1b2802e55c2f513119 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..fb5a7ca0b58a4b703119131fc664f2edbeac6ec1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "eslint.workingDirectories": [ + { "pattern": "app/*" }, + { "pattern": "packages/*" } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..81da705069139a5cf8cf9d9c63ceaedd91bf4b9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine +USER root + +USER 1000 +WORKDIR /usr/src/app +# Copy package.json and package-lock.json to the container +COPY --chown=1000 package.json package-lock.json ./ + +# Copy the rest of the application files to the container +COPY --chown=1000 . . + +RUN npm install +RUN npm run build + +# Expose the application port (assuming your app runs on port 3000) +EXPOSE 3000 + +# Start the application +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..695ee2d6a04b691b3f3aa1c4e76e7c4b3a931042 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 855c4c1ab5851d6c2225eab7440696382bd92b75..44a8cb208c5a2e8f9085877be23a0b42383691d7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,62 @@ ---- -title: Next Chat -emoji: 👀 -colorFrom: yellow -colorTo: purple -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference + + Next.js 14 and App Router-ready AI chatbot. +

Chat SDK

+
+ +

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

+ +

+ Read Docs · + Features · + Model Providers · + Deploy Your Own · + Running locally +

+
+ +## Features + +- [Next.js](https://nextjs.org) App Router + - 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 xAI (default), OpenAI, Fireworks, and other model providers +- [shadcn/ui](https://ui.shadcn.com) + - Styling with [Tailwind CSS](https://tailwindcss.com) + - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility +- Data Persistence + - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data + - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage +- [Auth.js](https://authjs.dev) + - Simple and secure authentication + +## Model Providers + +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. + +## 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?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) + +## Running locally + +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. + +> 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. + +1. Install Vercel CLI: `npm i -g vercel` +2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` +3. Download your environment variables: `vercel env pull` + +```bash +pnpm install +pnpm dev +``` + +Your app template should now be running on [localhost:3000](http://localhost:3000). diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..84f8ffde4391e2f19d453770bbf6444b239f44ec --- /dev/null +++ b/app/(auth)/actions.ts @@ -0,0 +1,84 @@ +'use server'; + +import { z } from 'zod'; + +import { createUser, getUser } from '@/lib/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'), + }); + + const [user] = await getUser(validatedData.email); + + if (user) { + return { status: 'user_exists' } as RegisterActionState; + } + 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 0000000000000000000000000000000000000000..ba2423472f3917c14f4179ae6b62b83537e27db4 --- /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)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..25af1fa7b7d321158467287f6153df17a080955e --- /dev/null +++ b/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,21 @@ +import { signIn } from '@/app/(auth)/auth'; +import { isDevelopmentEnvironment } from '@/lib/constants'; +import { getToken } from 'next-auth/jwt'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const redirectUrl = searchParams.get('redirectUrl') || '/'; + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (token) { + return NextResponse.redirect(new URL('/', request.url)); + } + + return signIn('guest', { redirect: true, redirectTo: redirectUrl }); +} diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7d7d50bf1fbabea159b49a879bf5685c0590101 --- /dev/null +++ b/app/(auth)/auth.config.ts @@ -0,0 +1,13 @@ +import type { 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: {}, +} satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..9185248db04994a6dac857d8323c96e242726a28 --- /dev/null +++ b/app/(auth)/auth.ts @@ -0,0 +1,92 @@ +import { compare } from 'bcrypt-ts'; +import NextAuth, { type DefaultSession } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import { createGuestUser, getUser } from '@/lib/db/queries'; +import { authConfig } from './auth.config'; +import { DUMMY_PASSWORD } from '@/lib/constants'; +import type { DefaultJWT } from 'next-auth/jwt'; + +export type UserType = 'guest' | 'regular'; + +declare module 'next-auth' { + interface Session extends DefaultSession { + user: { + id: string; + type: UserType; + } & DefaultSession['user']; + } + + interface User { + id?: string; + email?: string | null; + type: UserType; + } +} + +declare module 'next-auth/jwt' { + interface JWT extends DefaultJWT { + id: string; + type: UserType; + } +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + credentials: {}, + async authorize({ email, password }: any) { + const users = await getUser(email); + + if (users.length === 0) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const [user] = users; + + if (!user.password) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const passwordsMatch = await compare(password, user.password); + + if (!passwordsMatch) return null; + + return { ...user, type: 'regular' }; + }, + }), + Credentials({ + id: 'guest', + credentials: {}, + async authorize() { + const [guestUser] = await createGuestUser(); + return { ...guestUser, type: 'guest' }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id as string; + token.type = user.type; + } + + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id; + session.user.type = token.type; + } + + return session; + }, + }, +}); diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33e9e827096256bdab915c5e45d3fee97b65defb --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useActionState, useEffect, useState } from 'react'; +import { toast } from '@/components/toast'; + +import { AuthForm } from '@/components/auth-form'; +import { SubmitButton } from '@/components/submit-button'; + +import { login, type LoginActionState } from '../actions'; +import { useSession } from 'next-auth/react'; + +export default function Page() { + const router = useRouter(); + + const [email, setEmail] = useState(''); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + login, + { + status: 'idle', + }, + ); + + const { update: updateSession } = useSession(); + + useEffect(() => { + if (state.status === 'failed') { + toast({ + type: 'error', + description: 'Invalid credentials!', + }); + } else if (state.status === 'invalid_data') { + toast({ + type: 'error', + description: 'Failed validating your submission!', + }); + } else if (state.status === 'success') { + setIsSuccessful(true); + updateSession(); + router.refresh(); + } + }, [state.status]); + + 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 0000000000000000000000000000000000000000..ab2ee8266714aec0622e135e91e5ea3fdd24462f --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,78 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useActionState, useEffect, useState } from 'react'; + +import { AuthForm } from '@/components/auth-form'; +import { SubmitButton } from '@/components/submit-button'; + +import { register, type RegisterActionState } from '../actions'; +import { toast } from '@/components/toast'; +import { useSession } from 'next-auth/react'; + +export default function Page() { + const router = useRouter(); + + const [email, setEmail] = useState(''); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + register, + { + status: 'idle', + }, + ); + + const { update: updateSession } = useSession(); + + useEffect(() => { + if (state.status === 'user_exists') { + toast({ type: 'error', description: 'Account already exists!' }); + } else if (state.status === 'failed') { + toast({ type: 'error', description: 'Failed to create account!' }); + } else if (state.status === 'invalid_data') { + toast({ + type: 'error', + description: 'Failed validating your submission!', + }); + } else if (state.status === 'success') { + toast({ type: 'success', description: 'Account created successfully!' }); + + setIsSuccessful(true); + updateSession(); + router.refresh(); + } + }, [state]); + + 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)/actions.ts b/app/(chat)/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..14ee7dc2a4d5567bafd573d8d0b421bf2430fc8c --- /dev/null +++ b/app/(chat)/actions.ts @@ -0,0 +1,53 @@ +'use server'; + +import { generateText, type UIMessage } from 'ai'; +import { cookies } from 'next/headers'; +import { + deleteMessagesByChatIdAfterTimestamp, + getMessageById, + updateChatVisiblityById, +} from '@/lib/db/queries'; +import type { VisibilityType } from '@/components/visibility-selector'; +import { myProvider } from '@/lib/ai/providers'; + +export async function saveChatModelAsCookie(model: string) { + const cookieStore = await cookies(); + cookieStore.set('chat-model', model); +} + +export async function generateTitleFromUserMessage({ + message, +}: { + message: UIMessage; +}) { + const { text: title } = await generateText({ + model: myProvider.languageModel('title-model'), + system: `\n + - you will generate a short title based on the first message a user begins a conversation with + - ensure it is not more than 80 characters long + - the title should be a summary of the user's message + - do not use quotes or colons`, + prompt: JSON.stringify(message), + }); + + return title; +} + +export async function deleteTrailingMessages({ id }: { id: string }) { + const [message] = await getMessageById({ id }); + + await deleteMessagesByChatIdAfterTimestamp({ + chatId: message.chatId, + timestamp: message.createdAt, + }); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + await updateChatVisiblityById({ chatId, visibility }); +} diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..23e84885e66d3fe1c750eef7d7f5a4065e38d7a1 --- /dev/null +++ b/app/(chat)/api/chat/[id]/stream/route.ts @@ -0,0 +1,112 @@ +import { auth } from '@/app/(auth)/auth'; +import { + getChatById, + getMessagesByChatId, + getStreamIdsByChatId, +} from '@/lib/db/queries'; +import type { Chat } from '@/lib/db/schema'; +import { ChatSDKError } from '@/lib/errors'; +import type { ChatMessage } from '@/lib/types'; +import { createUIMessageStream, JsonToSseTransformStream } from 'ai'; +import { getStreamContext } from '../../route'; +import { differenceInSeconds } from 'date-fns'; + +export async function GET( + _: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: chatId } = await params; + + const streamContext = getStreamContext(); + const resumeRequestedAt = new Date(); + + if (!streamContext) { + return new Response(null, { status: 204 }); + } + + if (!chatId) { + return new ChatSDKError('bad_request:api').toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + let chat: Chat; + + try { + chat = await getChatById({ id: chatId }); + } catch { + return new ChatSDKError('not_found:chat').toResponse(); + } + + if (!chat) { + return new ChatSDKError('not_found:chat').toResponse(); + } + + if (chat.visibility === 'private' && chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:chat').toResponse(); + } + + const streamIds = await getStreamIdsByChatId({ chatId }); + + if (!streamIds.length) { + return new ChatSDKError('not_found:stream').toResponse(); + } + + const recentStreamId = streamIds.at(-1); + + if (!recentStreamId) { + return new ChatSDKError('not_found:stream').toResponse(); + } + + const emptyDataStream = createUIMessageStream({ + execute: () => {}, + }); + + const stream = await streamContext.resumableStream(recentStreamId, () => + emptyDataStream.pipeThrough(new JsonToSseTransformStream()), + ); + + /* + * For when the generation is streaming during SSR + * but the resumable stream has concluded at this point. + */ + if (!stream) { + const messages = await getMessagesByChatId({ id: chatId }); + const mostRecentMessage = messages.at(-1); + + if (!mostRecentMessage) { + return new Response(emptyDataStream, { status: 200 }); + } + + if (mostRecentMessage.role !== 'assistant') { + return new Response(emptyDataStream, { status: 200 }); + } + + const messageCreatedAt = new Date(mostRecentMessage.createdAt); + + if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) { + return new Response(emptyDataStream, { status: 200 }); + } + + const restoredStream = createUIMessageStream({ + execute: ({ writer }) => { + writer.write({ + type: 'data-appendMessage', + data: JSON.stringify(mostRecentMessage), + transient: true, + }); + }, + }); + + return new Response( + restoredStream.pipeThrough(new JsonToSseTransformStream()), + { status: 200 }, + ); + } + + return new Response(stream, { status: 200 }); +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7ea099a574c3b44cac51cb8d37677807cb6c583 --- /dev/null +++ b/app/(chat)/api/chat/route.ts @@ -0,0 +1,251 @@ +import { + convertToModelMessages, + createUIMessageStream, + JsonToSseTransformStream, + smoothStream, + stepCountIs, + streamText, +} from 'ai'; +import { auth, type UserType } from '@/app/(auth)/auth'; +import { type RequestHints, systemPrompt } from '@/lib/ai/prompts'; +import { + createStreamId, + deleteChatById, + getChatById, + getMessageCountByUserId, + getMessagesByChatId, + saveChat, + saveMessages, +} from '@/lib/db/queries'; +import { convertToUIMessages, generateUUID } from '@/lib/utils'; +import { generateTitleFromUserMessage } from '../../actions'; +import { createDocument } from '@/lib/ai/tools/create-document'; +import { updateDocument } from '@/lib/ai/tools/update-document'; +import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; +import { getWeather } from '@/lib/ai/tools/get-weather'; +import { isProductionEnvironment } from '@/lib/constants'; +import { myProvider } from '@/lib/ai/providers'; +import { entitlementsByUserType } from '@/lib/ai/entitlements'; +import { postRequestBodySchema, type PostRequestBody } from './schema'; +import { geolocation } from '@vercel/functions'; +import { + createResumableStreamContext, + type ResumableStreamContext, +} from 'resumable-stream'; +import { after } from 'next/server'; +import { ChatSDKError } from '@/lib/errors'; +import type { ChatMessage } from '@/lib/types'; +import type { ChatModel } from '@/lib/ai/models'; +import type { VisibilityType } from '@/components/visibility-selector'; + +export const maxDuration = 60; + +let globalStreamContext: ResumableStreamContext | null = null; + +export function getStreamContext() { + if (!globalStreamContext) { + try { + globalStreamContext = createResumableStreamContext({ + waitUntil: after, + }); + } catch (error: any) { + if (error.message.includes('REDIS_URL')) { + console.log( + ' > Resumable streams are disabled due to missing REDIS_URL', + ); + } else { + console.error(error); + } + } + } + + return globalStreamContext; +} + +export async function POST(request: Request) { + let requestBody: PostRequestBody; + + try { + const json = await request.json(); + requestBody = postRequestBodySchema.parse(json); + } catch (_) { + return new ChatSDKError('bad_request:api').toResponse(); + } + + try { + const { + id, + message, + selectedChatModel, + selectedVisibilityType, + }: { + id: string; + message: ChatMessage; + selectedChatModel: ChatModel['id']; + selectedVisibilityType: VisibilityType; + } = requestBody; + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + const userType: UserType = session.user.type; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 24, + }); + + if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { + return new ChatSDKError('rate_limit:chat').toResponse(); + } + + const chat = await getChatById({ id }); + + if (!chat) { + const title = await generateTitleFromUserMessage({ + message, + }); + + await saveChat({ + id, + userId: session.user.id, + title, + visibility: selectedVisibilityType, + }); + } else { + if (chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:chat').toResponse(); + } + } + + const messagesFromDb = await getMessagesByChatId({ id }); + const uiMessages = [...convertToUIMessages(messagesFromDb), message]; + + const { longitude, latitude, city, country } = geolocation(request); + + const requestHints: RequestHints = { + longitude, + latitude, + city, + country, + }; + + await saveMessages({ + messages: [ + { + chatId: id, + id: message.id, + role: 'user', + parts: message.parts, + attachments: [], + createdAt: new Date(), + }, + ], + }); + + const streamId = generateUUID(); + await createStreamId({ streamId, chatId: id }); + + const stream = createUIMessageStream({ + execute: ({ writer: dataStream }) => { + const result = streamText({ + model: myProvider.languageModel(selectedChatModel), + system: systemPrompt({ selectedChatModel, requestHints }), + messages: convertToModelMessages(uiMessages), + stopWhen: stepCountIs(5), + experimental_activeTools: + selectedChatModel === 'chat-model-reasoning' + ? [] + : [ + 'getWeather', + 'createDocument', + 'updateDocument', + 'requestSuggestions', + ], + experimental_transform: smoothStream({ chunking: 'word' }), + tools: { + getWeather, + createDocument: createDocument({ session, dataStream }), + updateDocument: updateDocument({ session, dataStream }), + requestSuggestions: requestSuggestions({ + session, + dataStream, + }), + }, + experimental_telemetry: { + isEnabled: isProductionEnvironment, + functionId: 'stream-text', + }, + }); + + result.consumeStream(); + + dataStream.merge( + result.toUIMessageStream({ + sendReasoning: true, + }), + ); + }, + generateId: generateUUID, + onFinish: async ({ messages }) => { + await saveMessages({ + messages: messages.map((message) => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + })), + }); + }, + onError: () => { + return 'Oops, an error occurred!'; + }, + }); + + const streamContext = getStreamContext(); + + if (streamContext) { + return new Response( + await streamContext.resumableStream(streamId, () => + stream.pipeThrough(new JsonToSseTransformStream()), + ), + ); + } else { + return new Response(stream.pipeThrough(new JsonToSseTransformStream())); + } + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + } +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new ChatSDKError('bad_request:api').toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + const chat = await getChatById({ id }); + + if (chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:chat').toResponse(); + } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); +} diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..555ef8b95c85fc701ee0cade678027bf4051e926 --- /dev/null +++ b/app/(chat)/api/chat/schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const textPartSchema = z.object({ + type: z.enum(['text']), + text: z.string().min(1).max(2000), +}); + +const filePartSchema = z.object({ + type: z.enum(['file']), + mediaType: z.enum(['image/jpeg', 'image/png']), + name: z.string().min(1).max(100), + url: z.string().url(), +}); + +const partSchema = z.union([textPartSchema, filePartSchema]); + +export const postRequestBodySchema = z.object({ + id: z.string().uuid(), + message: z.object({ + id: z.string().uuid(), + role: z.enum(['user']), + parts: z.array(partSchema), + }), + selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']), + selectedVisibilityType: z.enum(['public', 'private']), +}); + +export type PostRequestBody = z.infer; diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..de4183673c63dbec41cd8b3301ac1e73f33cc72f --- /dev/null +++ b/app/(chat)/api/document/route.ts @@ -0,0 +1,126 @@ +import { auth } from '@/app/(auth)/auth'; +import type { ArtifactKind } from '@/components/artifact'; +import { + deleteDocumentsByIdAfterTimestamp, + getDocumentsById, + saveDocument, +} from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is missing', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:document').toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (!document) { + return new ChatSDKError('not_found:document').toResponse(); + } + + if (document.userId !== session.user.id) { + return new ChatSDKError('forbidden:document').toResponse(); + } + + return Response.json(documents, { status: 200 }); +} + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is required.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('not_found:document').toResponse(); + } + + const { + content, + title, + kind, + }: { content: string; title: string; kind: ArtifactKind } = + await request.json(); + + const documents = await getDocumentsById({ id }); + + if (documents.length > 0) { + const [document] = documents; + + if (document.userId !== session.user.id) { + return new ChatSDKError('forbidden:document').toResponse(); + } + } + + const document = await saveDocument({ + id, + content, + title, + kind, + userId: session.user.id, + }); + + return Response.json(document, { status: 200 }); +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const timestamp = searchParams.get('timestamp'); + + if (!id) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter id is required.', + ).toResponse(); + } + + if (!timestamp) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter timestamp is required.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:document').toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (document.userId !== session.user.id) { + return new ChatSDKError('forbidden:document').toResponse(); + } + + const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ + id, + timestamp: new Date(timestamp), + }); + + return Response.json(documentsDeleted, { status: 200 }); +} diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..699a4cbef89d6a650882721538e7f58df2cffc06 --- /dev/null +++ b/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,68 @@ +import { put } from '@vercel/blob'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { auth } from '@/app/(auth)/auth'; + +// Use Blob instead of File since File is not available in Node.js environment +const FileSchema = z.object({ + file: z + .instanceof(Blob) + .refine((file) => file.size <= 5 * 1024 * 1024, { + message: 'File size should be less than 5MB', + }) + // Update the file type based on the kind of files you want to accept + .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), { + message: 'File type should be JPEG or PNG', + }), +}); + +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 Blob; + + 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 }); + } + + // Get filename from formData since Blob doesn't have name property + const filename = (formData.get('file') as 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 0000000000000000000000000000000000000000..e28290c627492b3024cfdcf0b7952c25e30f0d1a --- /dev/null +++ b/app/(chat)/api/history/route.ts @@ -0,0 +1,34 @@ +import { auth } from '@/app/(auth)/auth'; +import type { NextRequest } from 'next/server'; +import { getChatsByUserId } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = Number.parseInt(searchParams.get('limit') || '10'); + const startingAfter = searchParams.get('starting_after'); + const endingBefore = searchParams.get('ending_before'); + + if (startingAfter && endingBefore) { + return new ChatSDKError( + 'bad_request:api', + 'Only one of starting_after or ending_before can be provided.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + const chats = await getChatsByUserId({ + id: session.user.id, + limit, + startingAfter, + endingBefore, + }); + + return Response.json(chats); +} diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffbda2fb3225e4d5a49be968434d00954741a4ac --- /dev/null +++ b/app/(chat)/api/suggestions/route.ts @@ -0,0 +1,37 @@ +import { auth } from '@/app/(auth)/auth'; +import { getSuggestionsByDocumentId } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const documentId = searchParams.get('documentId'); + + if (!documentId) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter documentId is required.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:suggestions').toResponse(); + } + + const suggestions = await getSuggestionsByDocumentId({ + documentId, + }); + + const [suggestion] = suggestions; + + if (!suggestion) { + return Response.json([], { status: 200 }); + } + + if (suggestion.userId !== session.user.id) { + return new ChatSDKError('forbidden:api').toResponse(); + } + + return Response.json(suggestions, { status: 200 }); +} diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3d7e45d4ff5722f13e81467f650773550367003 --- /dev/null +++ b/app/(chat)/api/vote/route.ts @@ -0,0 +1,75 @@ +import { auth } from '@/app/(auth)/auth'; +import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries'; +import { ChatSDKError } from '@/lib/errors'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get('chatId'); + + if (!chatId) { + return new ChatSDKError( + 'bad_request:api', + 'Parameter chatId is required.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:vote').toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError('not_found:chat').toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:vote').toResponse(); + } + + const votes = await getVotesByChatId({ id: chatId }); + + return Response.json(votes, { status: 200 }); +} + +export async function PATCH(request: Request) { + const { + chatId, + messageId, + type, + }: { chatId: string; messageId: string; type: 'up' | 'down' } = + await request.json(); + + if (!chatId || !messageId || !type) { + return new ChatSDKError( + 'bad_request:api', + 'Parameters chatId, messageId, and type are required.', + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError('unauthorized:vote').toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError('not_found:vote').toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError('forbidden:vote').toResponse(); + } + + await voteMessage({ + chatId, + messageId, + type: type, + }); + + return new Response('Message voted', { status: 200 }); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9dc99d0f2c2d2ac72a8e7442aceecf941fa4996 --- /dev/null +++ b/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,76 @@ +import { cookies } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; + +import { auth } from '@/app/(auth)/auth'; +import { Chat } from '@/components/chat'; +import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; +import { DataStreamHandler } from '@/components/data-stream-handler'; +import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; +import { convertToUIMessages } from '@/lib/utils'; + +export default async function Page(props: { params: Promise<{ id: string }> }) { + const params = await props.params; + const { id } = params; + const chat = await getChatById({ id }); + + if (!chat) { + notFound(); + } + + const session = await auth(); + + if (!session) { + redirect('/api/auth/guest'); + } + + if (chat.visibility === 'private') { + if (!session.user) { + return notFound(); + } + + if (session.user.id !== chat.userId) { + return notFound(); + } + } + + const messagesFromDb = await getMessagesByChatId({ + id, + }); + + const uiMessages = convertToUIMessages(messagesFromDb); + + const cookieStore = await cookies(); + const chatModelFromCookie = cookieStore.get('chat-model'); + + if (!chatModelFromCookie) { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dfd9da2fdfda2ba78029dad94afcea655e27021c --- /dev/null +++ b/app/(chat)/layout.tsx @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; + +import { AppSidebar } from '@/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { auth } from '../(auth)/auth'; +import Script from 'next/script'; +import { DataStreamProvider } from '@/components/data-stream-provider'; + +export const experimental_ppr = true; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const [session, cookieStore] = await Promise.all([auth(), cookies()]); + const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true'; + + return ( + <> +