diff --git a/.env.example b/.env.example index 10d6a5c..8dc4740 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,5 @@ -# Then get your Google Gemini API Key here: https://cloud.google.com/vertex-ai +# 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 +# Create an access token here https://supabase.com/dashboard/account/tokens +SUPABASE_ACCESS_TOKEN=**** diff --git a/ai/index.ts b/ai/index.ts new file mode 100644 index 0000000..645d10e --- /dev/null +++ b/ai/index.ts @@ -0,0 +1,9 @@ +// import { openai } from "@ai-sdk/openai"; +import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai"; +import { ragMiddleware } from "./rag-middleware"; +import { google } from "@ai-sdk/google"; + +export const customModel = wrapLanguageModel({ + model: google("gemini-1.5-pro-002"), + middleware: ragMiddleware, +}); diff --git a/ai/rag-middleware.ts b/ai/rag-middleware.ts new file mode 100644 index 0000000..980b7ce --- /dev/null +++ b/ai/rag-middleware.ts @@ -0,0 +1,3 @@ +import { Experimental_LanguageModelV1Middleware } from "ai"; + +export const ragMiddleware: Experimental_LanguageModelV1Middleware = {}; diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000..4b56752 --- /dev/null +++ b/app/(auth)/actions.ts @@ -0,0 +1,80 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { createClient } from "@/utils/supabase/server"; + +export interface LoginActionState { + status: "idle" | "in_progress" | "success" | "failed"; +} + +export const login = async ( + _: LoginActionState, + formData: FormData, +): Promise => { + const supabase = createClient(); + + const { error } = await supabase.auth.signInWithPassword({ + email: formData.get("email") as string, + password: formData.get("password") as string, + }); + + if (error) { + return { status: "failed" } as LoginActionState; + } + + revalidatePath("/", "layout"); + redirect("/"); +}; + +export interface RegisterActionState { + status: "idle" | "in_progress" | "success" | "failed" | "user_exists"; +} + +export const register = async (_: RegisterActionState, formData: FormData) => { + const supabase = createClient(); + + let email = formData.get("email") as string; + let password = formData.get("password") as string; + + const { data, error } = await supabase.auth.signUp({ email, password }); + + if (error) { + if (error.code === "user_already_exists") { + return { status: "user_exists" } as RegisterActionState; + } + } + + const { user, session } = data; + + if (user && session) { + const { error } = await supabase.auth.signInWithPassword({ + email: formData.get("email") as string, + password: formData.get("password") as string, + }); + + if (error) { + return { status: "failed" } as LoginActionState; + } + + revalidatePath("/", "layout"); + redirect("/"); + } else { + return { status: "failed" } as RegisterActionState; + } +}; + +export const getUserFromSession = async () => { + const supabase = createClient(); + const { data } = await supabase.auth.getUser(); + return data.user; +}; + +export const signOut = async () => { + const supabase = createClient(); + const { error } = await supabase.auth.signOut(); + + if (!error) { + redirect("/login"); + } +}; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..9a7ee99 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { Form } from "@/components/form"; +import { SubmitButton } from "@/components/submit-button"; +import { useActionState, useEffect } from "react"; +import { login, LoginActionState } from "../actions"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const router = useRouter(); + + const [state, formAction] = useActionState( + login, + { + status: "idle", + }, + ); + + useEffect(() => { + if (state.status === "failed") { + toast.error("Invalid credentials!"); + } else if (state.status === "success") { + router.refresh(); + } + }, [state.status, router]); + + return ( +
+
+
+

Sign In

+

+ Use your email and password to sign in +

+
+
+ Sign in +

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

+
+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..e5f4e0a --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { Form } from "@/components/form"; +import { SubmitButton } from "@/components/submit-button"; +import { register, RegisterActionState } from "../actions"; +import { useActionState, useEffect } from "react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const router = useRouter(); + const [state, formAction] = useActionState( + register, + { + status: "idle", + }, + ); + + useEffect(() => { + if (state.status === "user_exists") { + toast.error("Account already exists"); + } else if (state.status === "failed") { + toast.error("Failed to create account"); + } else if (state.status === "success") { + toast.success("Account created successfully"); + router.refresh(); + } + }, [state, router]); + + return ( +
+
+
+

