"use client"; import type { UseChatHelpers } from "@ai-sdk/react"; import type { UIMessage } from "ai"; import equal from "fast-deep-equal"; import { ArrowUpIcon, BrainIcon, EyeIcon, LockIcon, WrenchIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { type ChangeEvent, type Dispatch, memo, type SetStateAction, useCallback, useEffect, useRef, useState, } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { ModelSelector, ModelSelectorContent, ModelSelectorGroup, ModelSelectorInput, ModelSelectorItem, ModelSelectorList, ModelSelectorLogo, ModelSelectorName, ModelSelectorTrigger, } from "@/components/ai-elements/model-selector"; import { type ChatModel, chatModels, DEFAULT_CHAT_MODEL, type ModelCapabilities, } from "@/lib/ai/models"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn } from "@/lib/utils"; import { PromptInput, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools, } from "../ai-elements/prompt-input"; import { Button } from "../ui/button"; import { PaperclipIcon, StopIcon } from "./icons"; import { PreviewAttachment } from "./preview-attachment"; import { type SlashCommand, SlashCommandMenu, slashCommands, } from "./slash-commands"; import { SuggestedActions } from "./suggested-actions"; import type { VisibilityType } from "./visibility-selector"; function setCookie(name: string, value: string) { const maxAge = 60 * 60 * 24 * 365; // 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, editingMessage, onCancelEdit, isLoading, }: { chatId: string; input: string; setInput: Dispatch>; status: UseChatHelpers["status"]; stop: () => void; attachments: Attachment[]; setAttachments: Dispatch>; messages: UIMessage[]; setMessages: UseChatHelpers["setMessages"]; sendMessage: | UseChatHelpers["sendMessage"] | (() => Promise); className?: string; selectedVisibilityType: VisibilityType; selectedModelId: string; onModelChange?: (modelId: string) => void; editingMessage?: ChatMessage | null; onCancelEdit?: () => void; isLoading?: boolean; }) { const router = useRouter(); const { setTheme, resolvedTheme } = useTheme(); const textareaRef = useRef(null); const { width } = useWindowSize(); const hasAutoFocused = useRef(false); useEffect(() => { if (!hasAutoFocused.current && width) { const timer = setTimeout(() => { textareaRef.current?.focus(); hasAutoFocused.current = true; }, 100); return () => clearTimeout(timer); } }, [width]); const [localStorageInput, setLocalStorageInput] = useLocalStorage( "input", "" ); useEffect(() => { if (textareaRef.current) { const domValue = textareaRef.current.value; const finalValue = domValue || localStorageInput || ""; setInput(finalValue); } }, [localStorageInput, setInput]); useEffect(() => { setLocalStorageInput(input); }, [input, setLocalStorageInput]); const handleInput = (event: React.ChangeEvent) => { const val = event.target.value; setInput(val); if (val.startsWith("/") && !val.includes(" ")) { setSlashOpen(true); setSlashQuery(val.slice(1)); setSlashIndex(0); } else { setSlashOpen(false); } }; const handleSlashSelect = (cmd: SlashCommand) => { setSlashOpen(false); setInput(""); switch (cmd.action) { case "new": router.push("/"); break; case "clear": setMessages(() => []); break; case "rename": toast("Rename is available from the sidebar chat menu."); break; case "model": { const modelBtn = document.querySelector( "[data-testid='model-selector']" ); modelBtn?.click(); break; } case "theme": setTheme(resolvedTheme === "dark" ? "light" : "dark"); break; case "delete": toast("Delete this chat?", { action: { label: "Delete", onClick: () => { fetch( `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`, { method: "DELETE" } ); router.push("/"); toast.success("Chat deleted"); }, }, }); break; case "purge": toast("Delete all chats?", { action: { label: "Delete all", onClick: () => { fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { method: "DELETE", }); router.push("/"); toast.success("All chats deleted"); }, }, }); break; default: break; } }; const fileInputRef = useRef(null); const [uploadQueue, setUploadQueue] = useState([]); const [slashOpen, setSlashOpen] = useState(false); const [slashQuery, setSlashQuery] = useState(""); const [slashIndex, setSlashIndex] = useState(0); const submitForm = useCallback(() => { window.history.pushState( {}, "", `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/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(""); setInput(""); if (width && width > 768) { textareaRef.current?.focus(); } }, [ input, setInput, attachments, sendMessage, setAttachments, setLocalStorageInput, width, chatId, ]); const uploadFile = useCallback(async (file: File) => { const formData = new FormData(); formData.append("file", file); try { const response = await fetch( `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/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) { toast.error("Failed to upload files"); } 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; } 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) { toast.error("Failed to upload pasted image(s)"); } finally { setUploadQueue([]); } }, [setAttachments, uploadFile] ); useEffect(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.addEventListener("paste", handlePaste); return () => textarea.removeEventListener("paste", handlePaste); }, [handlePaste]); return (
{editingMessage && onCancelEdit && (
Editing message
)} {!editingMessage && !isLoading && messages.length === 0 && attachments.length === 0 && uploadQueue.length === 0 && ( )}
{slashOpen && ( setSlashOpen(false)} onSelect={handleSlashSelect} query={slashQuery} selectedIndex={slashIndex} /> )}
{ if (input.startsWith("/")) { const query = input.slice(1).trim(); const cmd = slashCommands.find((c) => c.name === query); if (cmd) { handleSlashSelect(cmd); } return; } if (!input.trim() && attachments.length === 0) { return; } if (status === "ready" || status === "error") { submitForm(); } else { toast.error("Please wait for the model to finish its response!"); } }} > {(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) => ( ))}
)} { if (slashOpen) { const filtered = slashCommands.filter((cmd) => cmd.name.startsWith(slashQuery.toLowerCase()) ); if (e.key === "ArrowDown") { e.preventDefault(); setSlashIndex((i) => Math.min(i + 1, filtered.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSlashIndex((i) => Math.max(i - 1, 0)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); if (filtered[slashIndex]) { handleSlashSelect(filtered[slashIndex]); } return; } if (e.key === "Escape") { e.preventDefault(); setSlashOpen(false); return; } } if (e.key === "Escape" && editingMessage && onCancelEdit) { e.preventDefault(); onCancelEdit(); } }} placeholder={ editingMessage ? "Edit your message..." : "Ask anything..." } ref={textareaRef} value={input} /> {status === "submitted" ? ( ) : ( 0} status={status} variant="secondary" > )}
); } 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; } if (prevProps.editingMessage !== nextProps.editingMessage) { return false; } if (prevProps.isLoading !== nextProps.isLoading) { return false; } if (prevProps.messages.length !== nextProps.messages.length) { return false; } return true; } ); function PureAttachmentsButton({ fileInputRef, status, selectedModelId, }: { fileInputRef: React.MutableRefObject; status: UseChatHelpers["status"]; selectedModelId: string; }) { const { data: modelsResponse } = useSWR( `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, (url: string) => fetch(url).then((r) => r.json()), { revalidateOnFocus: false, dedupingInterval: 3_600_000 } ); const caps: Record | undefined = modelsResponse?.capabilities ?? modelsResponse; const hasVision = caps?.[selectedModelId]?.vision ?? false; return ( ); } const AttachmentsButton = memo(PureAttachmentsButton); function PureModelSelectorCompact({ selectedModelId, onModelChange, }: { selectedModelId: string; onModelChange?: (modelId: string) => void; }) { const [open, setOpen] = useState(false); const { data: modelsData } = useSWR( `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, (url: string) => fetch(url).then((r) => r.json()), { revalidateOnFocus: false, dedupingInterval: 3_600_000 } ); const capabilities: Record | undefined = modelsData?.capabilities ?? modelsData; const dynamicModels: ChatModel[] | undefined = modelsData?.models; const activeModels = dynamicModels ?? chatModels; const selectedModel = activeModels.find((m: ChatModel) => m.id === selectedModelId) ?? activeModels.find((m: ChatModel) => m.id === DEFAULT_CHAT_MODEL) ?? activeModels[0]; const [provider] = selectedModel.id.split("/"); return ( {(() => { const curatedIds = new Set(chatModels.map((m) => m.id)); const allModels = dynamicModels ? [ ...chatModels, ...dynamicModels.filter((m) => !curatedIds.has(m.id)), ] : chatModels; const grouped: Record< string, { model: ChatModel; curated: boolean }[] > = {}; for (const model of allModels) { const key = curatedIds.has(model.id) ? "_available" : model.provider; if (!grouped[key]) { grouped[key] = []; } grouped[key].push({ model, curated: curatedIds.has(model.id) }); } const sortedKeys = Object.keys(grouped).sort((a, b) => { if (a === "_available") { return -1; } if (b === "_available") { return 1; } return a.localeCompare(b); }); const providerNames: Record = { alibaba: "Alibaba", anthropic: "Anthropic", "arcee-ai": "Arcee AI", bytedance: "ByteDance", cohere: "Cohere", deepseek: "DeepSeek", google: "Google", inception: "Inception", kwaipilot: "Kwaipilot", meituan: "Meituan", meta: "Meta", minimax: "MiniMax", mistral: "Mistral", moonshotai: "Moonshot", morph: "Morph", nvidia: "Nvidia", openai: "OpenAI", perplexity: "Perplexity", "prime-intellect": "Prime Intellect", xiaomi: "Xiaomi", xai: "xAI", zai: "Zai", }; return sortedKeys.map((key) => ( {grouped[key].map(({ model, curated }) => { const logoProvider = model.id.split("/")[0]; return ( { if (!curated) { return; } onModelChange?.(model.id); setCookie("chat-model", model.id); setOpen(false); setTimeout(() => { document .querySelector( "[data-testid='multimodal-input']" ) ?.focus(); }, 50); }} value={model.id} > {model.name}
{capabilities?.[model.id]?.tools && ( )} {capabilities?.[model.id]?.vision && ( )} {capabilities?.[model.id]?.reasoning && ( )} {!curated && ( )}
); })}
)); })()}
); } const ModelSelectorCompact = memo(PureModelSelectorCompact); function PureStopButton({ stop, setMessages, }: { stop: () => void; setMessages: UseChatHelpers["setMessages"]; }) { return ( ); } const StopButton = memo(PureStopButton);