Refactor to use ai/rsc (#253)

This commit is contained in:
Jeremy 2024-03-14 20:00:52 +03:00 committed by GitHub
parent 69ca8fcc22
commit e85ba803dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2799 additions and 740 deletions

View file

@ -2,8 +2,10 @@ import { type Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/auth'
import { getChat } from '@/app/actions'
import { getChat, getMissingKeys } from '@/app/actions'
import { Chat } from '@/components/chat'
import { AI } from '@/lib/chat/actions'
import { Session } from '@/lib/types'
export interface ChatPageProps {
params: {
@ -27,21 +29,32 @@ export async function generateMetadata({
}
export default async function ChatPage({ params }: ChatPageProps) {
const session = await auth()
const session = (await auth()) as Session
const missingKeys = await getMissingKeys()
if (!session?.user) {
redirect(`/sign-in?next=/chat/${params.id}`)
redirect(`/login?next=/chat/${params.id}`)
}
const chat = await getChat(params.id, session.user.id)
const userId = session.user.id as string
const chat = await getChat(params.id, userId)
if (!chat) {
notFound()
redirect('/')
}
if (chat?.userId !== session?.user?.id) {
notFound()
}
return <Chat id={chat.id} initialMessages={chat.messages} />
return (
<AI initialAIState={{ chatId: chat.id, messages: chat.messages }}>
<Chat
id={chat.id}
session={session}
initialMessages={chat.messages}
missingKeys={missingKeys}
/>
</AI>
)
}

View file

@ -1,8 +1,22 @@
import { nanoid } from '@/lib/utils'
import { Chat } from '@/components/chat'
import { AI } from '@/lib/chat/actions'
import { auth } from '@/auth'
import { Session } from '@/lib/types'
import { getMissingKeys } from '../actions'
export default function IndexPage() {
const id = nanoid()
return <Chat id={id} />
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>
)
}

View file

@ -127,3 +127,30 @@ export async function shareChat(id: string) {
return payload
}
export async function saveChat(chat: Chat) {
const session = await auth()
if (session && session.user) {
const pipeline = kv.pipeline()
pipeline.hmset(`chat:${chat.id}`, chat)
pipeline.zadd(`user:chat:${chat.userId}`, {
score: Date.now(),
member: `chat:${chat.id}`
})
await pipeline.exec()
} else {
return
}
}
export async function refreshHistory(path: string) {
redirect(path)
}
export async function getMissingKeys() {
const keysRequired = ['OPENAI_API_KEY']
return keysRequired
.map(key => (process.env[key] ? '' : key))
.filter(key => key !== '')
}

View file

@ -1,2 +0,0 @@
export { GET, POST } from '@/auth'
export const runtime = 'edge'

View file

@ -1,65 +0,0 @@
import { kv } from '@vercel/kv'
import { OpenAIStream, StreamingTextResponse } from 'ai'
import OpenAI from 'openai'
import { auth } from '@/auth'
import { nanoid } from '@/lib/utils'
export const runtime = 'edge'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
export async function POST(req: Request) {
const json = await req.json()
const { messages, previewToken } = json
const userId = (await auth())?.user.id
if (!userId) {
return new Response('Unauthorized', {
status: 401
})
}
if (previewToken) {
openai.apiKey = previewToken
}
const res = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages,
temperature: 0.7,
stream: true
})
const stream = OpenAIStream(res, {
async onCompletion(completion) {
const title = json.messages[0].content.substring(0, 100)
const id = json.id ?? nanoid()
const createdAt = Date.now()
const path = `/chat/${id}`
const payload = {
id,
title,
userId,
createdAt,
path,
messages: [
...messages,
{
content: completion,
role: 'assistant'
}
]
}
await kv.hmset(`chat:${id}`, payload)
await kv.zadd(`user:chat:${userId}`, {
score: createdAt,
member: `chat:${id}`
})
}
})
return new StreamingTextResponse(stream)
}

View file

@ -1,73 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: ;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--ring: 240 5% 64.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: ;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 240 3.7% 15.9%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
@ -75,4 +73,4 @@
body {
@apply bg-background text-foreground;
}
}
}

View file

@ -1,4 +1,3 @@
import { Toaster } from 'react-hot-toast'
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
@ -7,6 +6,7 @@ import { cn } from '@/lib/utils'
import { TailwindIndicator } from '@/components/tailwind-indicator'
import { Providers } from '@/components/providers'
import { Header } from '@/components/header'
import { Toaster } from '@/components/ui/sonner'
export const metadata = {
metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
@ -43,7 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
GeistMono.variable
)}
>
<Toaster />
<Toaster position="top-center" />
<Providers
attribute="class"
defaultTheme="system"

