diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10d6a5c --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Then get your Google Gemini API Key here: https://cloud.google.com/vertex-ai +GOOGLE_GENERATIVE_AI_API_KEY=XXXXXXXX + +# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` +AUTH_SECRET=XXXXXXXX + +# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and +KV_URL=XXXXXXXX +KV_REST_API_URL=XXXXXXXX +KV_REST_API_TOKEN=XXXXXXXX +KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX + +# Get your kasada configurations here: https://kasada.io +KASADA_API_ENDPOINT=XXXXXXXX +KASADA_API_VERSION=XXXXXXXX +KASADA_HEADER_HOST=XXXXXXXX \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..c17b532 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": [ + "next/core-web-vitals", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off" + }, + "settings": { + "tailwindcss": { + "callees": ["cn", "cva"], + "config": "tailwind.config.js" + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd019e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# 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 +.vscode +.env*.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c16c29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 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 new file mode 100644 index 0000000..30d7161 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ + + Next.js 14 and App Router-ready AI chatbot. +

Next.js AI Chatbot

+
+ +

+ An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, Google Gemini, and Vercel KV. +

+ +

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

+
+ +## Features + +- [Next.js](https://nextjs.org) App Router +- React Server Components (RSCs), Suspense, and Server Actions +- [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI +- Support for Google Gemini (default), OpenAI, Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain +- [shadcn/ui](https://ui.shadcn.com) + - Styling with [Tailwind CSS](https://tailwindcss.com) + - [Radix UI](https://radix-ui.com) for headless component primitives + - Icons from [Phosphor Icons](https://phosphoricons.com) +- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) +- [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication + +## Model Providers + +This template ships with Google Gemini `models/gemini-1.0-pro-001` as the default. However, thanks to the [Vercel 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/), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. + +## Deploy Your Own + +You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fgemini-chatbot&from=templates&skippable-integrations=1&env=GOOGLE_GENERATIVE_AI_API_KEY%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fgemini-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{%22type%22:%22kv%22}]) + +## 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 Google Cloud 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/). + +## Authors + +This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: + +- Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) +- Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) +- shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) +- Jeremy Philemon ([@jrmyphlmn](https://twitter.com/jrmyphlmn)) - [Vercel](https://vercel.com) \ No newline at end of file diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 0000000..a25dfef --- /dev/null +++ b/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,66 @@ +import { type Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' + +import { auth } from '@/auth' +import { getChat, getMissingKeys } from '@/app/actions' +import { Chat } from '@/components/chat' +import { AI } from '@/lib/chat/actions' +import { Session } from '@/lib/types' + +export interface ChatPageProps { + params: { + id: string + } +} + +export async function generateMetadata({ + params +}: ChatPageProps): Promise { + const session = await auth() + + if (!session?.user) { + return {} + } + + const chat = await getChat(params.id, session.user.id) + return { + title: chat?.title.toString().slice(0, 50) ?? 'Chat' + } +} + +export default async function ChatPage({ params }: ChatPageProps) { + const session = (await auth()) as Session + const missingKeys = await getMissingKeys() + + if (!session?.user) { + redirect(`/login?next=/chat/${params.id}`) + } + + const userId = session.user.id as string + const chat = await getChat(params.id, userId) + + if (!chat) { + redirect('/') + } + + if (chat?.userId !== session?.user?.id) { + notFound() + } + + return ( + + + + ) +} diff --git a/app/(chat)/error.tsx b/app/(chat)/error.tsx new file mode 100644 index 0000000..2163a74 --- /dev/null +++ b/app/(chat)/error.tsx @@ -0,0 +1,19 @@ +'use client' + +export default function Error({ + error +}: { + error: Error & { digest?: string } +}) { + return ( +
+

+ Oops, something went wrong! +

+

+ {error.message || 'The AI got rate limited, please try again later.'} +

+

Digest: {error.digest}

