From df02a18d8c45a88b0a35aa5b6d0ffef34131a5a7 Mon Sep 17 00:00:00 2001 From: admineral <50579369+admineral@users.noreply.github.com> Date: Mon, 27 Nov 2023 19:31:17 +0100 Subject: [PATCH 01/18] Fix type mismatch in removeChat function --- app/actions.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/actions.ts b/app/actions.ts index 2c8a5ddf91..5bfc41f012 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -51,7 +51,11 @@ export async function removeChat({ id, path }: { id: string; path: string }) { const uid = await kv.hget(`chat:${id}`, 'userId') - if (uid !== session?.user?.id) { + // Convert both IDs to strings before comparing + // This is necessary because the session user ID is a string, while the chat user ID is a number + // The strict inequality check (!==) in JavaScript checks both value and type, so it would fail if the types are different + // By converting both IDs to strings, we ensure that the comparison is done between two strings, and it will work correctly even if the IDs are originally of different types + if (String(uid) !== String(session?.user?.id)) { return { error: 'Unauthorized' } From 022846e7063d69c9639f4f3f545bcf2e8cac3dec Mon Sep 17 00:00:00 2001 From: admineral <50579369+admineral@users.noreply.github.com> Date: Mon, 27 Nov 2023 20:50:38 +0100 Subject: [PATCH 02/18] Fix Unauthorized error by ensuring user ID in JWT token is a string --- app/actions.ts | 6 +----- auth.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/actions.ts b/app/actions.ts index 5bfc41f012..2c8a5ddf91 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -51,11 +51,7 @@ export async function removeChat({ id, path }: { id: string; path: string }) { const uid = await kv.hget(`chat:${id}`, 'userId') - // Convert both IDs to strings before comparing - // This is necessary because the session user ID is a string, while the chat user ID is a number - // The strict inequality check (!==) in JavaScript checks both value and type, so it would fail if the types are different - // By converting both IDs to strings, we ensure that the comparison is done between two strings, and it will work correctly even if the IDs are originally of different types - if (String(uid) !== String(session?.user?.id)) { + if (uid !== session?.user?.id) { return { error: 'Unauthorized' } diff --git a/auth.ts b/auth.ts index 7c0c6a03f1..5e1cf7b5ac 100644 --- a/auth.ts +++ b/auth.ts @@ -18,7 +18,7 @@ export const { callbacks: { jwt({ token, profile }) { if (profile) { - token.id = profile.id + token.id = String(profile.id); // Convert profile.id to a string token.image = profile.avatar_url || profile.picture } return token From 39d971ecb635aada59456e50fd5403b267c0311e Mon Sep 17 00:00:00 2001 From: admineral <50579369+admineral@users.noreply.github.com> Date: Mon, 27 Nov 2023 20:55:14 +0100 Subject: [PATCH 03/18] Fix Unauthorized error by ensuring user ID in JWT token is a string --- auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth.ts b/auth.ts index 5e1cf7b5ac..a9f8abbcc6 100644 --- a/auth.ts +++ b/auth.ts @@ -25,7 +25,7 @@ export const { }, session: ({ session, token }) => { if (session?.user && token?.id) { - session.user.id = String(token.id) + session.user.id = String(token.id); // Convert token.id to a string } return session }, From 18576f43b9959a224dd7965556628357b19f350e Mon Sep 17 00:00:00 2001 From: admineral <50579369+admineral@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:14:06 +0100 Subject: [PATCH 04/18] Convert uid to string for consistent comparison with session.user.id --- app/actions.ts | 3 ++- auth.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/actions.ts b/app/actions.ts index 2c8a5ddf91..7fe08a74d5 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -49,7 +49,8 @@ export async function removeChat({ id, path }: { id: string; path: string }) { } } - const uid = await kv.hget(`chat:${id}`, 'userId') + //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 { diff --git a/auth.ts b/auth.ts index a9f8abbcc6..7c0c6a03f1 100644 --- a/auth.ts +++ b/auth.ts @@ -18,14 +18,14 @@ export const { callbacks: { jwt({ token, profile }) { if (profile) { - token.id = String(profile.id); // Convert profile.id to a string + token.id = profile.id token.image = profile.avatar_url || profile.picture } return token }, session: ({ session, token }) => { if (session?.user && token?.id) { - session.user.id = String(token.id); // Convert token.id to a string + session.user.id = String(token.id) } return session }, From e85ba803dd3d92415e5efaaf7b83b15b5033f75b Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 14 Mar 2024 20:00:52 +0300 Subject: [PATCH 05/18] Refactor to use ai/rsc (#253) --- .env.example | 16 +- README.md | 11 +- app/(chat)/chat/[id]/page.tsx | 25 +- app/(chat)/page.tsx | 18 +- app/actions.ts | 27 ++ app/api/auth/[...nextauth]/route.ts | 2 - app/api/chat/route.ts | 65 --- app/globals.css | 74 ++-- app/layout.tsx | 4 +- app/login/actions.ts | 50 +++ app/login/page.tsx | 18 + app/share/[id]/page.tsx | 16 +- app/sign-in/page.tsx | 17 - app/signup/actions.ts | 75 ++++ app/signup/page.tsx | 18 + auth.config.ts | 42 ++ auth.ts | 91 ++-- components.json | 17 + components/chat-history.tsx | 5 +- components/chat-list.tsx | 14 +- components/chat-panel.tsx | 180 ++++---- components/chat-scroll-anchor.tsx | 3 +- components/chat-share-dialog.tsx | 15 +- components/chat.tsx | 124 ++---- components/clear-history.tsx | 18 +- components/empty-screen.tsx | 33 +- components/header.tsx | 9 +- components/login-form.tsx | 93 +++++ components/prompt-form.tsx | 96 +++-- components/sidebar-actions.tsx | 8 +- components/sidebar-mobile.tsx | 5 +- components/signup-form.tsx | 94 +++++ components/stocks/event.tsx | 30 ++ components/stocks/events-skeleton.tsx | 31 ++ components/stocks/index.tsx | 38 ++ components/stocks/message.tsx | 134 ++++++ components/stocks/spinner.tsx | 16 + components/stocks/stock-purchase.tsx | 146 +++++++ components/stocks/stock-skeleton.tsx | 22 + components/stocks/stock.tsx | 210 ++++++++++ components/stocks/stocks-skeleton.tsx | 9 + components/stocks/stocks.tsx | 59 +++ components/ui/alert-dialog.tsx | 32 +- components/ui/badge.tsx | 22 +- components/ui/button.tsx | 22 +- components/ui/codeblock.tsx | 2 +- components/ui/dialog.tsx | 32 +- components/ui/dropdown-menu.tsx | 105 ++++- components/ui/input.tsx | 8 +- components/ui/select.tsx | 87 +++- components/ui/separator.tsx | 14 +- components/ui/sheet.tsx | 48 +-- components/ui/sonner.tsx | 31 ++ components/ui/switch.tsx | 12 +- components/ui/textarea.tsx | 8 +- components/ui/tooltip.tsx | 10 +- components/user-menu.tsx | 60 +-- lib/chat/actions.tsx | 509 +++++++++++++++++++++++ lib/hooks/use-streamable-text.ts | 26 ++ lib/types.ts | 14 +- lib/utils.ts | 20 + middleware.ts | 7 +- package.json | 16 +- pnpm-lock.yaml | 398 ++++++++++++++---- scripts/seed.mjs | 34 ++ tailwind.config.js => tailwind.config.ts | 36 +- 66 files changed, 2795 insertions(+), 736 deletions(-) delete mode 100644 app/api/auth/[...nextauth]/route.ts delete mode 100644 app/api/chat/route.ts create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx delete mode 100644 app/sign-in/page.tsx create mode 100644 app/signup/actions.ts create mode 100644 app/signup/page.tsx create mode 100644 auth.config.ts create mode 100644 components.json create mode 100644 components/login-form.tsx create mode 100644 components/signup-form.tsx create mode 100644 components/stocks/event.tsx create mode 100644 components/stocks/events-skeleton.tsx create mode 100644 components/stocks/index.tsx create mode 100644 components/stocks/message.tsx create mode 100644 components/stocks/spinner.tsx create mode 100644 components/stocks/stock-purchase.tsx create mode 100644 components/stocks/stock-skeleton.tsx create mode 100644 components/stocks/stock.tsx create mode 100644 components/stocks/stocks-skeleton.tsx create mode 100644 components/stocks/stocks.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 lib/chat/actions.tsx create mode 100644 lib/hooks/use-streamable-text.ts create mode 100644 scripts/seed.mjs rename tailwind.config.js => tailwind.config.ts (72%) diff --git a/.env.example b/.env.example index 67f39f7fbd..228fab9864 100644 --- a/.env.example +++ b/.env.example @@ -4,13 +4,6 @@ OPENAI_API_KEY=XXXXXXXX # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` AUTH_SECRET=XXXXXXXX -# Create a GitHub OAuth app here: https://github.com/settings/applications/new -# For info on authorization callback URL visit here: https://authjs.dev/reference/core/providers_github#callback-url -AUTH_GITHUB_ID=XXXXXXXX -AUTH_GITHUB_SECRET=XXXXXXXX -# Support OAuth login on preview deployments, see: https://authjs.dev/guides/basics/deployment#securing-a-preview-deployment -# Set the following only when deployed. In this example, we can reuse the same OAuth app, but if you are storing users, we recommend using a different OAuth app for development/production so that you don't mix your test and production user base. -# AUTH_REDIRECT_PROXY_URL=https://YOURAPP.vercel.app/api/auth # Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and KV_URL=XXXXXXXX @@ -18,3 +11,12 @@ KV_REST_API_URL=XXXXXXXX KV_REST_API_TOKEN=XXXXXXXX KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX +# Instructions to create postgres database here: https://vercel.com/docs/storage/vercel-postgres/quickstart and +POSTGRES_URL=XXXXXXXX +POSTGRES_PRISMA_URL=XXXXXXXX +POSTGRES_URL_NO_SSL=XXXXXXXX +POSTGRES_URL_NON_POOLING=XXXXXXXX +POSTGRES_USER=XXXXXXXX +POSTGRES_HOST=XXXXXXXX +POSTGRES_PASSWORD=XXXXXXXX +POSTGRES_DATABASE=XXXXXXXX diff --git a/README.md b/README.md index 9884fa3692..486f804565 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - 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) +- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) and [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) - [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication ## Model Providers @@ -37,7 +37,7 @@ This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks 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_GITHUB_ID%2CAUTH_GITHUB_SECRET%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"}]) +[![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"},{"type":"postgres"}]) ## Creating a KV Database Instance @@ -45,6 +45,12 @@ Follow the steps outlined in the [quick start guide](https://vercel.com/docs/sto 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. +## Create a Postgres Database Instance + +Similarly, follow the steps outline in the [quick start guide](https://vercel.com/docs/storage/vercel-postgres/quickstart) provided by Vercel. This guide will assist you in creating and configuring your Postgres database instance on Vercel, enabling your application to interact with it. + +Remember to update your environment variables (`POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, `POSTGRES_HOST`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`) in the `.env` file with the appropriate credentials provided during the Postgres database setup. + ## 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. @@ -57,6 +63,7 @@ You will need to use the environment variables [defined in `.env.example`](.env. ```bash pnpm install +pnpm seed pnpm dev ``` diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 0ca46b1398..c8de380442 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -2,8 +2,10 @@ import { type Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { auth } from '@/auth' -import { getChat } from '@/app/actions' +import { getChat, getMissingKeys } from '@/app/actions' import { Chat } from '@/components/chat' +import { AI } from '@/lib/chat/actions' +import { Session } from '@/lib/types' export interface ChatPageProps { params: { @@ -27,21 +29,32 @@ export async function generateMetadata({ } export default async function ChatPage({ params }: ChatPageProps) { - const session = await auth() + const session = (await auth()) as Session + const missingKeys = await getMissingKeys() if (!session?.user) { - redirect(`/sign-in?next=/chat/${params.id}`) + redirect(`/login?next=/chat/${params.id}`) } - const chat = await getChat(params.id, session.user.id) + const userId = session.user.id as string + const chat = await getChat(params.id, userId) if (!chat) { - notFound() + redirect('/') } if (chat?.userId !== session?.user?.id) { notFound() } - return + return ( + + + + ) } diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index c464137e5f..51bdedaedc 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,8 +1,22 @@ 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 '../actions' -export default function IndexPage() { +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 + return ( + + + + ) } diff --git a/app/actions.ts b/app/actions.ts index 7dedef78cd..c624f561ee 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -127,3 +127,30 @@ export async function shareChat(id: string) { 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/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 883210bb21..0000000000 --- a/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GET, POST } from '@/auth' -export const runtime = 'edge' diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts deleted file mode 100644 index 8d9b3c5466..0000000000 --- a/app/api/chat/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { kv } from '@vercel/kv' -import { OpenAIStream, StreamingTextResponse } from 'ai' -import OpenAI from 'openai' - -import { auth } from '@/auth' -import { nanoid } from '@/lib/utils' - -export const runtime = 'edge' - -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY -}) - -export async function POST(req: Request) { - const json = await req.json() - const { messages, previewToken } = json - const userId = (await auth())?.user.id - - if (!userId) { - return new Response('Unauthorized', { - status: 401 - }) - } - - if (previewToken) { - openai.apiKey = previewToken - } - - const res = await openai.chat.completions.create({ - model: 'gpt-3.5-turbo', - messages, - temperature: 0.7, - stream: true - }) - - const stream = OpenAIStream(res, { - async onCompletion(completion) { - const title = json.messages[0].content.substring(0, 100) - const id = json.id ?? nanoid() - const createdAt = Date.now() - const path = `/chat/${id}` - const payload = { - id, - title, - userId, - createdAt, - path, - messages: [ - ...messages, - { - content: completion, - role: 'assistant' - } - ] - } - await kv.hmset(`chat:${id}`, payload) - await kv.zadd(`user:chat:${userId}`, { - score: createdAt, - member: `chat:${id}` - }) - } - }) - - return new StreamingTextResponse(stream) -} diff --git a/app/globals.css b/app/globals.css index 9beeb2c943..0b46ea13c8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,73 +1,71 @@ @tailwind base; @tailwind components; @tailwind utilities; - + @layer base { :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%; - + + --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: ; - + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --ring: 240 5% 64.9%; - + --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%; - - --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%; - + + --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: ; - + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; - - --ring: 240 3.7% 15.9%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; } } - + @layer base { * { @apply border-border; @@ -75,4 +73,4 @@ body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index d9d70d91e2..638dbbf0a9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,3 @@ -import { Toaster } from 'react-hot-toast' import { GeistSans } from 'geist/font/sans' import { GeistMono } from 'geist/font/mono' @@ -7,6 +6,7 @@ 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' export const metadata = { metadataBase: new URL(`https://${process.env.VERCEL_URL}`), @@ -43,7 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) { GeistMono.variable )} > - + + + + ) +} diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 638fed5b5d..8a3361dd26 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -1,10 +1,14 @@ import { type Metadata } from 'next' -import { notFound } from 'next/navigation' +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: { @@ -29,11 +33,13 @@ export default async function SharePage({ params }: SharePageProps) { notFound() } + const uiState: UIState = getUIStateFromAIState(chat) + return ( <>
-
-
+
+

{chat.title}

@@ -42,7 +48,9 @@ export default async function SharePage({ params }: SharePageProps) {
- + + +
diff --git a/app/sign-in/page.tsx b/app/sign-in/page.tsx deleted file mode 100644 index a1383fffd0..0000000000 --- a/app/sign-in/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { auth } from '@/auth' -import { LoginButton } from '@/components/login-button' -import { redirect } from 'next/navigation' - -export default async function SignInPage() { - const session = await auth() - // redirect to home if user is already logged in - if (session?.user) { - redirect('/') - } - - return ( -
- -
- ) -} diff --git a/app/signup/actions.ts b/app/signup/actions.ts new file mode 100644 index 0000000000..7bddb74888 --- /dev/null +++ b/app/signup/actions.ts @@ -0,0 +1,75 @@ +'use server' + +import { signIn } from '@/auth' +import { db } from '@vercel/postgres' +import { getStringFromBuffer } from '@/lib/utils' +import { z } from 'zod' +import { AuthResult } from '@/lib/types' + +export async function signup( + _prevState: AuthResult | undefined, + formData: FormData +) { + 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) + + const client = await db.connect() + + try { + await client.sql` + INSERT INTO users (email, password, salt) + VALUES (${email}, ${hashedPassword}, ${salt}) + ON CONFLICT (id) DO NOTHING; + ` + + await signIn('credentials', { + email, + password, + redirect: false + }) + + return { type: 'success', message: 'Account created!' } + } catch (error) { + const { message } = error as Error + + if ( + message.startsWith('duplicate key value violates unique constraint') + ) { + return { type: 'error', message: 'User already exists! Please log in.' } + } else { + return { + type: 'error', + message: 'Something went wrong! Please try again.' + } + } + } finally { + client.release() + } + } else { + return { + type: 'error', + message: 'Invalid entries, please try again!' + } + } +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000000..dbac96428e --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,18 @@ +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/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000000..6e74c18bbd --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,42 @@ +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 index 7c0c6a03f1..882cc95e48 100644 --- a/auth.ts +++ b/auth.ts @@ -1,39 +1,62 @@ -import NextAuth, { type DefaultSession } from 'next-auth' -import GitHub from 'next-auth/providers/github' - -declare module 'next-auth' { - interface Session { - user: { - /** The user's id. */ - id: string - } & DefaultSession['user'] +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' +import { authConfig } from './auth.config' +import { z } from 'zod' +import { sql } from '@vercel/postgres' +import { getStringFromBuffer } from './lib/utils' + +interface User { + id: string + name: string + email: string + password: string + salt: string +} + +async function getUser(email: string): Promise { + try { + const user = await sql`SELECT * FROM users WHERE email=${email}` + return user.rows[0] + } catch (error) { + throw new Error('Failed to fetch user.') } } -export const { - handlers: { GET, POST }, - auth -} = NextAuth({ - providers: [GitHub], - callbacks: { - jwt({ token, profile }) { - if (profile) { - token.id = profile.id - token.image = profile.avatar_url || profile.picture - } - return token - }, - session: ({ session, token }) => { - if (session?.user && token?.id) { - session.user.id = String(token.id) +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 } - return session - }, - authorized({ auth }) { - return !!auth?.user // this ensures there is a logged in user for -every- request - } - }, - pages: { - signIn: '/sign-in' // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages - } + }) + ] }) diff --git a/components.json b/components.json new file mode 100644 index 0000000000..58b812d037 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/chat-history.tsx b/components/chat-history.tsx index 7450fe8cb7..88b6ff440e 100644 --- a/components/chat-history.tsx +++ b/components/chat-history.tsx @@ -14,7 +14,10 @@ interface ChatHistoryProps { export async function ChatHistory({ userId }: ChatHistoryProps) { return (
-
+
+

Chat History

+
+
{messages.map((message, index) => ( -
- - {index < messages.length - 1 && ( - - )} +
+ {message.display} + {index < messages.length - 1 && }
))}
diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c92844a9fa..0f093a4a7a 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -1,102 +1,124 @@ import * as React from 'react' -import { type UseChatHelpers } from 'ai/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 { IconRefresh, IconShare, IconStop } from '@/components/ui/icons' +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 - extends Pick< - UseChatHelpers, - | 'append' - | 'isLoading' - | 'reload' - | 'messages' - | 'stop' - | 'input' - | 'setInput' - > { +export interface ChatPanelProps { id?: string title?: string + input: string + setInput: (value: string) => void } -export function ChatPanel({ - id, - title, - isLoading, - stop, - append, - reload, - input, - setInput, - messages -}: ChatPanelProps) { +export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) { + const [aiState] = useAIState() + const [messages, setMessages] = useUIState() + const { submitUserMessage } = useActions() const [shareDialogOpen, setShareDialogOpen] = React.useState(false) + const exampleMessages = [ + { + heading: 'Explain the concept', + subheading: 'of a serverless function', + message: `Explain the concept of a serverless function` + }, + { + heading: 'What are the benefits', + subheading: 'of using turborepo in my codebase?', + message: 'What are the benefits of using turborepo in my codebase?' + }, + { + heading: 'List differences between', + subheading: 'pages and app router in Next.js', + message: `List differences between pages and app router in Next.js` + }, + { + heading: 'What is the price', + subheading: `of VRCL in the stock market?`, + message: `What is the price of VRCL in the stock market?` + } + ] + return ( -
+
+
-
- {isLoading ? ( - - ) : ( - messages?.length >= 2 && ( -
- - {id && title ? ( - <> - - setShareDialogOpen(false)} - shareChat={shareChat} - chat={{ - id, - title, - messages - }} - /> - - ) : null} +
+ {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} +
- ) - )} + ))}
-
- { - await append({ - id, - content: value, - role: 'user' - }) - }} - input={input} - setInput={setInput} - isLoading={isLoading} - /> + + {messages?.length >= 2 ? ( +
+
+ {id && title ? ( + <> + + setShareDialogOpen(false)} + shareChat={shareChat} + chat={{ + id, + title, + messages: aiState.messages + }} + /> + + ) : null} +
+
+ ) : null} + +
+
diff --git a/components/chat-scroll-anchor.tsx b/components/chat-scroll-anchor.tsx index ac809f4486..29b6c21306 100644 --- a/components/chat-scroll-anchor.tsx +++ b/components/chat-scroll-anchor.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import { useInView } from 'react-intersection-observer' - import { useAtBottom } from '@/lib/hooks/use-at-bottom' interface ChatScrollAnchorProps { @@ -14,7 +13,7 @@ export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { const { ref, entry, inView } = useInView({ trackVisibility, delay: 100, - rootMargin: '0px 0px -150px 0px' + rootMargin: '0px 0px -125px 0px' }) React.useEffect(() => { diff --git a/components/chat-share-dialog.tsx b/components/chat-share-dialog.tsx index 83bac71cbf..2e5f8e27b1 100644 --- a/components/chat-share-dialog.tsx +++ b/components/chat-share-dialog.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { type DialogProps } from '@radix-ui/react-dialog' -import { toast } from 'react-hot-toast' +import { toast } from 'sonner' import { ServerActionResult, type Chat } from '@/lib/types' import { Button } from '@/components/ui/button' @@ -42,18 +42,7 @@ export function ChatShareDialog({ url.pathname = chat.sharePath copyToClipboard(url.toString()) onCopy() - toast.success('Share link copied to clipboard', { - style: { - borderRadius: '10px', - background: '#333', - color: '#fff', - fontSize: '14px' - }, - iconTheme: { - primary: 'white', - secondary: 'black' - } - }) + toast.success('Share link copied to clipboard') }, [copyToClipboard, onCopy] ) diff --git a/components/chat.tsx b/components/chat.tsx index e8d85b3f66..27e31976c4 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,61 +1,60 @@ 'use client' -import { useChat, type Message } from 'ai/react' - import { cn } from '@/lib/utils' import { ChatList } from '@/components/chat-list' import { ChatPanel } from '@/components/chat-panel' import { EmptyScreen } from '@/components/empty-screen' import { ChatScrollAnchor } from '@/components/chat-scroll-anchor' import { useLocalStorage } from '@/lib/hooks/use-local-storage' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { useState } from 'react' -import { Button } from './ui/button' -import { Input } from './ui/input' -import { toast } from 'react-hot-toast' +import { useEffect, useState } from 'react' +import { useUIState, useAIState } from 'ai/rsc' +import { Session } from '@/lib/types' import { usePathname, useRouter } from 'next/navigation' +import { Message } from '@/lib/chat/actions' +import { toast } from 'sonner' -const IS_PREVIEW = process.env.VERCEL_ENV === 'preview' export interface ChatProps extends React.ComponentProps<'div'> { initialMessages?: Message[] id?: string + session?: Session + missingKeys: string[] } -export function Chat({ id, initialMessages, className }: ChatProps) { +export function Chat({ id, className, session, missingKeys }: ChatProps) { const router = useRouter() const path = usePathname() - const [previewToken, setPreviewToken] = useLocalStorage( - 'ai-token', - null - ) - const [previewTokenDialog, setPreviewTokenDialog] = useState(IS_PREVIEW) - const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '') - const { messages, append, reload, stop, isLoading, input, setInput } = - useChat({ - initialMessages, - id, - body: { - id, - previewToken - }, - onResponse(response) { - if (response.status === 401) { - toast.error(response.statusText) - } - }, - onFinish() { - if (!path.includes('chat')) { - window.history.pushState({}, '', `/chat/${id}`) - } + const [input, setInput] = useState('') + const [messages] = useUIState() + const [aiState] = useAIState() + const isLoading = true + + 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]) + return ( <>
@@ -68,52 +67,7 @@ export function Chat({ id, initialMessages, className }: ChatProps) { )}
- - - - - - Enter your OpenAI Key - - If you have not obtained your OpenAI API key, you can do so by{' '} - - signing up - {' '} - on the OpenAI website. This is only necessary for preview - environments so that the open source community can test the app. - The token will be saved to your browser's local storage under - the name ai-token. - - - setPreviewTokenInput(e.target.value)} - /> - - - - - + ) } diff --git a/components/clear-history.tsx b/components/clear-history.tsx index 553d2db3c9..69cf70ec39 100644 --- a/components/clear-history.tsx +++ b/components/clear-history.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useRouter } from 'next/navigation' -import { toast } from 'react-hot-toast' +import { toast } from 'sonner' import { ServerActionResult } from '@/lib/types' import { Button } from '@/components/ui/button' @@ -54,16 +54,14 @@ export function ClearHistory({ disabled={isPending} onClick={event => { event.preventDefault() - startTransition(() => { - clearChats().then(result => { - if (result && 'error' in result) { - toast.error(result.error) - return - } + startTransition(async () => { + const result = await clearChats() + if (result && 'error' in result) { + toast.error(result.error) + return + } - setOpen(false) - router.push('/') - }) + setOpen(false) }) }} > diff --git a/components/empty-screen.tsx b/components/empty-screen.tsx index 90859309b3..4e2eadd48b 100644 --- a/components/empty-screen.tsx +++ b/components/empty-screen.tsx @@ -22,34 +22,31 @@ const exampleMessages = [ export function EmptyScreen({ setInput }: Pick) { return (
-
-

+
+

Welcome to Next.js AI Chatbot!

-

+

This is an open source AI chatbot app template built with{' '} - Next.js and{' '} + Next.js, the{' '} + + Vercel AI SDK + + , and{' '} Vercel KV .

- You can start a conversation here or try the following examples: + It uses{' '} + + React Server Components + {' '} + to combine text with UI generated as output of the LLM. The UI state + is synced through the SDK so the model is aware of your interactions + as they happen.

-
- {exampleMessages.map((message, index) => ( - - ))} -

) diff --git a/components/header.tsx b/components/header.tsx index 3242e9a091..cdf57016b4 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -14,9 +14,10 @@ import { UserMenu } from '@/components/user-menu' import { SidebarMobile } from './sidebar-mobile' import { SidebarToggle } from './sidebar-toggle' import { ChatHistory } from './chat-history' +import { Session } from '@/lib/types' async function UserOrLogin() { - const session = await auth() + const session = (await auth()) as Session return ( <> {session?.user ? ( @@ -27,7 +28,7 @@ async function UserOrLogin() { ) : ( - + @@ -38,7 +39,7 @@ async function UserOrLogin() { ) : ( )}
@@ -65,7 +66,7 @@ export function Header() { GitHub diff --git a/components/login-form.tsx b/components/login-form.tsx new file mode 100644 index 0000000000..a9269af1fc --- /dev/null +++ b/components/login-form.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useFormState, useFormStatus } from 'react-dom' +import { authenticate } from '@/app/login/actions' +import Link from 'next/link' +import { useEffect } from 'react' +import { toast } from 'sonner' +import { IconSpinner } from './ui/icons' + +export default function LoginForm() { + const [result, dispatch] = useFormState(authenticate, undefined) + + useEffect(() => { + if (result) { + if (result.type === 'error') { + toast.error(result.message) + } else { + toast.success(result.message) + } + } + }, [result]) + + return ( +
+
+

Please log in to continue.

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + + No account yet?
Sign up
+ +
+ ) +} + +function LoginButton() { + const { pending } = useFormStatus() + + return ( + + ) +} diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx index 1b2011ae91..ac74d65c5d 100644 --- a/components/prompt-form.tsx +++ b/components/prompt-form.tsx @@ -1,32 +1,36 @@ +'use client' + import * as React from 'react' import Textarea from 'react-textarea-autosize' -import { UseChatHelpers } from 'ai/react' -import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' -import { cn } from '@/lib/utils' -import { Button, buttonVariants } from '@/components/ui/button' + +import { useActions, useUIState } from 'ai/rsc' + +import { UserMessage } from './stocks/message' +import { type AI } from '@/lib/chat/actions' +import { Button } from '@/components/ui/button' +import { IconArrowElbow, IconPlus } from '@/components/ui/icons' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { IconArrowElbow, IconPlus } from '@/components/ui/icons' +import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' +import { nanoid } from 'nanoid' import { useRouter } from 'next/navigation' -export interface PromptProps - extends Pick { - onSubmit: (value: string) => void - isLoading: boolean -} - export function PromptForm({ - onSubmit, input, - setInput, - isLoading -}: PromptProps) { + setInput +}: { + input: string + setInput: (value: string) => void +}) { + const router = useRouter() const { formRef, onKeyDown } = useEnterSubmit() const inputRef = React.useRef(null) - const router = useRouter() + const { submitUserMessage } = useActions() + const [_, setMessages] = useUIState() + React.useEffect(() => { if (inputRef.current) { inputRef.current.focus() @@ -35,33 +39,47 @@ export function PromptForm({ return (
{ + ref={formRef} + onSubmit={async (e: any) => { e.preventDefault() - if (!input?.trim()) { - return + + // Blur focus on mobile + if (window.innerWidth < 600) { + e.target['message']?.blur() } + + const value = input.trim() setInput('') - await onSubmit(input) + if (!value) return + + // Optimistically add user message UI + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + display: {value} + } + ]) + + // Submit and get response message + const responseMessage = await submitUserMessage(value) + setMessages(currentMessages => [...currentMessages, responseMessage]) }} - ref={formRef} > -
+
- + New Chat @@ -69,21 +87,21 @@ export function PromptForm({ ref={inputRef} tabIndex={0} onKeyDown={onKeyDown} + placeholder="Send a message." + className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm" + autoFocus + spellCheck={false} + autoComplete="off" + autoCorrect="off" + name="message" rows={1} value={input} onChange={e => setInput(e.target.value)} - placeholder="Send a message." - spellCheck={false} - className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm" />
- diff --git a/components/sidebar-actions.tsx b/components/sidebar-actions.tsx index bfcde3a646..4f90f2d5da 100644 --- a/components/sidebar-actions.tsx +++ b/components/sidebar-actions.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation' import * as React from 'react' -import { toast } from 'react-hot-toast' +import { toast } from 'sonner' import { ServerActionResult, type Chat } from '@/lib/types' import { @@ -42,12 +42,12 @@ export function SidebarActions({ return ( <> -
+
- + {children} diff --git a/components/signup-form.tsx b/components/signup-form.tsx new file mode 100644 index 0000000000..a2e9aef95d --- /dev/null +++ b/components/signup-form.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useFormState, useFormStatus } from 'react-dom' +import { signup } from '@/app/signup/actions' +import Link from 'next/link' +import { useEffect } from 'react' +import { toast } from 'sonner' +import { IconSpinner } from './ui/icons' +import { useRouter } from 'next/navigation' + +export default function SignupForm() { + const router = useRouter() + const [result, dispatch] = useFormState(signup, undefined) + + useEffect(() => { + if (result) { + if (result.type === 'error') { + toast.error(result.message) + } else { + router.refresh() + toast.success(result.message) + } + } + }, [result, router]) + + return ( + +
+

Sign up for an account!

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + + Already have an account? +
Log in
+ + + ) +} + +function LoginButton() { + const { pending } = useFormStatus() + + return ( + + ) +} diff --git a/components/stocks/event.tsx b/components/stocks/event.tsx new file mode 100644 index 0000000000..2b7d7ab967 --- /dev/null +++ b/components/stocks/event.tsx @@ -0,0 +1,30 @@ +import { format, parseISO } from 'date-fns' + +interface Event { + date: string + headline: string + description: string +} + +export function Events({ props: events }: { props: Event[] }) { + return ( +
+ {events.map(event => ( +
+
+ {format(parseISO(event.date), 'dd LLL, yyyy')} +
+
+ {event.headline} +
+
+ {event.description.slice(0, 70)}... +
+
+ ))} +
+ ) +} diff --git a/components/stocks/events-skeleton.tsx b/components/stocks/events-skeleton.tsx new file mode 100644 index 0000000000..8b109b21b1 --- /dev/null +++ b/components/stocks/events-skeleton.tsx @@ -0,0 +1,31 @@ +const placeholderEvents = [ + { + date: '2022-10-01', + headline: 'NVIDIA releases new AI-powered graphics card', + description: + 'NVIDIA unveils the latest graphics card infused with AI capabilities, revolutionizing gaming and rendering experiences.' + } +] + +export const EventsSkeleton = ({ events = placeholderEvents }) => { + return ( +
+ {placeholderEvents.map(event => ( +
+
+ {event.date} +
+
+ {event.headline} +
+
+ {event.description.slice(0, 70)}... +
+
+ ))} +
+ ) +} diff --git a/components/stocks/index.tsx b/components/stocks/index.tsx new file mode 100644 index 0000000000..15d167c191 --- /dev/null +++ b/components/stocks/index.tsx @@ -0,0 +1,38 @@ +'use client' + +import dynamic from 'next/dynamic' +import { StockSkeleton } from './stock-skeleton' +import { StocksSkeleton } from './stocks-skeleton' +import { EventsSkeleton } from './events-skeleton' + +export { spinner } from './spinner' +export { BotCard, BotMessage, SystemMessage } from './message' + +const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), { + ssr: false, + loading: () => +}) + +const Purchase = dynamic( + () => import('./stock-purchase').then(mod => mod.Purchase), + { + ssr: false, + loading: () => ( +
+ Loading stock info... +
+ ) + } +) + +const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), { + ssr: false, + loading: () => +}) + +const Events = dynamic(() => import('./event').then(mod => mod.Events), { + ssr: false, + loading: () => +}) + +export { Stock, Purchase, Stocks, Events } diff --git a/components/stocks/message.tsx b/components/stocks/message.tsx new file mode 100644 index 0000000000..bba6724cbc --- /dev/null +++ b/components/stocks/message.tsx @@ -0,0 +1,134 @@ +'use client' + +import { IconOpenAI, IconUser } from '@/components/ui/icons' +import { cn } from '@/lib/utils' +import { spinner } from './spinner' +import { CodeBlock } from '../ui/codeblock' +import { MemoizedReactMarkdown } from '../markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import { StreamableValue } from 'ai/rsc' +import { useStreamableText } from '@/lib/hooks/use-streamable-text' + +// Different types of message bubbles. + +export function UserMessage({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
+ {children} +
+
+ ) +} + +export function BotMessage({ + content, + className +}: { + content: string | StreamableValue + className?: string +}) { + const text = useStreamableText(content) + + return ( +
+
+ +
+
+ {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 ( + + ) + } + }} + > + {text} +
+
+
+ ) +} + +export function BotCard({ + children, + showAvatar = true +}: { + children: React.ReactNode + showAvatar?: boolean +}) { + return ( +
+
+ +
+
{children}
+
+ ) +} + +export function SystemMessage({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ) +} + +export function SpinnerMessage() { + return ( +
+
+ +
+
+ {spinner} +
+
+ ) +} diff --git a/components/stocks/spinner.tsx b/components/stocks/spinner.tsx new file mode 100644 index 0000000000..8eab1d31b5 --- /dev/null +++ b/components/stocks/spinner.tsx @@ -0,0 +1,16 @@ +'use client' + +export const spinner = ( + + + +) diff --git a/components/stocks/stock-purchase.tsx b/components/stocks/stock-purchase.tsx new file mode 100644 index 0000000000..5a6d3644fa --- /dev/null +++ b/components/stocks/stock-purchase.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useId, useState } from 'react' +import { useActions, useAIState, useUIState } from 'ai/rsc' +import { formatNumber } from '@/lib/utils' + +import type { AI } from '@/lib/chat/actions' + +interface Purchase { + defaultAmount?: number + name: string + price: number + status: 'requires_action' | 'completed' | 'expired' +} + +export function Purchase({ + props: { defaultAmount, name, price, status = 'expired' } +}: { + props: Purchase +}) { + const [value, setValue] = useState(defaultAmount || 100) + const [purchasingUI, setPurchasingUI] = useState(null) + const [aiState, setAIState] = useAIState() + const [, setMessages] = useUIState() + const { confirmPurchase } = useActions() + + // Unique identifier for this UI component. + const id = useId() + + // Whenever the slider changes, we need to update the local value state and the history + // so LLM also knows what's going on. + function onSliderChange(e: React.ChangeEvent) { + const newValue = Number(e.target.value) + setValue(newValue) + + // Insert a hidden history info to the list. + const message = { + role: 'system' as const, + content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${( + newValue * price + ).toFixed(2)}]`, + + // Identifier of this UI component, so we don't insert it many times. + id + } + + // If last history state is already this info, update it. This is to avoid + // adding every slider change to the history. + if (aiState.messages[aiState.messages.length - 1]?.id === id) { + setAIState({ + ...aiState, + messages: [...aiState.messages.slice(0, -1), message] + }) + + return + } + + // If it doesn't exist, append it to history. + setAIState({ ...aiState, messages: [...aiState.messages, message] }) + } + + return ( +
+
+ +1.23% ↑ +
+
{name}
+
${price}
+ {purchasingUI ? ( +
{purchasingUI}
+ ) : status === 'requires_action' ? ( + <> +
+

Shares to purchase

+ + + 10 + + + 100 + + + 500 + + + 1000 + +
+ +
+

Total cost

+
+
+ {value} + + shares + +
+
×
+ + ${price} + + per share + + +
+ = {formatNumber(value * price)} +
+
+
+ + + + ) : status === 'completed' ? ( +

+ You have successfully purchased {value} ${name}. Total cost:{' '} + {formatNumber(value * price)} +

+ ) : status === 'expired' ? ( +

Your checkout session has expired!

+ ) : null} +
+ ) +} diff --git a/components/stocks/stock-skeleton.tsx b/components/stocks/stock-skeleton.tsx new file mode 100644 index 0000000000..e7b7a93baf --- /dev/null +++ b/components/stocks/stock-skeleton.tsx @@ -0,0 +1,22 @@ +export const StockSkeleton = () => { + return ( +
+
+ xxxxxxx +
+
+ xxxx +
+
+ xxxx +
+
+ xxxxxx xxx xx xxxx xx xxx +
+ +
+
+
+
+ ) +} diff --git a/components/stocks/stock.tsx b/components/stocks/stock.tsx new file mode 100644 index 0000000000..aef249bc62 --- /dev/null +++ b/components/stocks/stock.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useState, useRef, useEffect, useId } from 'react' +import { scaleLinear } from 'd3-scale' +import { subMonths, format } from 'date-fns' +import { useResizeObserver } from 'usehooks-ts' +import { useAIState } from 'ai/rsc' + +interface Stock { + symbol: string + price: number + delta: number +} + +export function Stock({ props: { symbol, price, delta } }: { props: Stock }) { + const [aiState, setAIState] = useAIState() + const id = useId() + + const [priceAtTime, setPriceAtTime] = useState({ + time: '00:00', + value: price.toFixed(2), + x: 0 + }) + + const [startHighlight, setStartHighlight] = useState(0) + const [endHighlight, setEndHighlight] = useState(0) + + const chartRef = useRef(null) + const { width = 0 } = useResizeObserver({ + ref: chartRef, + box: 'border-box' + }) + + const xToDate = scaleLinear( + [0, width], + [subMonths(new Date(), 6), new Date()] + ) + const xToValue = scaleLinear( + [0, width], + [price - price / 2, price + price / 2] + ) + + useEffect(() => { + if (startHighlight && endHighlight) { + const message = { + id, + role: 'system' as const, + content: `[User has highlighted dates between between ${format( + xToDate(startHighlight), + 'd LLL' + )} and ${format(xToDate(endHighlight), 'd LLL, yyyy')}` + } + + if (aiState.messages[aiState.messages.length - 1]?.id === id) { + setAIState({ + ...aiState, + messages: [...aiState.messages.slice(0, -1), message] + }) + } else { + setAIState({ + ...aiState, + messages: [...aiState.messages, message] + }) + } + } + }, [startHighlight, endHighlight]) + + return ( +
+
+ {`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${ + delta > 0 ? '↑' : '↓' + }`} +
+
{symbol}
+
${price}
+
+ Closed: Feb 27, 4:59 PM EST +
+ +
{ + if (chartRef.current) { + const { clientX } = event + const { left } = chartRef.current.getBoundingClientRect() + + setStartHighlight(clientX - left) + setEndHighlight(0) + + setPriceAtTime({ + time: format(xToDate(clientX), 'dd LLL yy'), + value: xToValue(clientX).toFixed(2), + x: clientX - left + }) + } + }} + onPointerUp={event => { + if (chartRef.current) { + const { clientX } = event + const { left } = chartRef.current.getBoundingClientRect() + + setEndHighlight(clientX - left) + } + }} + onPointerMove={event => { + if (chartRef.current) { + const { clientX } = event + const { left } = chartRef.current.getBoundingClientRect() + + setPriceAtTime({ + time: format(xToDate(clientX), 'dd LLL yy'), + value: xToValue(clientX).toFixed(2), + x: clientX - left + }) + } + }} + onPointerLeave={() => { + setPriceAtTime({ + time: '00:00', + value: price.toFixed(2), + x: 0 + }) + }} + ref={chartRef} + > + {priceAtTime.x > 0 ? ( +
+
${priceAtTime.value}
+
+ {priceAtTime.time} +
+
+ ) : null} + + {startHighlight ? ( +
+ ) : null} + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/components/stocks/stocks-skeleton.tsx b/components/stocks/stocks-skeleton.tsx new file mode 100644 index 0000000000..efe5628d56 --- /dev/null +++ b/components/stocks/stocks-skeleton.tsx @@ -0,0 +1,9 @@ +export const StocksSkeleton = () => { + return ( +
+
+
+
+
+ ) +} diff --git a/components/stocks/stocks.tsx b/components/stocks/stocks.tsx new file mode 100644 index 0000000000..004ac87189 --- /dev/null +++ b/components/stocks/stocks.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useActions, useUIState } from 'ai/rsc' + +import type { AI } from '@/lib/chat/actions' + +interface Stock { + symbol: string + price: number + delta: number +} + +export function Stocks({ props: stocks }: { props: Stock[] }) { + const [, setMessages] = useUIState() + const { submitUserMessage } = useActions() + + return ( +
+ {stocks.map(stock => ( + + ))} +
+ ) +} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx index 2302ff2d3d..57760f2ee4 100644 --- a/components/ui/alert-dialog.tsx +++ b/components/ui/alert-dialog.tsx @@ -1,10 +1,10 @@ -'use client' +"use client" -import * as React from 'react' -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" -import { cn } from '@/lib/utils' -import { buttonVariants } from '@/components/ui/button' +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" const AlertDialog = AlertDialogPrimitive.Root @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( ) => (
) -AlertDialogHeader.displayName = 'AlertDialogHeader' +AlertDialogHeader.displayName = "AlertDialogHeader" const AlertDialogFooter = ({ className, @@ -65,13 +65,13 @@ const AlertDialogFooter = ({ }: React.HTMLAttributes) => (
) -AlertDialogFooter.displayName = 'AlertDialogFooter' +AlertDialogFooter.displayName = "AlertDialogFooter" const AlertDialogTitle = React.forwardRef< React.ElementRef, @@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -91,7 +91,7 @@ const AlertDialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -117,8 +117,8 @@ const AlertDialogCancel = React.forwardRef< = memo(({ language, value }) => { padding: '1.5rem 1rem' }} lineNumberStyle={{ - userSelect: "none", + userSelect: 'none' }} codeTagProps={{ style: { diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 7649a101a3..5ad11c87d8 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -1,10 +1,10 @@ -'use client' +"use client" -import * as React from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" -import { cn } from '@/lib/utils' -import { IconClose } from '@/components/ui/icons' +import { cn } from "@/lib/utils" const Dialog = DialogPrimitive.Root @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< {children} - + Close @@ -59,13 +59,13 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (
) -DialogHeader.displayName = 'DialogHeader' +DialogHeader.displayName = "DialogHeader" const DialogFooter = ({ className, @@ -73,13 +73,13 @@ const DialogFooter = ({ }: React.HTMLAttributes) => (
) -DialogFooter.displayName = 'DialogFooter' +DialogFooter.displayName = "DialogFooter" const DialogTitle = React.forwardRef< React.ElementRef, @@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef< (({ className, ...props }, ref) => ( )) @@ -112,11 +112,11 @@ export { Dialog, DialogPortal, DialogOverlay, - DialogClose, DialogTrigger, + DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, - DialogDescription + DialogDescription, } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index 184d4e6007..1ff35de900 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,9 +1,14 @@ -'use client' +"use client" -import * as React from 'react' -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" const DropdownMenu = DropdownMenuPrimitive.Root @@ -17,6 +22,28 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -24,7 +51,7 @@ const DropdownMenuSubContent = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -78,8 +152,8 @@ const DropdownMenuLabel = React.forwardRef< (({ className, ...props }, ref) => ( )) @@ -105,18 +179,20 @@ const DropdownMenuShortcut = ({ }: React.HTMLAttributes) => { return ( ) } -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, @@ -124,5 +200,6 @@ export { DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, - DropdownMenuRadioGroup + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, } diff --git a/components/ui/input.tsx b/components/ui/input.tsx index 684a857f3d..a92b8e0e58 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' +import * as React from "react" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" export interface InputProps extends React.InputHTMLAttributes {} @@ -11,7 +11,7 @@ const Input = React.forwardRef( ( ) } ) -Input.displayName = 'Input' +Input.displayName = "Input" export { Input } diff --git a/components/ui/select.tsx b/components/ui/select.tsx index c9986a66d3..f489242110 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -1,14 +1,15 @@ -'use client' +"use client" -import * as React from 'react' -import * as SelectPrimitive from '@radix-ui/react-select' - -import { cn } from '@/lib/utils' +import * as React from "react" import { - IconArrowDown, - IconCheck, - IconChevronUpDown -} from '@/components/ui/icons' + CaretSortIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons" +import * as SelectPrimitive from "@radix-ui/react-select" + +import { cn } from "@/lib/utils" const Select = SelectPrimitive.Root @@ -23,43 +24,81 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", className )} {...props} > {children} - + )) SelectTrigger.displayName = SelectPrimitive.Trigger.displayName +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + const SelectContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( +>(({ className, children, position = "popper", ...props }, ref) => ( + {children} + )) @@ -71,7 +110,7 @@ const SelectLabel = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -84,14 +123,14 @@ const SelectItem = React.forwardRef< - + - + {children} @@ -105,7 +144,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -119,5 +158,7 @@ export { SelectContent, SelectLabel, SelectItem, - SelectSeparator + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, } diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx index 6c55e0b2ca..12d81c4a85 100644 --- a/components/ui/separator.tsx +++ b/components/ui/separator.tsx @@ -1,16 +1,16 @@ -'use client' +"use client" -import * as React from 'react' -import * as SeparatorPrimitive from '@radix-ui/react-separator' +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" const Separator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >( ( - { className, orientation = 'horizontal', decorative = true, ...props }, + { className, orientation = "horizontal", decorative = true, ...props }, ref ) => ( (({ className, ...props }, ref) => ( , SheetContentProps ->(({ side = 'left', className, children, ...props }, ref) => ( +>(({ side = "right", className, children, ...props }, ref) => ( {children} - + Close @@ -80,13 +80,13 @@ const SheetHeader = ({ }: React.HTMLAttributes) => (
) -SheetHeader.displayName = 'SheetHeader' +SheetHeader.displayName = "SheetHeader" const SheetFooter = ({ className, @@ -94,13 +94,13 @@ const SheetFooter = ({ }: React.HTMLAttributes) => (
) -SheetFooter.displayName = 'SheetFooter' +SheetFooter.displayName = "SheetFooter" const SheetTitle = React.forwardRef< React.ElementRef, @@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -120,7 +120,7 @@ const SheetDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -136,5 +136,5 @@ export { SheetHeader, SheetFooter, SheetTitle, - SheetDescription + SheetDescription, } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000000..452f4d9f0d --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx index 29b8c29d10..37d61e4bf7 100644 --- a/components/ui/switch.tsx +++ b/components/ui/switch.tsx @@ -1,9 +1,9 @@ -'use client' +"use client" -import * as React from 'react' -import * as SwitchPrimitives from '@radix-ui/react-switch' +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" const Switch = React.forwardRef< React.ElementRef, @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx index e25af722c7..d1258e47a4 100644 --- a/components/ui/textarea.tsx +++ b/components/ui/textarea.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' +import * as React from "react" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" export interface TextareaProps extends React.TextareaHTMLAttributes {} @@ -10,7 +10,7 @@ const Textarea = React.forwardRef( return (