50
app/login/actions.ts Normal file
View file

@ -0,0 +1,50 @@
'use server'
import { signIn } from '@/auth'
import { AuthResult } from '@/lib/types'
import { AuthError } from 'next-auth'
import { z } from 'zod'
export async function authenticate(
_prevState: AuthResult | undefined,
formData: FormData
) {
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,
redirectTo: '/'
})
} else {
return { type: 'error', message: 'Invalid credentials!' }
}
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return { type: 'error', message: 'Invalid credentials!' }
default:
return {
type: 'error',
message: 'Something went wrong, please try again!'
}
}
}
throw error
}
}

18
app/login/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import { auth } from '@/auth'
import LoginForm from '@/components/login-form'
import { Session } from '@/lib/types'
import { redirect } from 'next/navigation'
export default async function LoginPage() {
const session = (await auth()) as Session
if (session) {
redirect('/')
}
return (
<main className="flex flex-col p-4">
<LoginForm />
</main>
)
}

View file

@ -1,10 +1,14 @@
import { type Metadata } from 'next'
import { notFound } from 'next/navigation'
import { notFound, redirect } from 'next/navigation'
import { formatDate } from '@/lib/utils'
import { getSharedChat } from '@/app/actions'
import { ChatList } from '@/components/chat-list'
import { FooterText } from '@/components/footer'
import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions'
export const runtime = 'edge'
export const preferredRegion = 'home'
interface SharePageProps {
params: {
@ -29,11 +33,13 @@ export default async function SharePage({ params }: SharePageProps) {
notFound()
}
const uiState: UIState = getUIStateFromAIState(chat)
return (
<>
<div className="flex-1 space-y-6">
<div className="px-4 py-6 border-b bg-background md:px-6 md:py-8">
<div className="max-w-2xl mx-auto md:px-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">
@ -42,7 +48,9 @@ export default async function SharePage({ params }: SharePageProps) {
</div>
</div>
</div>
<ChatList messages={chat.messages} />
<AI>
<ChatList messages={uiState} />
</AI>
</div>
<FooterText className="py-8" />
</>

View file

@ -1,17 +0,0 @@
import { auth } from '@/auth'
import { LoginButton } from '@/components/login-button'
import { redirect } from 'next/navigation'
export default async function SignInPage() {
const session = await auth()
// redirect to home if user is already logged in
if (session?.user) {
redirect('/')
}
return (
<div className="flex h-[calc(100vh-theme(spacing.16))] items-center justify-center py-10">
<LoginButton />
</div>
)
}

75
app/signup/actions.ts Normal file
View file

@ -0,0 +1,75 @@
'use server'
import { signIn } from '@/auth'
import { db } from '@vercel/postgres'
import { getStringFromBuffer } from '@/lib/utils'
import { z } from 'zod'
import { AuthResult } from '@/lib/types'
export async function signup(
_prevState: AuthResult | undefined,
formData: FormData
) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const parsedCredentials = z
.object({
email: z.string().email(),
password: z.string().min(6)
})
.safeParse({
email,
password
})
if (parsedCredentials.success) {
const salt = crypto.randomUUID()
const encoder = new TextEncoder()
const saltedPassword = encoder.encode(password + salt)
const hashedPasswordBuffer = await crypto.subtle.digest(
'SHA-256',
saltedPassword
)
const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
const client = await db.connect()
try {
await client.sql`
INSERT INTO users (email, password, salt)
VALUES (${email}, ${hashedPassword}, ${salt})
ON CONFLICT (id) DO NOTHING;
`
await signIn('credentials', {
email,
password,
redirect: false
})
return { type: 'success', message: 'Account created!' }
} catch (error) {
const { message } = error as Error
if (
message.startsWith('duplicate key value violates unique constraint')
) {
return { type: 'error', message: 'User already exists! Please log in.' }
} else {
return {
type: 'error',
message: 'Something went wrong! Please try again.'
}
}
} finally {
client.release()
}
} else {
return {
type: 'error',
message: 'Invalid entries, please try again!'
}
}
}

18
app/signup/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import { auth } from '@/auth'
import SignupForm from '@/components/signup-form'
import { Session } from '@/lib/types'
import { redirect } from 'next/navigation'
export default async function SignupPage() {
const session = (await auth()) as Session
if (session) {
redirect('/')
}
return (
<main className="flex flex-col p-4">
<SignupForm />
</main>
)
}