Refactor to use hooks (#436)

This commit is contained in:
Jeremy 2024-10-11 18:00:22 +05:30 committed by GitHub
parent 124efca9a1
commit cb60f8b143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 8871 additions and 8726 deletions

85
app/(auth)/actions.ts Normal file
View file

@ -0,0 +1,85 @@
"use server";
import { z } from "zod";
import { createUser, getUser } from "@/db/queries";
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export interface LoginActionState {
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
}
export const login = async (
_: LoginActionState,
formData: FormData,
): Promise<LoginActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
return { status: "failed" };
}
};
export interface RegisterActionState {
status:
| "idle"
| "in_progress"
| "success"
| "failed"
| "user_exists"
| "invalid_data";
}
export const register = async (
_: RegisterActionState,
formData: FormData,
): Promise<RegisterActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
let [user] = await getUser(validatedData.email);
if (user) {
return { status: "user_exists" } as RegisterActionState;
} else {
await createUser(validatedData.email, validatedData.password);
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
}
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
return { status: "failed" };
}
};

View file

@ -0,0 +1 @@
export { GET, POST } from "@/app/(auth)/auth";

39
app/(auth)/auth.config.ts Normal file
View file

@ -0,0 +1,39 @@
import { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: {
signIn: "/login",
newUser: "/",
},
providers: [
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
// while this file is also used in non-Node.js environments
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
let isLoggedIn = !!auth?.user;
let isOnChat = nextUrl.pathname.startsWith("/");
let isOnRegister = nextUrl.pathname.startsWith("/register");
let isOnLogin = nextUrl.pathname.startsWith("/login");
if (isLoggedIn && (isOnLogin || isOnRegister)) {
return Response.redirect(new URL("/", nextUrl));
}
if (isOnRegister || isOnLogin) {
return true; // Always allow access to register and login pages
}
if (isOnChat) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
}
if (isLoggedIn) {
return Response.redirect(new URL("/", nextUrl));
}
return true;
},
},
} satisfies NextAuthConfig;

53
app/(auth)/auth.ts Normal file
View file

@ -0,0 +1,53 @@
import { compare } from "bcrypt-ts";
import NextAuth, { User, Session } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { getUser } from "@/db/queries";
import { authConfig } from "./auth.config";
interface ExtendedSession extends Session {
user: User;
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {},
async authorize({ email, password }: any) {
let users = await getUser(email);
if (users.length === 0) return null;
let passwordsMatch = await compare(password, users[0].password!);
if (passwordsMatch) return users[0] as any;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({
session,
token,
}: {
session: ExtendedSession;
token: any;
}) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});

65
app/(auth)/login/page.tsx Normal file
View file

@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import { toast } from "sonner";
import { AuthForm } from "@/components/custom/auth-form";
import { SubmitButton } from "@/components/custom/submit-button";
import { login, LoginActionState } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: "idle",
},
);
useEffect(() => {
if (state.status === "failed") {
toast.error("Invalid credentials!");
} else if (state.status === "invalid_data") {
toast.error("Failed validating your submission!");
} else if (state.status === "success") {
router.refresh();
}
}, [state.status, router]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="w-full max-w-md overflow-hidden rounded-2xl flex flex-col gap-12">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign In</h3>
<p className="text-sm text-gray-500 dark:text-zinc-400">
Use your email and password to sign in
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton>Sign in</SubmitButton>
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
{"Don't have an account? "}
<Link
href="/register"
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
>
Sign up
</Link>
{" for free."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import { toast } from "sonner";
import { AuthForm } from "@/components/custom/auth-form";
import { SubmitButton } from "@/components/custom/submit-button";
import { register, RegisterActionState } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [state, formAction] = useActionState<RegisterActionState, FormData>(
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 === "invalid_data") {
toast.error("Failed validating your submission!");
} else if (state.status === "success") {
toast.success("Account created successfully");
router.refresh();
}
}, [state, router]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="w-full max-w-md overflow-hidden rounded-2xl gap-12 flex flex-col">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign Up</h3>
<p className="text-sm text-gray-500 dark:text-zinc-400">
Create an account with your email and password
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton>Sign Up</SubmitButton>
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
{"Already have an account? "}
<Link
href="/login"
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
>
Sign in
</Link>
{" instead."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { convertToCoreMessages, Message, streamText } from "ai";
import { z } from "zod";
import { customModel } from "@/ai";
import { auth } from "@/app/(auth)/auth";
import { deleteChatById, getChatById, saveChat } from "@/db/queries";
export async function POST(request: Request) {
const { id, messages }: { id: string; messages: Array<Message> } =
await request.json();
const session = await auth();
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const coreMessages = convertToCoreMessages(messages);
const result = await streamText({
model: customModel,
system:
"you are a friendly assistant! keep your responses concise and helpful.",
messages: coreMessages,
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}&current=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
);
const weatherData = await response.json();
return weatherData;
},
},
},
onFinish: async ({ responseMessages }) => {
if (session.user && session.user.id) {
try {
await saveChat({
id,
messages: [...coreMessages, ...responseMessages],
userId: session.user.id,
});
} catch (error) {
console.error("Failed to save chat");
}
}
},
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 session = await auth();
if (!session || !session.user) {
return new Response("Unauthorized", { status: 401 });
}
try {
const chat = await getChatById({ id });
if (chat.userId !== session.user.id) {
return new Response("Unauthorized", { status: 401 });
}
await deleteChatById({ id });
return new Response("Chat deleted", { status: 200 });
} catch (error) {
return new Response("An error occurred while processing your request", {
status: 500,
});
}
}