Sign Up

+

+ Create an account with your email and password +

+
+
+ Sign Up +

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

+
+
+
+ ); +} diff --git a/app/(chat)/[id]/page.tsx b/app/(chat)/[id]/page.tsx new file mode 100644 index 0000000..aca03cd --- /dev/null +++ b/app/(chat)/[id]/page.tsx @@ -0,0 +1,22 @@ +import { Message } from "ai"; +import { Chat } from "@/utils/supabase/schema"; +import { getChatById } from "../actions"; +import { notFound } from "next/navigation"; +import { Chat as PreviewChat } from "@/components/chat"; + +export default async function Page({ params }: { params: any }) { + const { id } = params; + const chatFromDb = await getChatById({ id }); + + if (!chatFromDb) { + notFound(); + } + + // type casting + const chat: Chat = { + ...chatFromDb, + messages: chatFromDb.messages as Message[], + }; + + return ; +} diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts new file mode 100644 index 0000000..d2c0722 --- /dev/null +++ b/app/(chat)/actions.ts @@ -0,0 +1,31 @@ +import { createClient } from "@/utils/supabase/server"; + +export async function saveChat({ + id, + messages, + userId, +}: { + id: string; + messages: any; + userId: string; +}) { + const supabase = createClient(); + + await supabase.from("chat").upsert({ + id, + messages, + userId, + }); +} + +export async function getChatById({ id }: { id: string }) { + const supabase = createClient(); + + const { data: chat } = await supabase + .from("chat") + .select("*") + .eq("id", id) + .single(); + + return chat; +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts new file mode 100644 index 0000000..3735859 --- /dev/null +++ b/app/(chat)/api/chat/route.ts @@ -0,0 +1,82 @@ +import { customModel } from "@/ai"; +import { saveChat } from "@/app/(chat)/actions"; +import { convertToCoreMessages, streamText } from "ai"; +import { getUserFromSession } from "@/app/(auth)/actions"; +import { createClient } from "@/utils/supabase/server"; +import { z } from "zod"; + +export async function POST(request: Request) { + const { id, messages, selectedFilePathnames } = await request.json(); + + const user = await getUserFromSession(); + + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const result = await streamText({ + model: customModel, + system: + "you are a friendly assistant! keep your responses concise and helpful.", + messages: convertToCoreMessages(messages), + experimental_providerMetadata: { + files: { + selection: selectedFilePathnames, + }, + }, + maxSteps: 5, + tools: { + getWeather: { + description: "Get the current weather at a location", + parameters: z.object({ + latitude: z.number(), + longitude: z.number(), + }), + execute: async ({ latitude, longitude }) => { + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`, + ); + + const weatherData = await response.json(); + return weatherData; + }, + }, + }, + onFinish: async ({ text }) => { + await saveChat({ + id, + messages: [...messages, { role: "assistant", content: text }], + userId: user.id, + }); + }, + experimental_telemetry: { + isEnabled: true, + functionId: "stream-text", + }, + }); + + return result.toDataStreamResponse({}); +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new Response("Not Found", { status: 404 }); + } + + const supabase = createClient(); + + try { + const { data, error } = await supabase.from("chat").delete().eq("id", id); + + if (error) throw error; + + return Response.json(data); + } catch (error) { + return new Response("An error occurred while processing your request", { + status: 500, + }); + } +} diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts new file mode 100644 index 0000000..c6d22cb --- /dev/null +++ b/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,47 @@ +import { getUserFromSession } from "@/app/(auth)/actions"; +import { createClient } from "@/utils/supabase/server"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + const user = await getUserFromSession(); + + if (!user) { + return Response.redirect("/login"); + } + + if (request.body === null) { + return new Response("Request body is empty", { status: 400 }); + } + + const supabase = createClient(); + + try { + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + + const filename = file.name; + const fileBuffer = await file.arrayBuffer(); + + const { data, error } = await supabase.storage + .from("attachments") + .upload(filename, fileBuffer, { + contentType: file.type, + upsert: true, + }); + + if (error) { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } + + return NextResponse.json({ data }); + } catch (error) { + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 }, + ); + } +} diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts new file mode 100644 index 0000000..7aea192 --- /dev/null +++ b/app/(chat)/api/history/route.ts @@ -0,0 +1,17 @@ +import { getUserFromSession } from "@/app/(auth)/actions"; +import { createClient } from "@/utils/supabase/server"; + +export async function GET() { + const supabase = createClient(); + const user = await getUserFromSession(); + + if (!user) { + return Response.json("Unauthorized!", { status: 401 }); + } + + const { data: chats } = await supabase + .from("chat") + .select("*") + .order("createdAt", { ascending: false }); + return Response.json(chats); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx deleted file mode 100644 index a25dfef..0000000 --- a/app/(chat)/chat/[id]/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 2163a74..0000000 --- a/app/(chat)/error.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'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 deleted file mode 100644 index 2825d59..0000000 --- a/app/(chat)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SidebarDesktop } from '@/components/sidebar-desktop' - -interface ChatLayoutProps { - children: React.ReactNode -} - -export default async function ChatLayout({ children }: ChatLayoutProps) { - return ( -
- - {children} -
- ) -} diff --git a/app/(chat)/opengraph-image.png b/app/(chat)/opengraph-image.png new file mode 100644 index 0000000..e8eb20e Binary files /dev/null and b/app/(chat)/opengraph-image.png differ diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index f43ce9f..d07eab2 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,22 +1,7 @@ -import { nanoid } from '@/lib/utils' -import { Chat } from '@/components/chat' -import { AI } from '@/lib/chat/actions' -import { auth } from '@/auth' -import { Session } from '@/lib/types' -import { getMissingKeys } from '../actions' +import { Chat } from "@/components/chat"; +import { generateUUID } from "@/utils/functions"; -export const metadata = { - title: 'Next.js AI Chatbot' -} - -export default async function IndexPage() { - const id = nanoid() - const session = (await auth()) as Session - const missingKeys = await getMissingKeys() - - return ( - - - - ) +export default async function Page() { + const id = generateUUID(); + return ; } diff --git a/app/(chat)/twitter-image.png b/app/(chat)/twitter-image.png new file mode 100644 index 0000000..e8eb20e Binary files /dev/null and b/app/(chat)/twitter-image.png differ diff --git a/app/(chat)/waiting-room/page.tsx b/app/(chat)/waiting-room/page.tsx deleted file mode 100644 index 862523f..0000000 --- a/app/(chat)/waiting-room/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -'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 deleted file mode 100644 index b70e01b..0000000 --- a/app/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...restpath]]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 105f9fa..0000000 --- a/app/actions.ts +++ /dev/null @@ -1,156 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { redirect } from 'next/navigation' -import { kv } from '@vercel/kv' - -import { auth } from '@/auth' -import { type Chat } from '@/lib/types' - -export async function getChats(userId?: string | null) { - 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/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..a06852a Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index 0b46ea1..4bb5653 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,76 +1,95 @@ @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%; +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} - --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 utilities { + .text-balance { + text-wrap: balance; + } } - + @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.skeleton { + *[class^="text-"] { + color: transparent; + @apply rounded-md bg-foreground/20 select-none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index f22cf96..818c4e4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,66 +1,35 @@ -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' +import { Navbar } from "@/components/navbar"; +import { Metadata } from "next"; +import { Toaster } from "sonner"; +import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; +import { GeistSans } from "geist/font/sans"; -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 const metadata: Metadata = { + metadataBase: new URL("https://gemini.vercel.ai"), + title: "Gemini Chatbot", + description: "Next.js chatbot template using the AI SDK and Gemini.", +}; -export default function RootLayout({ children }: RootLayoutProps) { +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { return ( - - - - - + + -
-
-
{children}
-
- -
- + + + {children} + - ) + ); } diff --git a/app/login/actions.ts b/app/login/actions.ts deleted file mode 100644 index f23e220..0000000 --- a/app/login/actions.ts +++ /dev/null @@ -1,71 +0,0 @@ -'use server' - -import { signIn } from '@/auth' -import { User } from '@/lib/types' -import { AuthError } from 'next-auth' -import { z } from 'zod' -import { kv } from '@vercel/kv' -import { ResultCode } from '@/lib/utils' - -export async function getUser(email: string) { - const user = await kv.hgetall(`user:${email}`) - return user -} - -interface Result { - type: string - resultCode: ResultCode -} - -export async function authenticate( - _prevState: Result | undefined, - formData: FormData -): Promise { - try { - const email = formData.get('email') - const password = formData.get('password') - - const parsedCredentials = z - .object({ - email: z.string().email(), - password: z.string().min(6) - }) - .safeParse({ - email, - password - }) - - if (parsedCredentials.success) { - await signIn('credentials', { - email, - password, - redirect: false - }) - - return { - type: 'success', - resultCode: ResultCode.UserLoggedIn - } - } else { - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - } - } catch (error) { - if (error instanceof AuthError) { - switch (error.type) { - case 'CredentialsSignin': - return { - type: 'error', - resultCode: ResultCode.InvalidCredentials - } - default: - return { - type: 'error', - resultCode: ResultCode.UnknownError - } - } - } - } -} diff --git a/app/login/page.tsx b/app/login/page.tsx deleted file mode 100644 index 1fba27b..0000000 --- a/app/login/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { auth } from '@/auth' -import LoginForm from '@/components/login-form' -import { Session } from '@/lib/types' -import { redirect } from 'next/navigation' - -export default async function LoginPage() { - const session = (await auth()) as Session - - if (session) { - redirect('/') - } - - return ( -
- -
- ) -} diff --git a/app/new/page.tsx b/app/new/page.tsx deleted file mode 100644 index d235894..0000000 --- a/app/new/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation' - -export default async function NewPage() { - redirect('/') -} diff --git a/app/opengraph-image.png b/app/opengraph-image.png deleted file mode 100644 index 73d6023..0000000 Binary files a/app/opengraph-image.png and /dev/null differ diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx deleted file mode 100644 index d80f0c8..0000000 --- a/app/share/[id]/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { type Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' - -import { formatDate } from '@/lib/utils' -import { getSharedChat } from '@/app/actions' -import { ChatList } from '@/components/chat-list' -import { FooterText } from '@/components/footer' -import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions' - -export const runtime = 'edge' -export const preferredRegion = 'home' - -interface SharePageProps { - params: { - id: string - } -} - -export async function generateMetadata({ - params -}: SharePageProps): Promise { - const chat = await getSharedChat(params.id) - - return { - title: chat?.title.slice(0, 50) ?? 'Chat' - } -} - -export default async function SharePage({ params }: SharePageProps) { - const chat = await getSharedChat(params.id) - - if (!chat || !chat?.sharePath) { - notFound() - } - - const uiState: UIState = getUIStateFromAIState(chat) - - return ( - <> -
-
-
-
-

{chat.title}

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

Chat History

-
-
- - - New Chat - -
- - {Array.from({ length: 10 }).map((_, i) => ( -
- ))} -
- } - > - {/* @ts-ignore */} - -
-
- ) -} diff --git a/components/chat-list.tsx b/components/chat-list.tsx deleted file mode 100644 index 6831e3e..0000000 --- a/components/chat-list.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index d4e4b40..0000000 --- a/components/chat-message-actions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -import { type Message } from 'ai' - -import { Button } from '@/components/ui/button' -import { IconCheck, IconCopy } from '@/components/ui/icons' -import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' -import { cn } from '@/lib/utils' - -interface ChatMessageActionsProps extends React.ComponentProps<'div'> { - message: Message -} - -export function ChatMessageActions({ - message, - className, - ...props -}: ChatMessageActionsProps) { - const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) - - const onCopy = () => { - if (isCopied) return - copyToClipboard(message.content) - } - - return ( -
- -
- ) -} diff --git a/components/chat-message.tsx b/components/chat-message.tsx deleted file mode 100644 index e17d857..0000000 --- a/components/chat-message.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// Inspired by Chatbot-UI and modified to fit the needs of this project -// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx - -import { Message } from 'ai' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' - -import { cn } from '@/lib/utils' -import { CodeBlock } from '@/components/ui/codeblock' -import { MemoizedReactMarkdown } from '@/components/markdown' -import { 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 deleted file mode 100644 index afa0e1c..0000000 --- a/components/chat-panel.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import * as React from 'react' - -import { shareChat } from '@/app/actions' -import { Button } from '@/components/ui/button' -import { PromptForm } from '@/components/prompt-form' -import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' -import { IconShare } from '@/components/ui/icons' -import { FooterText } from '@/components/footer' -import { ChatShareDialog } from '@/components/chat-share-dialog' -import { useAIState, useActions, useUIState } from 'ai/rsc' -import type { AI } from '@/lib/chat/actions' -import { nanoid } from 'nanoid' -import { UserMessage } from './stocks/message' -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 deleted file mode 100644 index d96447c..0000000 --- a/components/chat-share-dialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client' - -import * as React from 'react' -import { type DialogProps } from '@radix-ui/react-dialog' -import { toast } from 'sonner' - -import { ServerActionResult, type Chat } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { IconSpinner } from '@/components/ui/icons' -import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' - -interface ChatShareDialogProps extends DialogProps { - chat: Pick - shareChat: (id: string) => ServerActionResult - onCopy: () => void -} - -export function ChatShareDialog({ - chat, - shareChat, - onCopy, - ...props -}: ChatShareDialogProps) { - const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) - const [isSharePending, startShareTransition] = React.useTransition() - - const copyShareLink = React.useCallback( - async (chat: Chat) => { - if (!chat.sharePath) { - return toast.error('Could not copy share link to clipboard') - } - - const url = new URL(window.location.href) - url.pathname = chat.sharePath - copyToClipboard(url.toString()) - onCopy() - toast.success('Share link copied to clipboard') - }, - [copyToClipboard, onCopy] - ) - - return ( - - - - Share link to chat - - Anyone with the URL will be able to view the shared chat. - - -
-
{chat.title}
-
- {chat.messages.length} messages -
-
- - - -
-
- ) -} diff --git a/components/chat.tsx b/components/chat.tsx index 608f8c6..b86da04 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,84 +1,74 @@ -'use client' +"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' +import { Attachment, Message } from "ai"; +import { useChat } from "ai/react"; +import { useState } from "react"; +import { Message as PreviewMessage } from "@/components/message"; +import { useScrollToBottom } from "@/components/use-scroll-to-bottom"; +import { MultimodalInput } from "./multimodal-input"; +import { Overview } from "./overview"; -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) +export function Chat({ + id, + initialMessages, +}: { + id: string; + initialMessages: Array; +}) { + const [selectedFilePathnames] = useState>([]); - useEffect(() => { - if (session?.user) { - if (!path.includes('chat') && messages.length === 1) { - window.history.replaceState({}, '', `/chat/${id}`) - } - } - }, [id, path, session?.user, messages]) + const { messages, handleSubmit, input, setInput, append, isLoading, stop } = + useChat({ + body: { id, selectedFilePathnames }, + initialMessages, + onFinish: () => { + window.history.replaceState({}, "", `/${id}`); + }, + }); - useEffect(() => { - const messagesLength = aiState.messages?.length - if (messagesLength === 2) { - router.refresh() - } - }, [aiState.messages, router]) + const [messagesContainerRef, messagesEndRef] = + useScrollToBottom(); - useEffect(() => { - setNewChatId(id) - }) + const [attachments, setAttachments] = useState>([]); - useEffect(() => { - missingKeys.map(key => { - toast.error(`Missing ${key} environment variable!`) - }) - }, [missingKeys]) + return ( +
+
+
+ {messages.length === 0 && } - const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } = - useScrollAnchor() + {messages.map((message, index) => ( + + ))} +
+
- return ( -
-
- {messages.length ? ( - - ) : ( - - )} -
+
+ +
-
- ) + ); } diff --git a/components/clear-history.tsx b/components/clear-history.tsx deleted file mode 100644 index 69cf70e..0000000 --- a/components/clear-history.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client' - -import * as React from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' - -import { ServerActionResult } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from '@/components/ui/alert-dialog' -import { IconSpinner } from '@/components/ui/icons' - -interface ClearHistoryProps { - isEnabled: boolean - clearChats: () => ServerActionResult -} - -export function ClearHistory({ - isEnabled = false, - clearChats -}: ClearHistoryProps) { - const [open, setOpen] = React.useState(false) - const [isPending, startTransition] = React.useTransition() - const router = useRouter() - - return ( - - - - - - - Are you absolutely sure? - - This will permanently delete your chat history and remove your data - from our servers. - - - - Cancel - { - event.preventDefault() - startTransition(async () => { - const result = await clearChats() - if (result && 'error' in result) { - toast.error(result.error) - return - } - - setOpen(false) - }) - }} - > - {isPending && } - Delete - - - - - ) -} diff --git a/components/empty-screen.tsx b/components/empty-screen.tsx deleted file mode 100644 index 719f693..0000000 --- a/components/empty-screen.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index ba6cc01..0000000 --- a/components/external-link.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 018be51..0000000 --- a/components/flights/boarding-pass.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'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 deleted file mode 100644 index 74b28ef..0000000 --- a/components/flights/destinations.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'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 deleted file mode 100644 index 110cdfa..0000000 --- a/components/flights/flight-status.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'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 deleted file mode 100644 index 25944c9..0000000 --- a/components/flights/list-flights.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'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 deleted file mode 100644 index 73f7148..0000000 --- a/components/flights/purchase-ticket.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'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 deleted file mode 100644 index 23ebf11..0000000 --- a/components/flights/select-seats.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* 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 deleted file mode 100644 index 3e700eb..0000000 --- a/components/footer.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/form.tsx b/components/form.tsx new file mode 100644 index 0000000..1b5fd79 --- /dev/null +++ b/components/form.tsx @@ -0,0 +1,44 @@ +import { Input } from "./shadcn/input"; +import { Label } from "./shadcn/label"; + +export function Form({ + action, + children, +}: { + action: any; + children: React.ReactNode; +}) { + return ( +
+
+ + + + + + + +
+ + {children} +
+ ); +} diff --git a/components/header.tsx b/components/header.tsx deleted file mode 100644 index 3db547d..0000000 --- a/components/header.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* 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/history.tsx b/components/history.tsx new file mode 100644 index 0000000..29cc1e9 --- /dev/null +++ b/components/history.tsx @@ -0,0 +1,241 @@ +"use client"; + +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { + InfoIcon, + MenuIcon, + MoreHorizontalIcon, + PencilEditIcon, + TrashIcon, +} from "./icons"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import Link from "next/link"; +import cx from "classnames"; +import { useParams, usePathname } from "next/navigation"; +import { Chat } from "@/utils/supabase/schema"; +import { fetcher } from "@/utils/functions"; +import { Button } from "./shadcn/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./shadcn/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./shadcn/alert-dialog"; +import { toast } from "sonner"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "./shadcn/sheet"; +import { User } from "@supabase/supabase-js"; +import { ThemeToggle } from "./theme-toggle"; + +export const History = ({ user }: { user: User | null }) => { + const { id } = useParams(); + const pathname = usePathname(); + + const [isHistoryVisible, setIsHistoryVisible] = useState(false); + const { + data: history, + error, + isLoading, + mutate, + } = useSWR>("/api/history", fetcher, { + fallbackData: [], + }); + + useEffect(() => { + mutate(); + }, [pathname, mutate]); + + const [deleteId, setDeleteId] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDelete = async () => { + const deletePromise = fetch(`/api/chat?id=${deleteId}`, { + method: "DELETE", + }); + + toast.promise(deletePromise, { + loading: "Deleting chat...", + success: () => { + mutate((history) => { + if (history) { + return history.filter((h) => h.id !== id); + } + }); + return "Chat deleted successfully"; + }, + error: "Failed to delete chat", + }); + + setShowDeleteDialog(false); + }; + + return ( + <> + + + { + setIsHistoryVisible(state); + }} + > + + + + History + + {history === undefined ? "loading" : history.length} chats + + + + +
+
+
History
+ +
+ {history === undefined ? "loading" : history.length} chats +
+
+
+ +
+ {user && ( + + )} + +
+ {!user ? ( +
+ +
Login to save and revisit previous chats!
+
+ ) : null} + + {!isLoading && history?.length === 0 && user ? ( +
+ +
No chats found
+
+ ) : null} + + {isLoading && user ? ( +
+ {[44, 32, 28, 52].map((item) => ( +
+
+
+ ))} +
+ ) : null} + + {history && + history.map((chat) => ( +
+ + + + + + + + + + + + +
+ ))} +
+
+ + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + chat and remove it from our servers. + + + + Cancel + + Continue + + + + + + ); +}; diff --git a/components/hotels/list-hotels.tsx b/components/hotels/list-hotels.tsx deleted file mode 100644 index 7e9664c..0000000 --- a/components/hotels/list-hotels.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* 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/icons.tsx b/components/icons.tsx new file mode 100644 index 0000000..c267c88 --- /dev/null +++ b/components/icons.tsx @@ -0,0 +1,611 @@ +export const BotIcon = () => { + return ( + + + + ); +}; + +export const UserIcon = () => { + return ( + + + + ); +}; + +export const AttachmentIcon = () => { + return ( + + + + ); +}; + +export const VercelIcon = ({ size = 17 }) => { + return ( + + + + ); +}; + +export const GitIcon = () => { + return ( + + + + + + + + + + + ); +}; + +export const BoxIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const HomeIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const GPSIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const InvoiceIcon = ({ size = 16 }: { size: number }) => { + return ( + + + + ); +}; + +export const LogoOpenAI = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const LogoGoogle = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + + ); +}; + +export const LogoAnthropic = () => { + return ( + + + + ); +}; + +export const RouteIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const FileIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const LoaderIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export const UploadIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MenuIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const PencilEditIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const CheckedSquare = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const UncheckedSquare = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MoreIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const TrashIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const InfoIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const ArrowUpIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const StopIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const PaperclipIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MoreHorizontalIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; + +export const MessageIcon = ({ size = 16 }: { size?: number }) => { + return ( + + + + ); +}; diff --git a/components/login-button.tsx b/components/login-button.tsx deleted file mode 100644 index ae8f842..0000000 --- a/components/login-button.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'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 deleted file mode 100644 index dc64b06..0000000 --- a/components/login-form.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'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 index d449146..4c552bc 100644 --- a/components/markdown.tsx +++ b/components/markdown.tsx @@ -1,9 +1,78 @@ -import { FC, memo } from 'react' -import ReactMarkdown, { Options } from 'react-markdown' +import Link from "next/link"; +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; -export const MemoizedReactMarkdown: FC = memo( - ReactMarkdown, - (prevProps, nextProps) => - prevProps.children === nextProps.children && - prevProps.className === nextProps.className -) +const NonMemoizedMarkdown = ({ children }: { children: string }) => { + const components = { + code: ({ node, inline, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + return !inline && match ? ( +
+          {children}
+        
+ ) : ( + + {children} + + ); + }, + ol: ({ node, children, ...props }: any) => { + return ( +
    + {children} +
+ ); + }, + li: ({ node, children, ...props }: any) => { + return ( +
  • + {children} +
  • + ); + }, + ul: ({ node, children, ...props }: any) => { + return ( +
      + {children} +
    + ); + }, + strong: ({ node, children, ...props }: any) => { + return ( + + {children} + + ); + }, + a: ({ node, children, ...props }: any) => { + return ( + + {children} + + ); + }, + }; + + return ( + + {children} + + ); +}; + +export const Markdown = React.memo( + NonMemoizedMarkdown, + (prevProps, nextProps) => prevProps.children === nextProps.children, +); diff --git a/components/media/video.tsx b/components/media/video.tsx deleted file mode 100644 index a8f3846..0000000 --- a/components/media/video.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { SpinnerIcon } from '../ui/icons' - -export const Video = ({ isLoading }: { isLoading: boolean }) => { - return ( -
    -
    - ) -} diff --git a/components/message.tsx b/components/message.tsx new file mode 100644 index 0000000..cf33146 --- /dev/null +++ b/components/message.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { motion } from "framer-motion"; +import { BotIcon, UserIcon } from "./icons"; +import { ReactNode } from "react"; +import { Markdown } from "./markdown"; +import { Attachment, ToolInvocation } from "ai"; +import { PreviewAttachment } from "./preview-attachment"; +import { Weather } from "./weather"; + +export const Message = ({ + role, + content, + toolInvocations, + attachments, +}: { + role: string; + content: string | ReactNode; + toolInvocations: Array | undefined; + attachments?: Array; +}) => { + return ( + +
    + {role === "assistant" ? : } +
    + +
    + {content && ( +
    + {content as string} +
    + )} + + {toolInvocations && ( +
    + {toolInvocations.map((toolInvocation) => { + const { toolName, toolCallId, state } = toolInvocation; + + if (state === "result") { + const { result } = toolInvocation; + + return ( +
    + {toolName === "getWeather" ? ( + + ) : null} +
    + ); + } else { + return ( +
    + {toolName === "getWeather" ? : null} +
    + ); + } + })} +
    + )} + + {attachments && ( +
    + {attachments.map((attachment) => ( + + ))} +
    + )} +
    +
    + ); +}; diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx new file mode 100644 index 0000000..bc578d0 --- /dev/null +++ b/components/multimodal-input.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { Button } from "./shadcn/button"; +import { motion } from "framer-motion"; +import { Textarea } from "./shadcn/textarea"; +import { PreviewAttachment } from "./preview-attachment"; +import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; +import React, { + useRef, + useEffect, + useState, + useCallback, + Dispatch, + SetStateAction, + ChangeEvent, +} from "react"; +import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai"; + +const suggestedActions = [ + { + title: "What is", + label: "the meaning of life?", + action: "what is the meaning of life?", + }, + { + title: "Why do", + label: "developers use Next.js?", + action: "why do developers use Next.js?", + }, +]; + +export function MultimodalInput({ + input, + setInput, + isLoading, + stop, + attachments, + setAttachments, + messages, + append, + handleSubmit, +}: { + input: string; + setInput: (value: string) => void; + isLoading: boolean; + stop: () => void; + attachments: Array; + setAttachments: Dispatch>>; + messages: Array; + append: ( + message: Message | CreateMessage, + chatRequestOptions?: ChatRequestOptions, + ) => Promise; + handleSubmit: ( + event?: { + preventDefault?: () => void; + }, + chatRequestOptions?: ChatRequestOptions, + ) => void; +}) { + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current) { + adjustHeight(); + } + }, []); + + const adjustHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; + } + }; + + const handleInput = (event: React.ChangeEvent) => { + setInput(event.target.value); + adjustHeight(); + }; + + const fileInputRef = useRef(null); + const [uploadQueue, setUploadQueue] = useState>([]); + + const submitForm = useCallback(() => { + handleSubmit(undefined, { + experimental_attachments: attachments, + }); + + setAttachments([]); + }, [attachments, handleSubmit, setAttachments]); + + const uploadFile = async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch(`/api/files/upload`, { + method: "POST", + body: formData, + }); + + if (response.ok) { + const { data } = await response.json(); + const { path } = data; + + return { + url: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/attachments/${path}`, + name: file.name, + contentType: file.type, + }; + } else { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error("Error uploading file:", error); + throw error; + } + }; + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + + setUploadQueue(files.map((file) => file.name)); + + try { + const uploadPromises = files.map((file) => uploadFile(file)); + const uploadedAttachments = await Promise.all(uploadPromises); + + setAttachments((currentAttachments) => [ + ...currentAttachments, + ...uploadedAttachments, + ]); + } catch (error) { + console.error("Error uploading files:", error); + } finally { + setUploadQueue([]); + } + }, + [setAttachments], + ); + + return ( +
    + {messages.length === 0 && + attachments.length === 0 && + uploadQueue.length === 0 && ( +
    + {suggestedActions.map((suggestedAction, index) => ( + 1 ? "hidden sm:block" : "block"} + > + + + ))} +
    + )} + + + + {(attachments.length > 0 || uploadQueue.length > 0) && ( +
    + {attachments.map((attachment) => ( + + ))} + + {uploadQueue.map((filename) => ( + + ))} +
    + )} + +