+
+ ) +} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx new file mode 100644 index 0000000..2825d59 --- /dev/null +++ b/app/(chat)/layout.tsx @@ -0,0 +1,14 @@ +import { SidebarDesktop } from '@/components/sidebar-desktop' + +interface ChatLayoutProps { + children: React.ReactNode +} + +export default async function ChatLayout({ children }: ChatLayoutProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx new file mode 100644 index 0000000..f43ce9f --- /dev/null +++ b/app/(chat)/page.tsx @@ -0,0 +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 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 ( + + + + ) +} diff --git a/app/(chat)/waiting-room/page.tsx b/app/(chat)/waiting-room/page.tsx new file mode 100644 index 0000000..862523f --- /dev/null +++ b/app/(chat)/waiting-room/page.tsx @@ -0,0 +1,10 @@ +'use client' + +export default function Page() { + return ( +
+

You are in the queue

+

Please try again in a few minutes.

+
+ ) +} diff --git a/app/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...restpath]]/route.ts b/app/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...restpath]]/route.ts new file mode 100644 index 0000000..b70e01b --- /dev/null +++ b/app/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...restpath]]/route.ts @@ -0,0 +1,37 @@ +export const runtime = 'edge' +export const dynamic = 'force-dynamic' +export const maxDuration = 3 + +async function handler(request: Request) { + const url = new URL(request.url) + + url.protocol = 'https:' + url.host = process.env.KASADA_API_ENDPOINT || '' + url.port = '' + url.searchParams.delete('restpath') + + const headers = new Headers(request.headers) + headers.set('X-Forwarded-Host', process.env.KASADA_HEADER_HOST || '') + headers.delete('host') + const r = await fetch(url.toString(), { + method: request.method, + body: request.body, + headers, + mode: request.mode, + redirect: 'manual', + // @ts-expect-error + duplex: 'half' + }) + const responseHeaders = new Headers(r.headers) + responseHeaders.set('cdn-cache-control', 'no-cache') + return new Response(r.body, { + status: r.status, + statusText: r.statusText, + headers: responseHeaders + }) +} + +export const GET = handler +export const POST = handler +export const OPTIONS = handler +export const PUT = handler diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 0000000..105f9fa --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,156 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { kv } from '@vercel/kv' + +import { auth } from '@/auth' +import { type Chat } from '@/lib/types' + +export async function getChats(userId?: string | null) { + if (!userId) { + return [] + } + + try { + const pipeline = kv.pipeline() + const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, { + rev: true + }) + + for (const chat of chats) { + pipeline.hgetall(chat) + } + + const results = await pipeline.exec() + + return results as Chat[] + } catch (error) { + return [] + } +} + +export async function getChat(id: string, userId: string) { + const chat = await kv.hgetall(`chat:${id}`) + + if (!chat || (userId && chat.userId !== userId)) { + return null + } + + return chat +} + +export async function removeChat({ id, path }: { id: string; path: string }) { + const session = await auth() + + if (!session) { + return { + error: 'Unauthorized' + } + } + + //Convert uid to string for consistent comparison with session.user.id + const uid = String(await kv.hget(`chat:${id}`, 'userId')) + + if (uid !== session?.user?.id) { + return { + error: 'Unauthorized' + } + } + + await kv.del(`chat:${id}`) + await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`) + + revalidatePath('/') + return revalidatePath(path) +} + +export async function clearChats() { + const session = await auth() + + if (!session?.user?.id) { + return { + error: 'Unauthorized' + } + } + + const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1) + if (!chats.length) { + return redirect('/') + } + const pipeline = kv.pipeline() + + for (const chat of chats) { + pipeline.del(chat) + pipeline.zrem(`user:chat:${session.user.id}`, chat) + } + + await pipeline.exec() + + revalidatePath('/') + return redirect('/') +} + +export async function getSharedChat(id: string) { + const chat = await kv.hgetall(`chat:${id}`) + + if (!chat || !chat.sharePath) { + return null + } + + return chat +} + +export async function shareChat(id: string) { + const session = await auth() + + if (!session?.user?.id) { + return { + error: 'Unauthorized' + } + } + + const chat = await kv.hgetall(`chat:${id}`) + + if (!chat || chat.userId !== session.user.id) { + return { + error: 'Something went wrong' + } + } + + const payload = { + ...chat, + sharePath: `/share/${chat.id}` + } + + await kv.hmset(`chat:${chat.id}`, payload) + + return payload +} + +export async function saveChat(chat: Chat) { + const session = await auth() + + if (session && session.user) { + const pipeline = kv.pipeline() + pipeline.hmset(`chat:${chat.id}`, chat) + pipeline.zadd(`user:chat:${chat.userId}`, { + score: Date.now(), + member: `chat:${chat.id}` + }) + await pipeline.exec() + } else { + return + } +} + +export async function refreshHistory(path: string) { + redirect(path) +} + +export async function getMissingKeys() { + const keysRequired = ['GOOGLE_GENERATIVE_AI_API_KEY'] + return keysRequired + .map(key => (process.env[key] ? '' : key)) + .filter(key => key !== '') +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0b46ea1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f22cf96 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,66 @@ +import { GeistSans } from 'geist/font/sans' +import { GeistMono } from 'geist/font/mono' +import { Analytics } from '@vercel/analytics/react' +import '@/app/globals.css' +import { cn } from '@/lib/utils' +import { TailwindIndicator } from '@/components/tailwind-indicator' +import { Providers } from '@/components/providers' +import { Header } from '@/components/header' +import { Toaster } from '@/components/ui/sonner' +import { KasadaClient } from '@/lib/kasada/kasada-client' + +export const metadata = { + metadataBase: new URL('https://gemini.vercel.ai'), + title: { + default: 'Next.js Gemini Chatbot', + template: `%s - Next.js Gemini Chatbot` + }, + description: + 'Build your own generative UI chatbot using the Vercel AI SDK and Google Gemini', + icons: { + icon: '/favicon.ico', + shortcut: '/favicon-16x16.png', + apple: '/apple-touch-icon.png' + } +} + +export const viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: 'white' }, + { media: '(prefers-color-scheme: dark)', color: 'black' } + ] +} + +interface RootLayoutProps { + children: React.ReactNode +} + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + + +
+
+
{children}
+
+ +
+ + + + ) +} diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..f23e220 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,71 @@ +'use server' + +import { signIn } from '@/auth' +import { User } from '@/lib/types' +import { AuthError } from 'next-auth' +import { z } from 'zod' +import { kv } from '@vercel/kv' +import { ResultCode } from '@/lib/utils' + +export async function getUser(email: string) { + const user = await kv.hgetall(`user:${email}`) + return user +} + +interface Result { + type: string + resultCode: ResultCode +} + +export async function authenticate( + _prevState: Result | undefined, + formData: FormData +): Promise { + try { + const email = formData.get('email') + const password = formData.get('password') + + const parsedCredentials = z + .object({ + email: z.string().email(), + password: z.string().min(6) + }) + .safeParse({ + email, + password + }) + + if (parsedCredentials.success) { + await signIn('credentials', { + email, + password, + redirect: false + }) + + return { + type: 'success', + resultCode: ResultCode.UserLoggedIn + } + } else { + return { + type: 'error', + resultCode: ResultCode.InvalidCredentials + } + } + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return { + type: 'error', + resultCode: ResultCode.InvalidCredentials + } + default: + return { + type: 'error', + resultCode: ResultCode.UnknownError + } + } + } + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..1fba27b --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,18 @@ +import { auth } from '@/auth' +import LoginForm from '@/components/login-form' +import { Session } from '@/lib/types' +import { redirect } from 'next/navigation' + +export default async function LoginPage() { + const session = (await auth()) as Session + + if (session) { + redirect('/') + } + + return ( +
+ +
+ ) +} diff --git a/app/new/page.tsx b/app/new/page.tsx new file mode 100644 index 0000000..d235894 --- /dev/null +++ b/app/new/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default async function NewPage() { + redirect('/') +} diff --git a/app/opengraph-image.png b/app/opengraph-image.png new file mode 100644 index 0000000..73d6023 Binary files /dev/null and b/app/opengraph-image.png differ diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx new file mode 100644 index 0000000..d80f0c8 --- /dev/null +++ b/app/share/[id]/page.tsx @@ -0,0 +1,58 @@ +import { type Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' + +import { formatDate } from '@/lib/utils' +import { getSharedChat } from '@/app/actions' +import { ChatList } from '@/components/chat-list' +import { FooterText } from '@/components/footer' +import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions' + +export const runtime = 'edge' +export const preferredRegion = 'home' + +interface SharePageProps { + params: { + id: string + } +} + +export async function generateMetadata({ + params +}: SharePageProps): Promise { + const chat = await getSharedChat(params.id) + + return { + title: chat?.title.slice(0, 50) ?? 'Chat' + } +} + +export default async function SharePage({ params }: SharePageProps) { + const chat = await getSharedChat(params.id) + + if (!chat || !chat?.sharePath) { + notFound() + } + + const uiState: UIState = getUIStateFromAIState(chat) + + return ( + <> +
+
+
+
+

{chat.title}

+
+ {formatDate(chat.createdAt)} · {chat.messages.length} messages +
+
+
+
+ + + +
+ + + ) +} diff --git a/app/signup/actions.ts b/app/signup/actions.ts new file mode 100644 index 0000000..492586a --- /dev/null +++ b/app/signup/actions.ts @@ -0,0 +1,111 @@ +'use server' + +import { signIn } from '@/auth' +import { ResultCode, getStringFromBuffer } from '@/lib/utils' +import { z } from 'zod' +import { kv } from '@vercel/kv' +import { getUser } from '../login/actions' +import { AuthError } from 'next-auth' + +export async function createUser( + email: string, + hashedPassword: string, + salt: string +) { + const existingUser = await getUser(email) + + if (existingUser) { + return { + type: 'error', + resultCode: ResultCode.UserAlreadyExists + } + } else { + const user = { + id: crypto.randomUUID(), + email, + password: hashedPassword, + salt + } + + await kv.hmset(`user:${email}`, user) + + return { + type: 'success', + resultCode: ResultCode.UserCreated + } + } +} + +interface Result { + type: string + resultCode: ResultCode +} + +export async function signup( + _prevState: Result | undefined, + formData: FormData +): Promise { + const email = formData.get('email') as string + const password = formData.get('password') as string + + const parsedCredentials = z + .object({ + email: z.string().email(), + password: z.string().min(6) + }) + .safeParse({ + email, + password + }) + + if (parsedCredentials.success) { + const salt = crypto.randomUUID() + + const encoder = new TextEncoder() + const saltedPassword = encoder.encode(password + salt) + const hashedPasswordBuffer = await crypto.subtle.digest( + 'SHA-256', + saltedPassword + ) + const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) + + try { + const result = await createUser(email, hashedPassword, salt) + + if (result.resultCode === ResultCode.UserCreated) { + await signIn('credentials', { + email, + password, + redirect: false + }) + } + + return result + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return { + type: 'error', + resultCode: ResultCode.InvalidCredentials + } + default: + return { + type: 'error', + resultCode: ResultCode.UnknownError + } + } + } else { + return { + type: 'error', + resultCode: ResultCode.UnknownError + } + } + } + } else { + return { + type: 'error', + resultCode: ResultCode.InvalidCredentials + } + } +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000..dbac964 --- /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/app/twitter-image.png b/app/twitter-image.png new file mode 100644 index 0000000..73d6023 Binary files /dev/null and b/app/twitter-image.png differ diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..6e74c18 --- /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 new file mode 100644 index 0000000..7542992 --- /dev/null +++ b/auth.ts @@ -0,0 +1,45 @@ +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' +import { authConfig } from './auth.config' +import { z } from 'zod' +import { getStringFromBuffer } from './lib/utils' +import { getUser } from './app/login/actions' + +export const { auth, signIn, signOut } = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + async authorize(credentials) { + const parsedCredentials = z + .object({ + email: z.string().email(), + password: z.string().min(6) + }) + .safeParse(credentials) + + if (parsedCredentials.success) { + const { email, password } = parsedCredentials.data + const user = await getUser(email) + + if (!user) return null + + const encoder = new TextEncoder() + const saltedPassword = encoder.encode(password + user.salt) + const hashedPasswordBuffer = await crypto.subtle.digest( + 'SHA-256', + saltedPassword + ) + const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) + + if (hashedPassword === user.password) { + return user + } else { + return null + } + } + + return null + } + }) + ] +}) diff --git a/components.json b/components.json new file mode 100644 index 0000000..58b812d --- /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/button-scroll-to-bottom.tsx b/components/button-scroll-to-bottom.tsx new file mode 100644 index 0000000..e1403f2 --- /dev/null +++ b/components/button-scroll-to-bottom.tsx @@ -0,0 +1,36 @@ +'use client' + +import * as React from 'react' + +import { cn } from '@/lib/utils' +import { Button, type ButtonProps } from '@/components/ui/button' +import { IconArrowDown } from '@/components/ui/icons' + +interface ButtonScrollToBottomProps extends ButtonProps { + isAtBottom: boolean + scrollToBottom: () => void +} + +export function ButtonScrollToBottom({ + className, + isAtBottom, + scrollToBottom, + ...props +}: ButtonScrollToBottomProps) { + return ( + + ) +} diff --git a/components/chat-history.tsx b/components/chat-history.tsx new file mode 100644 index 0000000..d91dfe5 --- /dev/null +++ b/components/chat-history.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' + +import Link from 'next/link' + +import { cn } from '@/lib/utils' +import { SidebarList } from '@/components/sidebar-list' +import { buttonVariants } from '@/components/ui/button' +import { IconPlus } from '@/components/ui/icons' + +interface ChatHistoryProps { + userId?: string +} + +export async function ChatHistory({ userId }: ChatHistoryProps) { + return ( +
+
+

Chat History

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

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

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

+ }, + code({ node, inline, className, children, ...props }) { + if (children.length) { + if (children[0] == '▍') { + return ( + + ) + } + + children[0] = (children[0] as string).replace('`▍`', '▍') + } + + const match = /language-(\w+)/.exec(className || '') + + if (inline) { + return ( + + {children} + + ) + } + + return ( + + ) + } + }} + > + {message.content} +
+ +
+
+ ) +} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx new file mode 100644 index 0000000..afa0e1c --- /dev/null +++ b/components/chat-panel.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' + +import { shareChat } from '@/app/actions' +import { Button } from '@/components/ui/button' +import { PromptForm } from '@/components/prompt-form' +import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' +import { IconShare } from '@/components/ui/icons' +import { FooterText } from '@/components/footer' +import { ChatShareDialog } from '@/components/chat-share-dialog' +import { useAIState, useActions, useUIState } from 'ai/rsc' +import type { AI } from '@/lib/chat/actions' +import { nanoid } from 'nanoid' +import { UserMessage } from './stocks/message' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +export interface ChatPanelProps { + id?: string + title?: string + input: string + setInput: (value: string) => void + isAtBottom: boolean + scrollToBottom: () => void +} + +export function ChatPanel({ + id, + title, + input, + setInput, + isAtBottom, + scrollToBottom +}: ChatPanelProps) { + const [aiState] = useAIState() + const [messages, setMessages] = useUIState() + const { submitUserMessage } = useActions() + const [shareDialogOpen, setShareDialogOpen] = React.useState(false) + + const exampleMessages = [ + { + heading: 'List flights flying from', + subheading: 'San Francisco to Rome today', + message: `List flights flying from San Francisco to Rome today` + }, + { + heading: 'What is the status', + subheading: 'of flight BA142?', + message: 'What is the status of flight BA142?' + } + ] + + return ( +
+ + +
+
+ {messages.length === 0 && + exampleMessages.map((example, index) => ( +
1 && 'hidden md:block' + )} + onClick={async () => { + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + display: {example.message} + } + ]) + + try { + const responseMessage = await submitUserMessage( + example.message + ) + + setMessages(currentMessages => [ + ...currentMessages, + responseMessage + ]) + } catch { + toast( +
+ You have reached your message limit! Please try again + later, or{' '} + + deploy your own version + + . +
+ ) + } + }} + > +
{example.heading}
+
+ {example.subheading} +
+
+ ))} +
+ + {messages?.length >= 2 ? ( +
+
+ {id && title ? ( + <> + + setShareDialogOpen(false)} + shareChat={shareChat} + chat={{ + id, + title, + messages: aiState.messages + }} + /> + + ) : null} +
+
+ ) : null} + +
+ + +
+
+
+ ) +} diff --git a/components/chat-share-dialog.tsx b/components/chat-share-dialog.tsx new file mode 100644 index 0000000..d96447c --- /dev/null +++ b/components/chat-share-dialog.tsx @@ -0,0 +1,95 @@ +'use client' + +import * as React from 'react' +import { type DialogProps } from '@radix-ui/react-dialog' +import { toast } from 'sonner' + +import { ServerActionResult, type Chat } from '@/lib/types' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { IconSpinner } from '@/components/ui/icons' +import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' + +interface ChatShareDialogProps extends DialogProps { + chat: Pick + shareChat: (id: string) => ServerActionResult + onCopy: () => void +} + +export function ChatShareDialog({ + chat, + shareChat, + onCopy, + ...props +}: ChatShareDialogProps) { + const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) + const [isSharePending, startShareTransition] = React.useTransition() + + const copyShareLink = React.useCallback( + async (chat: Chat) => { + if (!chat.sharePath) { + return toast.error('Could not copy share link to clipboard') + } + + const url = new URL(window.location.href) + url.pathname = chat.sharePath + copyToClipboard(url.toString()) + onCopy() + toast.success('Share link copied to clipboard') + }, + [copyToClipboard, onCopy] + ) + + return ( + + + + Share link to chat + + Anyone with the URL will be able to view the shared chat. + + +
+
{chat.title}
+
+ {chat.messages.length} messages +
+
+ + + +
+
+ ) +} diff --git a/components/chat.tsx b/components/chat.tsx new file mode 100644 index 0000000..608f8c6 --- /dev/null +++ b/components/chat.tsx @@ -0,0 +1,84 @@ +'use client' + +import { ChatList } from '@/components/chat-list' +import { ChatPanel } from '@/components/chat-panel' +import { EmptyScreen } from '@/components/empty-screen' +import { ListFlights } from '@/components/flights/list-flights' +import { ListHotels } from '@/components/hotels/list-hotels' +import { Message } from '@/lib/chat/actions' +import { useLocalStorage } from '@/lib/hooks/use-local-storage' +import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor' +import { Session } from '@/lib/types' +import { cn } from '@/lib/utils' +import { useAIState, useUIState } from 'ai/rsc' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +export interface ChatProps extends React.ComponentProps<'div'> { + initialMessages?: Message[] + id?: string + session?: Session + missingKeys: string[] +} + +export function Chat({ id, className, session, missingKeys }: ChatProps) { + const router = useRouter() + const path = usePathname() + const [input, setInput] = useState('') + const [messages] = useUIState() + const [aiState] = useAIState() + + const [_, setNewChatId] = useLocalStorage('newChatId', id) + + useEffect(() => { + if (session?.user) { + if (!path.includes('chat') && messages.length === 1) { + window.history.replaceState({}, '', `/chat/${id}`) + } + } + }, [id, path, session?.user, messages]) + + useEffect(() => { + const messagesLength = aiState.messages?.length + if (messagesLength === 2) { + router.refresh() + } + }, [aiState.messages, router]) + + useEffect(() => { + setNewChatId(id) + }) + + useEffect(() => { + missingKeys.map(key => { + toast.error(`Missing ${key} environment variable!`) + }) + }, [missingKeys]) + + const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } = + useScrollAnchor() + + return ( +
+
+ {messages.length ? ( + + ) : ( + + )} +
+
+ +
+ ) +} diff --git a/components/clear-history.tsx b/components/clear-history.tsx new file mode 100644 index 0000000..69cf70e --- /dev/null +++ b/components/clear-history.tsx @@ -0,0 +1,75 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +import { ServerActionResult } from '@/lib/types' +import { Button } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { IconSpinner } from '@/components/ui/icons' + +interface ClearHistoryProps { + isEnabled: boolean + clearChats: () => ServerActionResult +} + +export function ClearHistory({ + isEnabled = false, + clearChats +}: ClearHistoryProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + return ( + + + + + + + Are you absolutely sure? + + This will permanently delete your chat history and remove your data + from our servers. + + + + Cancel + { + event.preventDefault() + startTransition(async () => { + const result = await clearChats() + if (result && 'error' in result) { + toast.error(result.error) + return + } + + setOpen(false) + }) + }} + > + {isPending && } + Delete + + + + + ) +} diff --git a/components/empty-screen.tsx b/components/empty-screen.tsx new file mode 100644 index 0000000..719f693 --- /dev/null +++ b/components/empty-screen.tsx @@ -0,0 +1,35 @@ +import { ExternalLink } from '@/components/external-link' + +export function EmptyScreen() { + return ( +
+
+

+ Next.js Gemini Chatbot +

+

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

+

+ It uses{' '} + + React Server Components + {' '} + with function calling to mix both text with generative UI responses + from Gemini. The UI state is synced through the AI SDK so the model is + always aware of your stateful interactions as they happen in the + browser. +

+
+
+ ) +} diff --git a/components/external-link.tsx b/components/external-link.tsx new file mode 100644 index 0000000..ba6cc01 --- /dev/null +++ b/components/external-link.tsx @@ -0,0 +1,29 @@ +export function ExternalLink({ + href, + children +}: { + href: string + children: React.ReactNode +}) { + return ( + + {children} + + + ) +} diff --git a/components/flights/boarding-pass.tsx b/components/flights/boarding-pass.tsx new file mode 100644 index 0000000..018be51 --- /dev/null +++ b/components/flights/boarding-pass.tsx @@ -0,0 +1,88 @@ +'use client' + +/* eslint-disable @next/next/no-img-element */ +import Barcode from 'react-jsbarcode' + +interface BoardingPassProps { + summary: { + airline: string + arrival: string + departure: string + departureTime: string + arrivalTime: string + price: number + seat: string + date: string + gate: string + } +} + +export const BoardingPass = ({ + summary = { + airline: 'American Airlines', + arrival: 'SFO', + departure: 'NYC', + departureTime: '10:00 AM', + arrivalTime: '12:00 PM', + price: 100, + seat: '1A', + date: '2021-12-25', + gate: '31' + } +}: BoardingPassProps) => { + return ( +
+
+
+ airline logo +
+
+
{summary.airline}
+
+ {summary.departure} - {summary.arrival} +
+
+
+
Gate
+
{summary.gate}
+
+
+
+
Rauch / Guillermo
+
+
{summary.departure}
+
{summary.date}
+
{summary.arrival}
+
+
+
+
+
Seat
+
{summary.seat}
+
+
+
Class
+
BUSINESS
+
+
+
Departs
+
{summary.departureTime}
+
+
+
Arrival
+
{summary.arrivalTime}
+
+
+
+ +
+
+ ) +} diff --git a/components/flights/destinations.tsx b/components/flights/destinations.tsx new file mode 100644 index 0000000..74b28ef --- /dev/null +++ b/components/flights/destinations.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useActions, useUIState } from 'ai/rsc' + +export const Destinations = ({ destinations }: { destinations: string[] }) => { + const { submitUserMessage } = useActions() + const [_, setMessages] = useUIState() + + return ( +
+

+ Here is a list of holiday destinations based on the books you have read. + Choose one to proceed to booking a flight. +

+
+ {destinations.map(destination => ( + + ))} +
+
+ ) +} diff --git a/components/flights/flight-status.tsx b/components/flights/flight-status.tsx new file mode 100644 index 0000000..110cdfa --- /dev/null +++ b/components/flights/flight-status.tsx @@ -0,0 +1,151 @@ +'use client' + +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +import { useActions, useUIState } from 'ai/rsc' +import { + ArrowDownRight, + ArrowUpRight, + CheckIcon, + IconCheck, + IconStop, + SparklesIcon +} from '@/components/ui/icons' + +export interface StatusProps { + summary: { + departingCity: string + departingAirport: string + departingAirportCode: string + departingTime: string + arrivalCity: string + arrivalAirport: string + arrivalAirportCode: string + arrivalTime: string + flightCode: string + date: string + } +} + +export const suggestions = [ + 'Change my seat', + 'Change my flight', + 'Show boarding pass' +] + +export const FlightStatus = ({ + summary = { + departingCity: 'Miami', + departingAirport: 'Miami Intl', + departingAirportCode: 'MIA', + departingTime: '11:45 PM', + arrivalCity: 'San Francisco', + arrivalAirport: 'San Francisco Intl', + arrivalAirportCode: 'SFO', + arrivalTime: '4:20 PM', + flightCode: 'XY 2421', + date: 'Mon, 16 Sep' + } +}: StatusProps) => { + const { + departingCity, + departingAirport, + departingAirportCode, + departingTime, + arrivalCity, + arrivalAirport, + arrivalAirportCode, + arrivalTime, + flightCode, + date + } = summary + + const { submitUserMessage } = useActions() + const [_, setMessages] = useUIState() + + return ( +
+
+
+
+ airline logo +
+
+
+ {date} · {flightCode} +
+
+ {departingCity} to {arrivalCity} +
+
+
+
+
+
+
+ +
+
+
{departingAirportCode}
+
{departingAirport}
+
Terminal N · GATE D43
+
+
+
{departingTime}
+
in 6h 50m
+
+ 2h 15m late +
+
+
+
+
+ +
+
+ Total 11h 30m · 5, 563mi · Overnight +
+
+
+
+ +
+
+
{arrivalAirportCode}
+
{arrivalAirport}
+
Terminal 2 · GATE 59A
+
+
+
{arrivalTime}
+
+ 2h 15m late +
+
+
+
+
+
+ {suggestions.map(suggestion => ( +
{ + const response = await submitUserMessage(suggestion) + setMessages((currentMessages: any[]) => [ + ...currentMessages, + response + ]) + }} + > + + {suggestion} +
+ ))} +
+
+ ) +} diff --git a/components/flights/list-flights.tsx b/components/flights/list-flights.tsx new file mode 100644 index 0000000..25944c9 --- /dev/null +++ b/components/flights/list-flights.tsx @@ -0,0 +1,132 @@ +'use client' + +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ + +import { useActions, useUIState } from 'ai/rsc' + +interface Flight { + id: number + airlines: string + departureTime: string + arrivalTime: string + price: number +} + +interface ListFlightsProps { + summary: { + arrivalCity: string + departingCity: string + arrivalAirport: string + departingAirport: string + date: string + } +} + +export const ListFlights = ({ + summary = { + arrivalCity: 'San Francisco', + departingCity: 'New York City', + arrivalAirport: 'SFO', + departingAirport: 'JFK', + date: '2021-12-25' + } +}: ListFlightsProps) => { + const { arrivalCity, departingCity, arrivalAirport, departingAirport, date } = + summary + const { submitUserMessage } = useActions() + const [_, setMessages] = useUIState() + + const flights = [ + { + id: 1, + airlines: 'United Airlines', + departureTime: '8:30 PM', + arrivalTime: '4:20 PM+1', + price: 531 + }, + { + id: 2, + airlines: 'United Airlines', + departureTime: '2:40 PM', + arrivalTime: '10:25 AM+1', + price: 564 + }, + { + id: 3, + airlines: 'United Airlines', + departureTime: '3:00 PM', + arrivalTime: '10:50 AM+1', + price: 611 + } + ] + + return ( +
+
+
+
Departure
+
{departingCity}
+
+
+
Arrival
+
{arrivalCity}
+
+
+
Date
+
{date}
+
+
+
+ {flights && + flights.map(flight => ( +
{ + const response = await submitUserMessage( + `The user has selected flight ${flight.airlines}, departing at ${flight.departureTime} and arriving at ${flight.arrivalTime} for $${flight.price}. Now proceeding to select seats.` + ) + setMessages((currentMessages: any[]) => [ + ...currentMessages, + response + ]) + }} + > +
+ airline logo +
+
+
+
+ {flight.departureTime} - {flight.arrivalTime} +
+
{flight.airlines}
+
+
+
+ {flight.id === 2 ? '10hr 50min' : '10hr 45min'} +
+
+ {departingAirport} - {arrivalAirport} +
+
+
+
+ ${flight.price} +
+
+ One Way +
+
+
+
+ ))} +
+
+ ) +} diff --git a/components/flights/purchase-ticket.tsx b/components/flights/purchase-ticket.tsx new file mode 100644 index 0000000..73f7148 --- /dev/null +++ b/components/flights/purchase-ticket.tsx @@ -0,0 +1,149 @@ +'use client' + +import { + CardIcon, + GoogleIcon, + LockIcon, + SparklesIcon +} from '@/components/ui/icons' +import { cn } from '@/lib/utils' +import { readStreamableValue, useActions, useUIState } from 'ai/rsc' +import { useState } from 'react' + +type Status = + | 'requires_confirmation' + | 'requires_code' + | 'completed' + | 'failed' + | 'expired' + | 'in_progress' + +interface PurchaseProps { + status: Status + summary: { + airline: string + departureTime: string + arrivalTime: string + price: number + seat: string + } +} + +export const suggestions = [ + 'Show flight status', + 'Show boarding pass for flight' +] + +export const PurchaseTickets = ({ + status = 'requires_confirmation', + summary = { + airline: 'American Airlines', + departureTime: '10:00 AM', + arrivalTime: '12:00 PM', + price: 100, + seat: '1A' + } +}: PurchaseProps) => { + const [currentStatus, setCurrentStatus] = useState(status) + const { requestCode, validateCode, submitUserMessage } = useActions() + const [display, setDisplay] = useState(null) + const [_, setMessages] = useUIState() + + return ( +
+
+
+
+
+ +
+
Visa · · · · 0512
+
+
+ + Pay +
+
+ {currentStatus === 'requires_confirmation' ? ( +
+

+ Thanks for choosing your flight and hotel reservations! Confirm + your purchase to complete your booking. +

+ +
+ ) : currentStatus === 'requires_code' ? ( + <> +
+ Enter the code sent to your phone (***) *** 6137 to complete your + purchase. +
+
+ +
+ + + ) : currentStatus === 'completed' || currentStatus === 'in_progress' ? ( + display + ) : currentStatus === 'expired' ? ( +
+ Your Session has expired! +
+ ) : null} +
+ +
+ {suggestions.map(suggestion => ( + + ))} +
+
+ ) +} diff --git a/components/flights/select-seats.tsx b/components/flights/select-seats.tsx new file mode 100644 index 0000000..23ebf11 --- /dev/null +++ b/components/flights/select-seats.tsx @@ -0,0 +1,143 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +'use client' + +import { useAIState, useActions, useUIState } from 'ai/rsc' +import { useState } from 'react' +import { SparklesIcon } from '../ui/icons' + +interface SelectSeatsProps { + summary: { + departingCity: string + arrivalCity: string + flightCode: string + date: string + } +} + +export const suggestions = [ + 'Proceed to checkout', + 'List hotels and make a reservation' +] + +export const SelectSeats = ({ + summary = { + departingCity: 'New York City', + arrivalCity: 'San Francisco', + flightCode: 'CA123', + date: '23 March 2024' + } +}: SelectSeatsProps) => { + const availableSeats = ['3B', '2D'] + const [aiState, setAIState] = useAIState() + const [selectedSeat, setSelectedSeat] = useState('') + const { departingCity, arrivalCity, flightCode, date } = summary + const [_, setMessages] = useUIState() + const { submitUserMessage } = useActions() + + return ( +
+

+ Great! Here are the available seats for your flight. Please select a + seat to continue. +

+
+
+
+ airline logo +
+
+
+ {date} · {flightCode} +
+
+ {departingCity} to {arrivalCity} +
+
+
+
+
+ {[4, 3, 2, 1].map((row, rowIndex) => ( +
+ {['A', 'B', 0, 'C', 'D'].map((seat, seatIndex) => ( +
{ + setSelectedSeat(`${row}${seat}`) + + setAIState({ + ...aiState, + interactions: [ + `great, I have selected seat ${row}${seat}` + ] + }) + }} + > + {seatIndex === 2 ? ( +
+ {row} +
+ ) : ( +
+ )} +
+ ))} +
+ ))} +
+ {['A', 'B', '', 'C', 'D'].map((seat, index) => ( +
+ {seat} +
+ ))} +
+
+
+
+ {selectedSeat !== '' && ( +
+ {suggestions.map(suggestion => ( + + ))} +
+ )} +
+ ) +} diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..3e700eb --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { cn } from '@/lib/utils' +import { ExternalLink } from '@/components/external-link' + +export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

