diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac5e5ce --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +OPENAI_API_KEY=XXXXXXXX +NEXTAUTH_SECRET=XXXXXXXX diff --git a/.eslintrc.json b/.eslintrc.json index ef7fd95..6ec5479 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,25 @@ { "$schema": "https://json.schemastore.org/eslintrc", "root": true, - "extends": ["next/core-web-vitals", "prettier"] + "extends": [ + "next/core-web-vitals", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off" + }, + "settings": { + "tailwindcss": { + "callees": ["cn", "cva"], + "config": "tailwind.config.js" + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] } diff --git a/.gitignore b/.gitignore index d8de1d3..83d560e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ yarn-error.log* .contentlayer .env .vercel +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 99438eb..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} diff --git a/README.md b/README.md index a9b83db..247c999 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ # Next.js AI Chatbot -A an AI-powered Chatbot built with Next.js built by Vercel Labs. +A Next.js 13 and App Router-ready AI chatbot template featuring: + +- [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, HuggingFace, or custom AI chat models and/or LangChain +- Edge runtime-ready +- [Shadcn UI](https://ui.shadcn.com) + - Styling with [Tailwind CSS](https://tailwindcss.com) + - [Radix UI](https://radix-ui.com) for headless component primitives +- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) +- [Next Auth](https://github.com/nextauthjs/next-auth) for 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), switching LLM providers to [Anthropic](https://anthropic.com), [HuggingFace](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. + +## 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/concepts/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 OpenAI 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 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)) - [Contractor](https://shadcn.com) + +[Contributors](https://github.com/vercel-labs/ai-chatbot/graphs/contributors) diff --git a/app/actions.ts b/app/actions.ts index aa4c98f..3d40e88 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -1,7 +1,44 @@ 'use server' -import { kv } from '@vercel/kv' import { revalidatePath } from 'next/cache' +import { kv } from '@vercel/kv' + +import { type Chat } from '@/lib/types' + +export async function getChats(userId?: string | null) { + if (!userId) { + return [] + } + + try { + const pipeline = kv.pipeline() + const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1) + + 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 chat = await kv.hgetall(`chat:${id}`) + + if (!chat) { + throw new Error('Not found') + } + + if (userId && chat.userId !== userId) { + throw new Error('Unauthorized') + } + + return chat +} export async function removeChat({ id, diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 883210b..4463a8e 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,2 +1,2 @@ export { GET, POST } from '@/auth' -export const runtime = 'edge' +// export const runtime = 'edge' diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 710194d..515d947 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,10 +1,11 @@ import { auth } from '@/auth' import { kv } from '@vercel/kv' -import { nanoid } from '@/lib/utils' import { OpenAIStream, StreamingTextResponse } from 'ai-connector' import { Configuration, OpenAIApi } from 'openai-edge' -export const runtime = 'edge' +import { nanoid } from '@/lib/utils' + +// export const runtime = 'edge' const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY diff --git a/app/chat-list.tsx b/app/chat-list.tsx deleted file mode 100644 index 7675690..0000000 --- a/app/chat-list.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import { type Message } from 'ai-connector' - -import { ChatMessage } from './chat-message' -import { NextChatLogo } from '@/components/ui/nextchat-logo' -import { Plus } from 'lucide-react' -import { EmptyScreen } from './empty' - -export interface ChatList { - messages: Message[] -} - -export function ChatList({ messages }: ChatList) { - return ( -
-
-
-
- - Next.js Chatbot -
-
- -
-
-
-
- {messages.length > 0 ? ( -
- {messages.map(message => ( - - ))} -
- ) : ( - - )} -
-
- ) -} diff --git a/app/chat-message.tsx b/app/chat-message.tsx deleted file mode 100644 index dab5d07..0000000 --- a/app/chat-message.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import CodeBlock from '@/components/ui/codeblock' -import { MemoizedReactMarkdown } from '@/components/markdown' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' -import { cn } from '@/lib/utils' -import { fontMessage } from '@/lib/fonts' -import { Message } from 'ai-connector' -export interface ChatMessageProps { - message: Message -} - -export function ChatMessage(props: ChatMessageProps) { - return ( -
-
-
- {props.message.role === 'user' ? ( -
-
- User -
-
- ) : ( -
- -
- )} - - ▍ - - ) - } - - children[0] = (children[0] as string).replace('`▍`', '▍') - } - - const match = /language-(\w+)/.exec(className || '') - - return !inline ? ( - - ) : ( - - {children} - - ) - }, - table({ children }) { - return ( - - {children} -
- ) - }, - th({ children }) { - return ( - - {children} - - ) - }, - td({ children }) { - return ( - - {children} - - ) - } - }} - > - {props.message.content} -
-
-
-
- ) -} - -ChatMessage.displayName = 'ChatMessage' - -export function IconOpenAI(props: JSX.IntrinsicElements['svg']) { - return ( - - OpenAI icon - - - ) -} diff --git a/app/chat.tsx b/app/chat.tsx deleted file mode 100644 index d328db2..0000000 --- a/app/chat.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client' - -import { useRouter } from 'next/navigation' -import { Prompt } from './prompt' -import { useChat, type Message } from 'ai-connector' -import { ChatList } from './chat-list' - -export interface ChatProps { - // create?: (input: string) => Chat | undefined; - initialMessages?: Message[] - id?: string -} - -export function Chat({ - id, - // create, - initialMessages -}: ChatProps) { - const router = useRouter() - - const { isLoading, messages, reload, append } = useChat({ - initialMessages: initialMessages as any[], - id - // onCreate: (id: string) => { - // router.push(`/chat/${id}`); - // }, - }) - - return ( -
-
- -
-
- { - append({ - content: value, - role: 'user' - }) - }} - onRefresh={messages.length ? reload : undefined} - isLoading={isLoading} - /> -
-
- ) -} - -Chat.displayName = 'Chat' diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 61094d6..728ef16 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,15 +1,12 @@ -import { Sidebar } from '@/app/sidebar' - -import { auth } from '@/auth' import { type Metadata } from 'next' +import { auth } from '@/auth' -import { Chat } from '@/app/chat' -import { type Chat as ChatType } from '@/lib/types' -import { kv } from '@vercel/kv' -import { Message } from 'ai-connector' +import { Chat } from '@/components/chat' +import { getChat } from '@/app/actions' -export const runtime = 'edge' +// export const runtime = 'edge' export const preferredRegion = 'home' + export interface ChatPageProps { params: { id: string @@ -30,27 +27,5 @@ export default async function ChatPage({ params }: ChatPageProps) { const session = await auth() const chat = await getChat(params.id, session?.user?.email ?? '') - return ( -
- -
- -
-
- ) -} - -ChatPage.displayName = 'ChatPage' - -async function getChat(id: string, userId: string) { - const chat = await kv.hgetall(`chat:${id}`) - if (!chat) { - throw new Error('Not found') - } - - if (userId && chat.userId !== userId) { - throw new Error('Unauthorized') - } - - return chat + return } diff --git a/app/empty.tsx b/app/empty.tsx deleted file mode 100644 index 0deaf75..0000000 --- a/app/empty.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { cn } from '@/lib/utils' -import { ExternalLink } from './external-link' -import { fontMessage } from '@/lib/fonts' - -function ExampleBubble({ children }: { children?: React.ReactNode }) { - return ( -
-
-

{children}

-
- ) -} - -export function EmptyScreen() { - return ( -
-
-
-
-

Welcome to Next.js Chatbot!

-

- This is an open source AI chatbot app built with{' '} - Next.js and{' '} - - Vercel KV - - . You can start a conversation here or try the following examples: -

-
-
-
- Explain technical concepts - Summarize article - Get assistance - Draft an email -
-
-
- ) -} diff --git a/app/globals.css b/app/globals.css index 69f3827..9beeb2c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,17 +3,76 @@ @tailwind utilities; @layer base { - #__next, - #root { - height: 100%; + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: ; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --ring: 240 5% 64.9%; + + --radius: 0.5rem; } - body, - html { - height: 100%; + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: ; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + + --ring: 240 3.7% 15.9%; } } -pre:has(div.codeblock) { - padding: 0; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } diff --git a/app/layout.tsx b/app/layout.tsx index b9e8d26..4d366d0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,11 @@ -import './globals.css' import { Metadata } from 'next' -import { ThemeProvider } from '@/components/theme-provider' +import '@/app/globals.css' import { fontMono, fontSans } from '@/lib/fonts' import { cn } from '@/lib/utils' +import { Header } from '@/components/header' +import { TailwindIndicator } from '@/components/tailwind-indicator' +import { ThemeProvider } from '@/components/theme-provider' export const metadata: Metadata = { title: { @@ -28,22 +30,24 @@ interface RootLayoutProps { export default function RootLayout({ children }: RootLayoutProps) { return ( - <> - - - - - {children} - {/* */} - - - - + + + + +
+ {/* @ts-ignore */} +
+
{children}
+
+ +
+ + ) } diff --git a/app/page.tsx b/app/page.tsx index 0804ebc..ee1ff4c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,8 @@ -import { Chat } from './chat' -import { Sidebar } from './sidebar' -import { auth } from '@/auth' +import { Chat } from '@/components/chat' -export const runtime = 'edge' +// export const runtime = 'edge' export const preferredRegion = 'home' export default async function IndexPage() { - const session = await auth() - return ( -
- -
- -
-
- ) + return } diff --git a/app/prompt.tsx b/app/prompt.tsx deleted file mode 100644 index 0c03997..0000000 --- a/app/prompt.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client' - -import { CornerDownLeft, RefreshCcw, StopCircle } from 'lucide-react' -import { useState } from 'react' -import Textarea from 'react-textarea-autosize' - -import { Button } from '@/components/ui/button' -import { fontMessage } from '@/lib/fonts' -import { useCmdEnterSubmit } from '@/lib/hooks/use-command-enter-submit' -import { cn } from '@/lib/utils' - -export interface PromptProps { - onSubmit: (value: string) => void - onRefresh?: () => void - onAbort?: () => void - isLoading: boolean -} - -export function Prompt({ - onSubmit, - onRefresh, - onAbort, - isLoading -}: PromptProps) { - const [input, setInput] = useState('') - const { formRef, onKeyDown } = useCmdEnterSubmit() - return ( -
{ - e.preventDefault() - setInput('') - await onSubmit(input) - }} - ref={formRef} - className="stretch flex w-full flex-row gap-3 md:max-w-2xl lg:max-w-xl xl:max-w-3xl mx-auto px-4" - > -
-
-
- {onRefresh ? ( - - ) : null} - {/* */} -
-
-
-