"use client"; import type { UseChatHelpers } from "@ai-sdk/react"; import type { UIMessage } from "ai"; import equal from "fast-deep-equal"; import { CheckIcon } from "lucide-react"; import { type ChangeEvent, type Dispatch, memo, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { ModelSelector, ModelSelectorContent, ModelSelectorGroup, ModelSelectorInput, ModelSelectorItem, ModelSelectorList, ModelSelectorLogo, ModelSelectorName, ModelSelectorTrigger, } from "@/components/ai-elements/model-selector"; import { chatModels, DEFAULT_CHAT_MODEL, modelsByProvider, } from "@/lib/ai/models"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn } from "@/lib/utils"; import { PromptInput, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar, PromptInputTools, } from "./elements/prompt-input"; import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; import { PreviewAttachment } from "./preview-attachment"; import { SuggestedActions } from "./suggested-actions"; import { Button } from "./ui/button"; import type { VisibilityType } from "./visibility-selector"; function setCookie(name: string, value: string) { const maxAge = 60 * 60 * 24 * 365; // 1 year // biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`; } function PureMultimodalInput({ chatId, input, setInput, status, stop, attachments, setAttachments, messages, setMessages, sendMessage, className, selectedVisibilityType, selectedModelId, onModelChange, }: { chatId: string; input: string; setInput: Dispatch>; status: UseChatHelpers["status"]; stop: () => void; attachments: Attachment[]; setAttachments: Dispatch>; messages: UIMessage[]; setMessages: UseChatHelpers["setMessages"]; sendMessage: UseChatHelpers["sendMessage"]; className?: string; selectedVisibilityType: VisibilityType; selectedModelId: string; onModelChange?: (modelId: string) => void; }) { const textareaRef = useRef(null); const { width } = useWindowSize(); const adjustHeight = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = "44px"; } }, []); useEffect(() => { if (textareaRef.current) { adjustHeight(); } }, [adjustHeight]); const resetHeight = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = "44px"; } }, []); 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 }, [adjustHeight, localStorageInput, setInput]); 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.pushState({}, "", `/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, resetHeight, ]); const uploadFile = useCallback(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, }; } 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, uploadFile] ); const handlePaste = useCallback( async (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) { return; } const imageItems = Array.from(items).filter((item) => item.type.startsWith("image/") ); if (imageItems.length === 0) { return; } // Prevent default paste behavior for images event.preventDefault(); setUploadQueue((prev) => [...prev, "Pasted image"]); try { const uploadPromises = imageItems .map((item) => item.getAsFile()) .filter((file): file is File => file !== null) .map((file) => uploadFile(file)); const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( (attachment) => attachment !== undefined && attachment.url !== undefined && attachment.contentType !== undefined ); setAttachments((curr) => [ ...curr, ...(successfullyUploadedAttachments as Attachment[]), ]); } catch (error) { console.error("Error uploading pasted images:", error); toast.error("Failed to upload pasted image(s)"); } finally { setUploadQueue([]); } }, [setAttachments, uploadFile] ); // Add paste event listener to textarea useEffect(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.addEventListener("paste", handlePaste); return () => textarea.removeEventListener("paste", handlePaste); }, [handlePaste]); return (
{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} status={status} > )}
); } 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; } if (prevProps.selectedModelId !== nextProps.selectedModelId) { return false; } return true; } ); function PureAttachmentsButton({ fileInputRef, status, selectedModelId, }: { fileInputRef: React.MutableRefObject; status: UseChatHelpers["status"]; selectedModelId: string; }) { const isReasoningModel = selectedModelId.includes("reasoning") || selectedModelId.includes("think"); return ( ); } const AttachmentsButton = memo(PureAttachmentsButton); function PureModelSelectorCompact({ selectedModelId, onModelChange, }: { selectedModelId: string; onModelChange?: (modelId: string) => void; }) { const [open, setOpen] = useState(false); const selectedModel = chatModels.find((m) => m.id === selectedModelId) ?? chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ?? chatModels[0]; const [provider] = selectedModel.id.split("/"); // Provider display names const providerNames: Record = { anthropic: "Anthropic", openai: "OpenAI", google: "Google", xai: "xAI", reasoning: "Reasoning", }; return ( {Object.entries(modelsByProvider).map( ([providerKey, providerModels]) => ( {providerModels.map((model) => { const logoProvider = model.id.split("/")[0]; return ( { onModelChange?.(model.id); setCookie("chat-model", model.id); setOpen(false); }} value={model.id} > {model.name} {model.id === selectedModel.id && ( )} ); })} ) )} ); } const ModelSelectorCompact = memo(PureModelSelectorCompact); function PureStopButton({ stop, setMessages, }: { stop: () => void; setMessages: UseChatHelpers["setMessages"]; }) { return ( ); } const StopButton = memo(PureStopButton);