Refactor to use ai/rsc (#253)
This commit is contained in:
parent
69ca8fcc22
commit
e85ba803dd
66 changed files with 2799 additions and 740 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 !== '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export { GET, POST } from '@/auth'
|
||||
export const runtime = 'edge'
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
50
app/login/actions.ts
Normal 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
18
app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
75
app/signup/actions.ts
Normal 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
18
app/signup/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue