diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dedef78cd3504e365284900f8ec7999badabb24 --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,129 @@ +'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 +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e06982394c99eaf0e00845b68d66dfb65c1a6e7b --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,57 @@ +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 payload = { + id, + title, + userId, + createdAt, + messages: [ + ...messages, + { + content: completion, + role: 'assistant' + } + ] + } + } + }) + + return new StreamingTextResponse(stream) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9d70d91e2302abcbf01cd769ada860244532f6e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,62 @@ +import { Toaster } from 'react-hot-toast' +import { GeistSans } from 'geist/font/sans' +import { GeistMono } from 'geist/font/mono' + +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' + +export const metadata = { + metadataBase: new URL(`https://${process.env.VERCEL_URL}`), + 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' + } +} + +export const viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: 'white' }, + { media: '(prefers-color-scheme: dark)', color: 'black' } + ] +} + +interface RootLayoutProps { + children: React.ReactNode +} + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + +
+
+
{children}
+
+ +
+ + + ) +} diff --git a/app/opengraph-image.png b/app/opengraph-image.png new file mode 100644 index 0000000000000000000000000000000000000000..278b197ef70a1aaf344b9f86061ec0cefe80fff7 Binary files /dev/null and b/app/opengraph-image.png differ diff --git a/app/twitter-image.png b/app/twitter-image.png new file mode 100644 index 0000000000000000000000000000000000000000..278b197ef70a1aaf344b9f86061ec0cefe80fff7 Binary files /dev/null and b/app/twitter-image.png differ diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c0c6a03f1673fbb44bb92b62136ab80d996c9aa --- /dev/null +++ b/auth.ts @@ -0,0 +1,39 @@ +import NextAuth, { type DefaultSession } from 'next-auth' +import GitHub from 'next-auth/providers/github' + +declare module 'next-auth' { + interface Session { + user: { + /** The user's id. */ + id: string + } & DefaultSession['user'] + } +} + +export const { + handlers: { GET, POST }, + auth +} = NextAuth({ + providers: [GitHub], + callbacks: { + jwt({ token, profile }) { + if (profile) { + token.id = profile.id + token.image = profile.avatar_url || profile.picture + } + return token + }, + session: ({ session, token }) => { + if (session?.user && token?.id) { + session.user.id = String(token.id) + } + return session + }, + authorized({ auth }) { + return !!auth?.user // this ensures there is a logged in user for -every- request + } + }, + pages: { + signIn: '/sign-in' // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages + } +}) diff --git a/components/button-scroll-to-bottom.tsx b/components/button-scroll-to-bottom.tsx new file mode 100644 index 0000000000000000000000000000000000000000..436a6c0a7f5fe4c51efbdc867fa916a1977ec24f --- /dev/null +++ b/components/button-scroll-to-bottom.tsx @@ -0,0 +1,34 @@ +'use client' + +import * as React from 'react' + +import { cn } from '@/lib/utils' +import { useAtBottom } from '@/lib/hooks/use-at-bottom' +import { Button, type ButtonProps } from '@/components/ui/button' +import { IconArrowDown } from '@/components/ui/icons' + +export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { + const isAtBottom = useAtBottom() + + return ( + + ) +} diff --git a/components/chat-history.tsx b/components/chat-history.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7450fe8cb7a7747b1cb86ef4b3d581ceed840c7c --- /dev/null +++ b/components/chat-history.tsx @@ -0,0 +1,46 @@ +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 ( +
+
+ + + New Chat + +
+ + {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ } + > + {/* @ts-ignore */} + +
+
+ ) +} diff --git a/components/chat-list.tsx b/components/chat-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0aa677ede27d8fd241f05e0e32445dae75bc8eb7 --- /dev/null +++ b/components/chat-list.tsx @@ -0,0 +1,27 @@ +import { type Message } from 'ai' + +import { Separator } from '@/components/ui/separator' +import { ChatMessage } from '@/components/chat-message' + +export interface ChatList { + messages: Message[] +} + +export function ChatList({ messages }: ChatList) { + if (!messages.length) { + return null + } + + return ( +
+ {messages.map((message, index) => ( +
+ + {index < messages.length - 1 && ( + + )} +
+ ))} +
+ ) +} diff --git a/components/chat-message-actions.tsx b/components/chat-message-actions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d4e4b40a4d008e9b0f0d4414085793fe9bb03720 --- /dev/null +++ b/components/chat-message-actions.tsx @@ -0,0 +1,40 @@ +'use client' + +import { type Message } from 'ai' + +import { Button } from '@/components/ui/button' +import { IconCheck, IconCopy } from '@/components/ui/icons' +import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' +import { cn } from '@/lib/utils' + +interface ChatMessageActionsProps extends React.ComponentProps<'div'> { + message: Message +} + +export function ChatMessageActions({ + message, + className, + ...props +}: ChatMessageActionsProps) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) + + const onCopy = () => { + if (isCopied) return + copyToClipboard(message.content) + } + + return ( +
+ +
+ ) +} diff --git a/components/chat-message.tsx b/components/chat-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32ecd78411c2bfb51753f99eb9aa97b5a7db08ff --- /dev/null +++ b/components/chat-message.tsx @@ -0,0 +1,80 @@ +// Inspired by Chatbot-UI and modified to fit the needs of this project +// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx + +import { Message } from 'ai' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' + +import { cn } from '@/lib/utils' +import { CodeBlock } from '@/components/ui/codeblock' +import { MemoizedReactMarkdown } from '@/components/markdown' +import { IconOpenAI, IconUser } from '@/components/ui/icons' +import { ChatMessageActions } from '@/components/chat-message-actions' + +export interface ChatMessageProps { + message: Message +} + +export function ChatMessage({ message, ...props }: ChatMessageProps) { + return ( +
+
+ {message.role === 'user' ? : } +
+
+ {children}

