'use client'; import type { UIMessage } from 'ai'; import { useRef, useEffect, useState, useCallback, type Dispatch, type SetStateAction, type ChangeEvent, memo, } from 'react'; import { toast } from 'sonner'; import { useLocalStorage, useWindowSize } from 'usehooks-ts'; import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons'; import { PreviewAttachment } from './preview-attachment'; import { Button } from './ui/button'; import { SuggestedActions } from './suggested-actions'; import { PromptInput, PromptInputTextarea, PromptInputToolbar, PromptInputTools, PromptInputSubmit, } from './elements/prompt-input'; import equal from 'fast-deep-equal'; import type { UseChatHelpers } from '@ai-sdk/react'; import { AnimatePresence, motion } from 'framer-motion'; import { ArrowDown } from 'lucide-react'; import { useScrollToBottom } from '@/hooks/use-scroll-to-bottom'; import type { VisibilityType } from './visibility-selector'; import type { Attachment, ChatMessage } from '@/lib/types'; function PureMultimodalInput({ chatId, input, setInput, status, stop, attachments, setAttachments, messages, setMessages, sendMessage, className, selectedVisibilityType, }: { chatId: string; input: string; setInput: Dispatch>; status: UseChatHelpers['status']; stop: () => void; attachments: Array; setAttachments: Dispatch>>; messages: Array; setMessages: UseChatHelpers['setMessages']; sendMessage: UseChatHelpers['sendMessage']; className?: string; selectedVisibilityType: VisibilityType; }) { const textareaRef = useRef(null); const { width } = useWindowSize(); 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 resetHeight = () => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = '98px'; } }; const [localStorageInput, setLocalStorageInput] = useLocalStorage( 'input', '', ); useEffect(() => { if (textareaRef.current) { const domValue = textareaRef.current.value; // Prefer DOM value over localStorage to handle hydration const finalValue = domValue || localStorageInput || ''; setInput(finalValue); adjustHeight(); } // Only run once after hydration // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { setLocalStorageInput(input); }, [input, setLocalStorageInput]); const handleInput = (event: React.ChangeEvent) => { setInput(event.target.value); }; const fileInputRef = useRef(null); const [uploadQueue, setUploadQueue] = useState>([]); const submitForm = useCallback(() => { window.history.replaceState({}, '', `/chat/${chatId}`); sendMessage({ role: 'user', parts: [ ...attachments.map((attachment) => ({ type: 'file' as const, url: attachment.url, name: attachment.name, mediaType: attachment.contentType, })), { type: 'text', text: input, }, ], }); setAttachments([]); setLocalStorageInput(''); resetHeight(); setInput(''); if (width && width > 768) { textareaRef.current?.focus(); } }, [ input, setInput, attachments, sendMessage, setAttachments, setLocalStorageInput, width, chatId, ]); 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 { url, pathname, contentType } = data; return { url, name: pathname, contentType: contentType, }; } const { error } = await response.json(); toast.error(error); } catch (error) { toast.error('Failed to upload file, please try again!'); } }; 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); const successfullyUploadedAttachments = uploadedAttachments.filter( (attachment) => attachment !== undefined, ); setAttachments((currentAttachments) => [ ...currentAttachments, ...successfullyUploadedAttachments, ]); } catch (error) { console.error('Error uploading files!', error); } finally { setUploadQueue([]); } }, [setAttachments], ); const { isAtBottom, scrollToBottom } = useScrollToBottom(); useEffect(() => { if (status === 'submitted') { scrollToBottom(); } }, [status, scrollToBottom]); return (
{!isAtBottom && ( )} {messages.length === 0 && attachments.length === 0 && uploadQueue.length === 0 && ( )} { event.preventDefault(); if (status !== 'ready') { toast.error('Please wait for the model to finish its response!'); } else { submitForm(); } }} > {(attachments.length > 0 || uploadQueue.length > 0) && (
{attachments.map((attachment) => ( { setAttachments((currentAttachments) => currentAttachments.filter((a) => a.url !== attachment.url), ); if (fileInputRef.current) { fileInputRef.current.value = ''; } }} /> ))} {uploadQueue.map((filename) => ( ))}
)} {status === 'submitted' ? ( ) : ( 0} className="bg-primary hover:bg-primary/90 text-primary-foreground size-9 rounded-full" /> )}
); } export const MultimodalInput = memo( PureMultimodalInput, (prevProps, nextProps) => { if (prevProps.input !== nextProps.input) return false; if (prevProps.status !== nextProps.status) return false; if (!equal(prevProps.attachments, nextProps.attachments)) return false; if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) return false; return true; }, ); function PureAttachmentsButton({ fileInputRef, status, }: { fileInputRef: React.MutableRefObject; status: UseChatHelpers['status']; }) { return ( ); } const AttachmentsButton = memo(PureAttachmentsButton); function PureStopButton({ stop, setMessages, }: { stop: () => void; setMessages: UseChatHelpers['setMessages']; }) { return ( ); } const StopButton = memo(PureStopButton); function PureSendButton({ submitForm, input, uploadQueue, }: { submitForm: () => void; input: string; uploadQueue: Array; }) { return ( ); } const SendButton = memo(PureSendButton, (prevProps, nextProps) => { if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length) return false; if (prevProps.input !== nextProps.input) return false; return true; });