View file

@ -0,0 +1,69 @@
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
const FileSchema = z.object({
file: z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: "File size should be less than 5MB",
})
.refine(
(file) =>
["image/jpeg", "image/png", "application/pdf"].includes(file.type),
{
message: "File type should be JPEG, PNG, or PDF",
},
),
});
export async function POST(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
return new Response("Request body is empty", { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const validatedFile = FileSchema.safeParse({ file });
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
.join(", ");
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
const filename = file.name;
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${filename}`, fileBuffer, {
access: "public",
});
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
} catch (error) {
return NextResponse.json(
{ error: "Failed to process request" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,13 @@
import { auth } from "@/app/(auth)/auth";
import { getChatsByUserId } from "@/db/queries";
export async function GET() {
const session = await auth();
if (!session || !session.user) {
return Response.json("Unauthorized!", { status: 401 });
}
const chats = await getChatsByUserId({ id: session.user.id! });
return Response.json(chats);
}

View file

@ -1,65 +1,108 @@
import { type Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { CoreMessage, CoreToolMessage, Message, ToolInvocation } from "ai";
import { notFound } 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'
import { auth } from "@/app/(auth)/auth";
import { Chat as PreviewChat } from "@/components/custom/chat";
import { getChatById } from "@/db/queries";
import { Chat } from "@/db/schema";
import { generateUUID } from "@/lib/utils";
export interface ChatPageProps {
params: {
id: string
}
}
function addToolMessageToChat({
toolMessage,
messages,
}: {
toolMessage: CoreToolMessage;
messages: Array<Message>;
}): Array<Message> {
return messages.map((message) => {
if (message.toolInvocations) {
return {
...message,
toolInvocations: message.toolInvocations.map((toolInvocation) => {
const toolResult = toolMessage.content.find(
(tool) => tool.toolCallId === toolInvocation.toolCallId,
);
export async function generateMetadata({
params
}: ChatPageProps): Promise<Metadata> {
const session = await auth()
if (toolResult) {
return {
...toolInvocation,
state: "result",
result: toolResult.result,
};
}
if (!session?.user) {
return {}
}
const chat = await getChat(params.id, session.user.id)
if (!chat || 'error' in chat) {
redirect('/')
} else {
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 || 'error' in chat) {
redirect('/')
} else {
if (chat?.userId !== session?.user?.id) {
notFound()
return toolInvocation;
}),
};
}
return (
<AI initialAIState={{ chatId: chat.id, messages: chat.messages }}>
<Chat
id={chat.id}
session={session}
initialMessages={chat.messages}
missingKeys={missingKeys}
/>
</AI>
)
}
return message;
});
}
function convertToUIMessages(messages: Array<CoreMessage>): Array<Message> {
return messages.reduce((chatMessages: Array<Message>, message) => {
if (message.role === "tool") {
return addToolMessageToChat({
toolMessage: message as CoreToolMessage,
messages: chatMessages,
});
}
let textContent = "";
let toolInvocations: Array<ToolInvocation> = [];
if (typeof message.content === "string") {
textContent = message.content;
} else if (Array.isArray(message.content)) {
for (const content of message.content) {
if (content.type === "text") {
textContent += content.text;
} else if (content.type === "tool-call") {
toolInvocations.push({
state: "call",
toolCallId: content.toolCallId,
toolName: content.toolName,
args: content.args,
});
}
}
}
chatMessages.push({
id: generateUUID(),
role: message.role,
content: textContent,
toolInvocations,
});
return chatMessages;
}, []);
}
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: convertToUIMessages(chatFromDb.messages as Array<CoreMessage>),
};
const session = await auth();
if (!session || !session.user) {
return notFound();
}
if (session.user.id !== chat.userId) {
return notFound();
}
return <PreviewChat id={chat.id} initialMessages={chat.messages} />;
}

View file

@ -1,14 +0,0 @@
import { SidebarDesktop } from '@/components/sidebar-desktop'
interface ChatLayoutProps {
children: React.ReactNode
}
export default async function ChatLayout({ children }: ChatLayoutProps) {
return (
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
<SidebarDesktop />
{children}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -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 '@/app/actions'
import { Chat } from "@/components/custom/chat";
import { generateUUID } from "@/lib/utils";
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 (
<AI initialAIState={{ chatId: id, messages: [] }}>
<Chat id={id} session={session} missingKeys={missingKeys} />
</AI>
)
export default async function Page() {
const id = generateUUID();
return <Chat key={id} id={id} initialMessages={[]} />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -1,172 +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) {
const session = await auth()
if (!userId) {
return []
}
if (userId !== session?.user?.id) {
return {
error: 'Unauthorized'
}
}
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 session = await auth()
if (userId !== session?.user?.id) {
return {
error: 'Unauthorized'
}
}
const chat = await kv.hgetall<Chat>(`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>(`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>(`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 = ['OPENAI_API_KEY']
return keysRequired
.map(key => (process.env[key] ? '' : key))
.filter(key => key !== '')
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,76 +1,110 @@
@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%;
}
: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;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
: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;
}
@font-face {
font-family: "geist";
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist.woff2) format("woff2");
}
@font-face {
font-family: "geist-mono";
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist-mono.woff2) format("woff2");
}
}
.skeleton {
*[class^="text-"] {
color: transparent;
@apply rounded-md bg-foreground/20 select-none;
}
}