+ Open source AI chatbot built with{' '} + + Google Gemini + + , Next.js and{' '} + + Vercel AI SDK + + . +

+ ) +} diff --git a/components/header.tsx b/components/header.tsx new file mode 100644 index 0000000..3db547d --- /dev/null +++ b/components/header.tsx @@ -0,0 +1,82 @@ +/* eslint-disable @next/next/no-img-element */ +import * as React from 'react' +import Link from 'next/link' + +import { cn } from '@/lib/utils' +import { auth } from '@/auth' +import { Button, buttonVariants } from '@/components/ui/button' +import { + IconGitHub, + IconNextChat, + IconSeparator, + IconVercel +} from '@/components/ui/icons' +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()) as Session + return ( + <> + {session?.user ? ( + <> + + + + + + ) : ( + + gemini logo + + )} +
+ + {session?.user ? ( + + ) : ( + + )} +
+ + ) +} + +export function Header() { + return ( +
+
+ }> + + +
+ +
+ ) +} diff --git a/components/hotels/list-hotels.tsx b/components/hotels/list-hotels.tsx new file mode 100644 index 0000000..7e9664c --- /dev/null +++ b/components/hotels/list-hotels.tsx @@ -0,0 +1,87 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +'use client' + +import { useActions, useUIState } from 'ai/rsc' + +interface Hotel { + id: number + name: string + description: string + price: number +} + +interface ListHotelsProps { + hotels: Hotel[] +} + +export const ListHotels = ({ + hotels = [ + { + id: 1, + name: 'The St. Regis Rome', + description: 'Renowned luxury hotel with a lavish spa', + price: 450 + }, + { + id: 2, + name: 'The Inn at the Roman Forum', + description: 'Upscale hotel with Roman ruins and a bar', + price: 145 + }, + { + id: 3, + name: 'Hotel Roma', + description: 'Vibrant property with free breakfast', + price: 112 + } + ] +}: ListHotelsProps) => { + const { submitUserMessage } = useActions() + const [_, setMessages] = useUIState() + + return ( +
+

+ We recommend a 3 night stay in Rome. Here are some hotels you can choose + from. +

+
+ {hotels.map(hotel => ( +
{ + const response = await submitUserMessage( + `I want to book the ${hotel.name}, proceed to checkout by calling checkoutBooking function.` + ) + setMessages((currentMessages: any[]) => [ + ...currentMessages, + response + ]) + }} + > +
+
+ +
+
+
{hotel.name}
+
{hotel.description}
+
+
+
+
+ ${hotel.price} +
+
per night
+
+
+ ))} +
+
+ ) +} diff --git a/components/login-button.tsx b/components/login-button.tsx new file mode 100644 index 0000000..ae8f842 --- /dev/null +++ b/components/login-button.tsx @@ -0,0 +1,42 @@ +'use client' + +import * as React from 'react' +import { signIn } from 'next-auth/react' + +import { cn } from '@/lib/utils' +import { Button, type ButtonProps } from '@/components/ui/button' +import { IconGitHub, IconSpinner } from '@/components/ui/icons' + +interface LoginButtonProps extends ButtonProps { + showGithubIcon?: boolean + text?: string +} + +export function LoginButton({ + text = 'Login with GitHub', + showGithubIcon = true, + className, + ...props +}: LoginButtonProps) { + const [isLoading, setIsLoading] = React.useState(false) + return ( + + ) +} diff --git a/components/login-form.tsx b/components/login-form.tsx new file mode 100644 index 0000000..dc64b06 --- /dev/null +++ b/components/login-form.tsx @@ -0,0 +1,97 @@ +'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' +import { getMessageFromCode } from '@/lib/utils' +import { useRouter } from 'next/navigation' + +export default function LoginForm() { + const router = useRouter() + const [result, dispatch] = useFormState(authenticate, undefined) + + useEffect(() => { + if (result) { + if (result.type === 'error') { + toast.error(getMessageFromCode(result.resultCode)) + } else { + toast.success(getMessageFromCode(result.resultCode)) + router.refresh() + } + } + }, [result, router]) + + return ( +
+
+

Please log in to continue.

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + + No account yet?
Sign up
+ +
+ ) +} + +function LoginButton() { + const { pending } = useFormStatus() + + return ( + + ) +} diff --git a/components/markdown.tsx b/components/markdown.tsx new file mode 100644 index 0000000..d449146 --- /dev/null +++ b/components/markdown.tsx @@ -0,0 +1,9 @@ +import { FC, memo } from 'react' +import ReactMarkdown, { Options } from 'react-markdown' + +export const MemoizedReactMarkdown: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +) diff --git a/components/media/video.tsx b/components/media/video.tsx new file mode 100644 index 0000000..a8f3846 --- /dev/null +++ b/components/media/video.tsx @@ -0,0 +1,19 @@ +import { SpinnerIcon } from '../ui/icons' + +export const Video = ({ isLoading }: { isLoading: boolean }) => { + return ( +
+
+ ) +} diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx new file mode 100644 index 0000000..2e9bdfc --- /dev/null +++ b/components/prompt-form.tsx @@ -0,0 +1,172 @@ +'use client' + +import * as React from 'react' +import Textarea from 'react-textarea-autosize' + +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 { useEnterSubmit } from '@/lib/hooks/use-enter-submit' +import { nanoid } from 'nanoid' +import { toast } from 'sonner' + +export function PromptForm({ + input, + setInput +}: { + input: string + setInput: (value: string) => void +}) { + const { formRef, onKeyDown } = useEnterSubmit() + const inputRef = React.useRef(null) + const { submitUserMessage, describeImage } = useActions() + const [_, setMessages] = useUIState() + + React.useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + + const fileRef = React.useRef(null) + + return ( +
{ + e.preventDefault() + + // Blur focus on mobile + if (window.innerWidth < 600) { + e.target['message']?.blur() + } + + const value = input.trim() + setInput('') + if (!value) return + + // Optimistically add user message UI + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + display: {value} + } + ]) + + try { + // Submit and get response message + const responseMessage = await submitUserMessage(value) + setMessages(currentMessages => [...currentMessages, responseMessage]) + } catch { + toast( +
+ You have reached your message limit! Please try again later, or{' '} + + deploy your own version + + . +
+ ) + } + }} + > + { + if (!event.target.files) { + toast.error('No file selected') + return + } + + const file = event.target.files[0] + + if (file.type.startsWith('video/')) { + const responseMessage = await describeImage('') + setMessages(currentMessages => [ + ...currentMessages, + responseMessage + ]) + } else { + const reader = new FileReader() + reader.readAsDataURL(file) + + reader.onloadend = async () => { + const base64String = reader.result + const responseMessage = await describeImage(base64String) + setMessages(currentMessages => [ + ...currentMessages, + responseMessage + ]) + } + } + }} + /> +
+ {/* + */} + + {/* + Add Attachments + */} +