+ }, + code({ node, inline, className, children, ...props }) { + if (children.length) { + if (children[0] == '▍') { + return ( + + ) + } + + children[0] = (children[0] as string).replace('`▍`', '▍') + } + + const match = /language-(\w+)/.exec(className || '') + + if (inline) { + return ( + + {children} + + ) + } + + return ( + + ) + } + }} + > + {message.content} +
+ +
+
+ ) +} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17d4d596c922ef40a8371a999ebf2304daed1536 --- /dev/null +++ b/components/chat-panel.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { type UseChatHelpers } from 'ai/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 { IconRefresh, IconShare, IconStop } from '@/components/ui/icons' +import { ChatShareDialog } from '@/components/chat-share-dialog' + +export interface ChatPanelProps + extends Pick< + UseChatHelpers, + | 'append' + | 'isLoading' + | 'reload' + | 'messages' + | 'stop' + | 'input' + | 'setInput' + > { + id?: string + title?: string +} + +export function ChatPanel({ + id, + title, + isLoading, + stop, + append, + reload, + input, + setInput, + messages +}: ChatPanelProps) { + const [shareDialogOpen, setShareDialogOpen] = React.useState(false) + + return ( +
+ +
+
+ {isLoading ? ( + + ) : ( + messages?.length >= 2 && ( +
+ + {id && title ? ( + <> + + setShareDialogOpen(false)} + shareChat={shareChat} + chat={{ + id, + title, + messages + }} + /> + + ) : null} +
+ ) + )} +
+
+ { + await append({ + id, + content: value, + role: 'user' + }) + }} + input={input} + setInput={setInput} + isLoading={isLoading} + /> +
+
+
+ ) +} diff --git a/components/chat-scroll-anchor.tsx b/components/chat-scroll-anchor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac809f4486a48e134cb69314c3d0dae5e68d614e --- /dev/null +++ b/components/chat-scroll-anchor.tsx @@ -0,0 +1,29 @@ +'use client' + +import * as React from 'react' +import { useInView } from 'react-intersection-observer' + +import { useAtBottom } from '@/lib/hooks/use-at-bottom' + +interface ChatScrollAnchorProps { + trackVisibility?: boolean +} + +export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { + const isAtBottom = useAtBottom() + const { ref, entry, inView } = useInView({ + trackVisibility, + delay: 100, + rootMargin: '0px 0px -150px 0px' + }) + + React.useEffect(() => { + if (isAtBottom && trackVisibility && !inView) { + entry?.target.scrollIntoView({ + block: 'start' + }) + } + }, [inView, entry, isAtBottom, trackVisibility]) + + return
+} diff --git a/components/chat-share-dialog.tsx b/components/chat-share-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83bac71cbfc74e4ab30378911cdeb4f77c2be2df --- /dev/null +++ b/components/chat-share-dialog.tsx @@ -0,0 +1,106 @@ +'use client' + +import * as React from 'react' +import { type DialogProps } from '@radix-ui/react-dialog' +import { toast } from 'react-hot-toast' + +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', { + style: { + borderRadius: '10px', + background: '#333', + color: '#fff', + fontSize: '14px' + }, + iconTheme: { + primary: 'white', + secondary: 'black' + } + }) + }, + [copyToClipboard, onCopy] + ) + + return ( + + + + Share link to chat + + Anyone with the URL will be able to view the shared chat. + + +
+
{chat.title}
+
+ {chat.messages.length} messages +
+
+ + + +
+
+ ) +} diff --git a/components/chat.tsx b/components/chat.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e3cff4bb81857a0cee00865526b22f921e7799b --- /dev/null +++ b/components/chat.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useChat, type Message } from 'ai/react' + +import { cn } from '@/lib/utils' +import { ChatList } from '@/components/chat-list' +import { ChatPanel } from '@/components/chat-panel' +import { EmptyScreen } from '@/components/empty-screen' +import { ChatScrollAnchor } from '@/components/chat-scroll-anchor' +import { useLocalStorage } from '@/lib/hooks/use-local-storage' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { useState } from 'react' +import { Button } from './ui/button' +import { Input } from './ui/input' +import { toast } from 'react-hot-toast' +import { usePathname, useRouter } from 'next/navigation' + +export interface ChatProps extends React.ComponentProps<'div'> { + initialMessages?: Message[] + id?: string +} + +export function Chat({ id, initialMessages, className }: ChatProps) { + const router = useRouter() + const path = usePathname() + const [previewToken, setPreviewToken] = useLocalStorage( + 'ai-token', + null + ) + const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '') + const { messages, append, reload, stop, isLoading, input, setInput } = + useChat({ + initialMessages, + id, + body: { + id, + previewToken + }, + onResponse(response) { + if (response.status === 401) { + toast.error(response.statusText) + } + } + }) + return ( + <> +
+ {messages.length ? ( + <> + + + + ) : ( + + )} +
+ + + ) +} diff --git a/components/clear-history.tsx b/components/clear-history.tsx new file mode 100644 index 0000000000000000000000000000000000000000..553d2db3c9b35235147d3e4a8d991e82d5dac730 --- /dev/null +++ b/components/clear-history.tsx @@ -0,0 +1,77 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'react-hot-toast' + +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(() => { + clearChats().then(result => { + if (result && 'error' in result) { + toast.error(result.error) + return + } + + setOpen(false) + router.push('/') + }) + }) + }} + > + {isPending && } + Delete + + + + + ) +} diff --git a/components/empty-screen.tsx b/components/empty-screen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92245da1e1873e95ac56de8904d52b5b21f35e1f --- /dev/null +++ b/components/empty-screen.tsx @@ -0,0 +1,30 @@ +import { UseChatHelpers } from 'ai/react' + +import { Button } from '@/components/ui/button' +import { ExternalLink } from '@/components/external-link' +import { IconArrowRight } from '@/components/ui/icons' + +const exampleMessages = [ + { + heading: 'Explain technical concepts', + message: `What is a "serverless function"?` + }, + { + heading: 'Summarize an article', + message: 'Summarize the following article for a 2nd grader: \n' + }, + { + heading: 'Draft an email', + message: `Draft an email to my boss about the following: \n` + } +] + +export function EmptyScreen({ setInput }: Pick) { + return ( +
+
+

Welcome to Vision Agent

+
+
+ ) +} diff --git a/components/external-link.tsx b/components/external-link.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba6cc0161457e56c609c1a64ab458d573039d27d --- /dev/null +++ b/components/external-link.tsx @@ -0,0 +1,29 @@ +export function ExternalLink({ + href, + children +}: { + href: string + children: React.ReactNode +}) { + return ( + + {children} + + + ) +} diff --git a/components/header.tsx b/components/header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..72ea5c43719eff41f9ac4b1ce1eea9efa08c1b2b --- /dev/null +++ b/components/header.tsx @@ -0,0 +1,73 @@ +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' + +async function UserOrLogin() { + const session = await auth() + return ( + <> + {/* {session?.user ? ( + <> + + + + + + ) : ( */} + {/* + + + */} + {/* )} */} +
+ {/* */} + {session?.user ? ( + + ) : ( + + )} +
+ + ) +} + +export function Header() { + return ( +
+
+ {/* }> + + */} +
+
+ }> + + + {/* + + GitHub + */} +
+
+ ) +} diff --git a/components/login-button.tsx b/components/login-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ae8f8427413f44f19db3f10993ce0fa9b19b5fec --- /dev/null +++ b/components/login-button.tsx @@ -0,0 +1,42 @@ +'use client' + +import * as React from 'react' +import { signIn } from 'next-auth/react' + +import { cn } from '@/lib/utils' +import { Button, type ButtonProps } from '@/components/ui/button' +import { IconGitHub, IconSpinner } from '@/components/ui/icons' + +interface LoginButtonProps extends ButtonProps { + showGithubIcon?: boolean + text?: string +} + +export function LoginButton({ + text = 'Login with GitHub', + showGithubIcon = true, + className, + ...props +}: LoginButtonProps) { + const [isLoading, setIsLoading] = React.useState(false) + return ( + + ) +} diff --git a/components/markdown.tsx b/components/markdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d4491467a1f14d1d72e535caac9c40636054e5df --- /dev/null +++ b/components/markdown.tsx @@ -0,0 +1,9 @@ +import { FC, memo } from 'react' +import ReactMarkdown, { Options } from 'react-markdown' + +export const MemoizedReactMarkdown: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +) diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..748cdf25b000d26846ef91b4e73b85ac75811f8d --- /dev/null +++ b/components/prompt-form.tsx @@ -0,0 +1,97 @@ +import * as React from 'react' +import Textarea from 'react-textarea-autosize' +import { UseChatHelpers } from 'ai/react' +import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' +import { cn } from '@/lib/utils' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/components/ui/tooltip' +import { IconArrowElbow, IconPlus } from '@/components/ui/icons' +import { useRouter } from 'next/navigation' + +export interface PromptProps + extends Pick { + onSubmit: (value: string) => void + isLoading: boolean +} + +export function PromptForm({ + onSubmit, + input, + setInput, + isLoading +}: PromptProps) { + const { formRef, onKeyDown } = useEnterSubmit() + const inputRef = React.useRef(null) + const router = useRouter() + React.useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + + return ( +
{ + e.preventDefault() + if (!input?.trim()) { + return + } + setInput('') + await onSubmit(input) + }} + ref={formRef} + > +
+ {/* + + + + New Chat + */} +