View file

@ -1,64 +1,36 @@
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import { Metadata } from "next";
import { Toaster } from "sonner";
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 { Navbar } from "@/components/custom/navbar";
import { ThemeProvider } from "@/components/custom/theme-provider";
export const metadata = {
metadataBase: process.env.VERCEL_URL
? new URL(`https://${process.env.VERCEL_URL}`)
: undefined,
title: {
default: 'Next.js AI Chatbot',
template: `%s - Next.js AI Chatbot`
},
description: 'An AI-powered chatbot template built with Next.js and Vercel.',
icons: {
icon: '/favicon.ico',
shortcut: '/favicon-16x16.png',
apple: '/apple-touch-icon.png'
}
}
import "./globals.css";
export const viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' }
]
}
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
title: "Next.js Chatbot Template",
description: "Next.js chatbot template using the AI SDK.",
};
interface RootLayoutProps {
children: React.ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
'font-sans antialiased',
GeistSans.variable,
GeistMono.variable
)}
>
<Toaster position="top-center" />
<Providers
<html lang="en">
<body className="antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
</div>
<TailwindIndicator />
</Providers>
<Toaster position="top-center" />
<Navbar />
{children}
</ThemeProvider>
</body>
</html>
)
);
}

View file

@ -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>(`user:${email}`)
return user
}
interface Result {
type: string
resultCode: ResultCode
}
export async function authenticate(
_prevState: Result | undefined,
formData: FormData
): Promise<Result | undefined> {
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
}
}
}
}
}

View file

@ -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 (
<main className="flex flex-col p-4">
<LoginForm />
</main>
)
}

View file

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default async function NewPage() {
redirect('/')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

View file

@ -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<Metadata> {
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 (
<>
<div className="flex-1 space-y-6">
<div className="border-b bg-background px-4 py-6 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<div className="space-y-1 md:-mx-8">
<h1 className="text-2xl font-bold">{chat.title}</h1>
<div className="text-sm text-muted-foreground">
{formatDate(chat.createdAt)} · {chat.messages.length} messages
</div>
</div>
</div>
</div>
<AI>
<ChatList messages={uiState} isShared={true} />
</AI>
</div>
<FooterText className="py-8" />
</>
)
}

View file

@ -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<Result | undefined> {
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
}
}
}

View file

@ -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 (
<main className="flex flex-col p-4">
<SignupForm />
</main>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB