|
|
'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<SetStateAction<string>>; |
|
|
status: UseChatHelpers<ChatMessage>['status']; |
|
|
stop: () => void; |
|
|
attachments: Array<Attachment>; |
|
|
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>; |
|
|
messages: Array<UIMessage>; |
|
|
setMessages: UseChatHelpers<ChatMessage>['setMessages']; |
|
|
sendMessage: UseChatHelpers<ChatMessage>['sendMessage']; |
|
|
className?: string; |
|
|
selectedVisibilityType: VisibilityType; |
|
|
}) { |
|
|
const textareaRef = useRef<HTMLTextAreaElement>(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; |
|
|
|
|
|
const finalValue = domValue || localStorageInput || ''; |
|
|
setInput(finalValue); |
|
|
adjustHeight(); |
|
|
} |
|
|
|
|
|
|
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
setLocalStorageInput(input); |
|
|
}, [input, setLocalStorageInput]); |
|
|
|
|
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => { |
|
|
setInput(event.target.value); |
|
|
}; |
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]); |
|
|
|
|
|
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<HTMLInputElement>) => { |
|
|
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 ( |
|
|
<div className="flex relative flex-col gap-4 w-full"> |
|
|
<AnimatePresence> |
|
|
{!isAtBottom && ( |
|
|
<motion.div |
|
|
initial={{ opacity: 0, y: 10 }} |
|
|
animate={{ opacity: 1, y: 0 }} |
|
|
exit={{ opacity: 0, y: 10 }} |
|
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }} |
|
|
className="absolute bottom-28 left-1/2 z-50 -translate-x-1/2" |
|
|
> |
|
|
<Button |
|
|
data-testid="scroll-to-bottom-button" |
|
|
className="rounded-full" |
|
|
size="icon" |
|
|
variant="outline" |
|
|
onClick={(event) => { |
|
|
event.preventDefault(); |
|
|
scrollToBottom(); |
|
|
}} |
|
|
> |
|
|
<ArrowDown /> |
|
|
</Button> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
{messages.length === 0 && |
|
|
attachments.length === 0 && |
|
|
uploadQueue.length === 0 && ( |
|
|
<SuggestedActions |
|
|
sendMessage={sendMessage} |
|
|
chatId={chatId} |
|
|
selectedVisibilityType={selectedVisibilityType} |
|
|
/> |
|
|
)} |
|
|
|
|
|
<input |
|
|
type="file" |
|
|
className="fixed -top-4 -left-4 size-0.5 opacity-0 pointer-events-none" |
|
|
ref={fileInputRef} |
|
|
multiple |
|
|
onChange={handleFileChange} |
|
|
tabIndex={-1} |
|
|
/> |
|
|
|
|
|
<PromptInput |
|
|
className="border border-input bg-background shadow-sm transition-all duration-200 focus-within:ring-2 focus-within:ring-ring/20 focus-within:border-ring/30 rounded-lg" |
|
|
onSubmit={(event) => { |
|
|
event.preventDefault(); |
|
|
if (status !== 'ready') { |
|
|
toast.error('Please wait for the model to finish its response!'); |
|
|
} else { |
|
|
submitForm(); |
|
|
} |
|
|
}} |
|
|
> |
|
|
{(attachments.length > 0 || uploadQueue.length > 0) && ( |
|
|
<div |
|
|
data-testid="attachments-preview" |
|
|
className="flex overflow-x-auto flex-row gap-2 p-3 pb-0" |
|
|
> |
|
|
{attachments.map((attachment) => ( |
|
|
<PreviewAttachment |
|
|
key={attachment.url} |
|
|
attachment={attachment} |
|
|
onRemove={() => { |
|
|
setAttachments((currentAttachments) => |
|
|
currentAttachments.filter((a) => a.url !== attachment.url), |
|
|
); |
|
|
if (fileInputRef.current) { |
|
|
fileInputRef.current.value = ''; |
|
|
} |
|
|
}} |
|
|
/> |
|
|
))} |
|
|
|
|
|
{uploadQueue.map((filename) => ( |
|
|
<PreviewAttachment |
|
|
key={filename} |
|
|
attachment={{ |
|
|
url: '', |
|
|
name: filename, |
|
|
contentType: '', |
|
|
}} |
|
|
isUploading={true} |
|
|
/> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<PromptInputTextarea |
|
|
data-testid="multimodal-input" |
|
|
ref={textareaRef} |
|
|
placeholder="Send a message..." |
|
|
value={input} |
|
|
onChange={handleInput} |
|
|
minHeight={48} |
|
|
maxHeight={200} |
|
|
className="text-sm resize-none py-3 px-4 flex-1 min-h-[48px] max-h-[200px]" |
|
|
rows={1} |
|
|
autoFocus |
|
|
/> |
|
|
<PromptInputToolbar className="flex items-center p-2"> |
|
|
<PromptInputTools className="flex items-center gap-1 mr-auto"> |
|
|
<AttachmentsButton fileInputRef={fileInputRef} status={status} /> |
|
|
</PromptInputTools> |
|
|
{status === 'submitted' ? ( |
|
|
<StopButton stop={stop} setMessages={setMessages} /> |
|
|
) : ( |
|
|
<PromptInputSubmit |
|
|
status={status} |
|
|
disabled={!input.trim() || uploadQueue.length > 0} |
|
|
className="bg-primary hover:bg-primary/90 text-primary-foreground size-9 rounded-full" |
|
|
/> |
|
|
)} |
|
|
</PromptInputToolbar> |
|
|
</PromptInput> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
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<HTMLInputElement | null>; |
|
|
status: UseChatHelpers<ChatMessage>['status']; |
|
|
}) { |
|
|
return ( |
|
|
<Button |
|
|
data-testid="attachments-button" |
|
|
className="size-8 p-1.5 rounded-full hover:bg-accent" |
|
|
onClick={(event) => { |
|
|
event.preventDefault(); |
|
|
fileInputRef.current?.click(); |
|
|
}} |
|
|
disabled={status !== 'ready'} |
|
|
variant="ghost" |
|
|
> |
|
|
<PaperclipIcon size={16} /> |
|
|
</Button> |
|
|
); |
|
|
} |
|
|
|
|
|
const AttachmentsButton = memo(PureAttachmentsButton); |
|
|
|
|
|
function PureStopButton({ |
|
|
stop, |
|
|
setMessages, |
|
|
}: { |
|
|
stop: () => void; |
|
|
setMessages: UseChatHelpers<ChatMessage>['setMessages']; |
|
|
}) { |
|
|
return ( |
|
|
<Button |
|
|
data-testid="stop-button" |
|
|
className="size-9 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground" |
|
|
onClick={(event) => { |
|
|
event.preventDefault(); |
|
|
stop(); |
|
|
setMessages((messages) => messages); |
|
|
}} |
|
|
> |
|
|
<StopIcon size={16} /> |
|
|
</Button> |
|
|
); |
|
|
} |
|
|
|
|
|
const StopButton = memo(PureStopButton); |
|
|
|
|
|
function PureSendButton({ |
|
|
submitForm, |
|
|
input, |
|
|
uploadQueue, |
|
|
}: { |
|
|
submitForm: () => void; |
|
|
input: string; |
|
|
uploadQueue: Array<string>; |
|
|
}) { |
|
|
return ( |
|
|
<Button |
|
|
data-testid="send-button" |
|
|
className="rounded-full p-1.5 h-fit border dark:border-zinc-600" |
|
|
onClick={(event) => { |
|
|
event.preventDefault(); |
|
|
submitForm(); |
|
|
}} |
|
|
disabled={input.length === 0 || uploadQueue.length > 0} |
|
|
> |
|
|
<ArrowUpIcon size={14} /> |
|
|
</Button> |
|
|
); |
|
|
} |
|
|
|
|
|
const SendButton = memo(PureSendButton, (prevProps, nextProps) => { |
|
|
if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length) |
|
|
return false; |
|
|
if (prevProps.input !== nextProps.input) return false; |
|
|
return true; |
|
|
}); |
|
|
|