"use client"; import { AudioWaveformIcon, ChevronDown, CornerRightUp, FileIcon, FileTextIcon, ImagesIcon, Loader2, PaperclipIcon, PlusIcon, Square, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "ui/button"; import { UIMessage, UseChatHelpers } from "@ai-sdk/react"; import { SelectModel } from "./select-model"; import { appStore, UploadedFile } from "@/app/store"; import { useShallow } from "zustand/shallow"; import { ChatMention, ChatModel } from "app-types/chat"; import dynamic from "next/dynamic"; import { ToolModeDropdown } from "./tool-mode-dropdown"; import { ToolSelectDropdown } from "./tool-select-dropdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; import { useTranslations } from "next-intl"; import { Editor } from "@tiptap/react"; import { WorkflowSummary } from "app-types/workflow"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import equal from "lib/equal"; import { MCPIcon } from "ui/mcp-icon"; import { DefaultToolName } from "lib/ai/tools"; import { DefaultToolIcon } from "./default-tool-icon"; import { OpenAIIcon } from "ui/openai-icon"; import { GrokIcon } from "ui/grok-icon"; import { ClaudeIcon } from "ui/claude-icon"; import { GeminiIcon } from "ui/gemini-icon"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useThreadFileUploader } from "@/hooks/use-thread-file-uploader"; import { EMOJI_DATA } from "lib/const"; import { AgentSummary } from "app-types/agent"; import { FileUIPart, TextUIPart } from "ai"; import { toast } from "sonner"; import { isFilePartSupported, isIngestSupported } from "@/lib/ai/file-support"; import { useChatModels } from "@/hooks/queries/use-chat-models"; interface PromptInputProps { placeholder?: string; setInput: (value: string) => void; input: string; onStop: () => void; sendMessage: UseChatHelpers["sendMessage"]; toolDisabled?: boolean; isLoading?: boolean; model?: ChatModel; setModel?: (model: ChatModel) => void; voiceDisabled?: boolean; threadId?: string; disabledMention?: boolean; onFocus?: () => void; } const ChatMentionInput = dynamic(() => import("./chat-mention-input"), { ssr: false, loading() { return
; }, }); export default function PromptInput({ placeholder, sendMessage, model, setModel, input, onFocus, setInput, onStop, isLoading, toolDisabled, voiceDisabled, threadId, disabledMention, }: PromptInputProps) { const t = useTranslations("Chat"); const [isUploadDropdownOpen, setIsUploadDropdownOpen] = useState(false); const fileInputRef = useRef(null); const { uploadFiles } = useThreadFileUploader(threadId); const { data: providers } = useChatModels(); const [ globalModel, threadMentions, threadFiles, threadImageToolModel, appStoreMutate, ] = appStore( useShallow((state) => [ state.chatModel, state.threadMentions, state.threadFiles, state.threadImageToolModel, state.mutate, ]), ); const modelInfo = useMemo(() => { const provider = providers?.find( (provider) => provider.provider === globalModel?.provider, ); const model = provider?.models.find( (model) => model.name === globalModel?.model, ); return model; }, [providers, globalModel]); const supportedFileMimeTypes = modelInfo?.supportedFileMimeTypes; const canUploadImages = supportedFileMimeTypes?.some((mime) => mime.startsWith("image/")) ?? true; const mentions = useMemo(() => { if (!threadId) return []; return threadMentions[threadId!] ?? []; }, [threadMentions, threadId]); const uploadedFiles = useMemo(() => { if (!threadId) return []; return threadFiles[threadId] ?? []; }, [threadFiles, threadId]); const imageToolModel = useMemo(() => { if (!threadId) return undefined; return threadImageToolModel[threadId]; }, [threadImageToolModel, threadId]); const chatModel = useMemo(() => { return model ?? globalModel; }, [model, globalModel]); const editorRef = useRef(null); const setChatModel = useCallback( (model: ChatModel) => { if (setModel) { setModel(model); } else { appStoreMutate({ chatModel: model }); } }, [setModel, appStoreMutate], ); const deleteMention = useCallback( (mention: ChatMention) => { if (!threadId) return; appStoreMutate((prev) => { const newMentions = mentions.filter((m) => !equal(m, mention)); return { threadMentions: { ...prev.threadMentions, [threadId!]: newMentions, }, }; }); }, [mentions, threadId], ); const deleteFile = useCallback( (fileId: string) => { if (!threadId) return; // Find file and abort if uploading const file = uploadedFiles.find((f) => f.id === fileId); if (file?.isUploading && file.abortController) { file.abortController.abort(); } // Cleanup preview URL if exists if (file?.previewUrl) { URL.revokeObjectURL(file.previewUrl); } appStoreMutate((prev) => { const newFiles = uploadedFiles.filter((f) => f.id !== fileId); return { threadFiles: { ...prev.threadFiles, [threadId]: newFiles, }, }; }); }, [uploadedFiles, threadId, appStoreMutate], ); // uploadFiles handled by hook const handleFileSelect = useCallback( async (e: React.ChangeEvent) => { const list = e.target.files; if (!list) return; await uploadFiles(Array.from(list)); // Reset input if (fileInputRef.current) fileInputRef.current.value = ""; setIsUploadDropdownOpen(false); }, [uploadFiles], ); const handleGenerateImage = useCallback( (provider?: "google" | "openai") => { if (!provider) { appStoreMutate({ threadImageToolModel: {}, }); } if (!threadId) return; setIsUploadDropdownOpen(false); appStoreMutate((prev) => ({ threadImageToolModel: { ...prev.threadImageToolModel, [threadId]: provider, }, })); // Focus on the input editorRef.current?.commands.focus(); }, [threadId, editorRef], ); const addMention = useCallback( (mention: ChatMention) => { if (!threadId) return; appStoreMutate((prev) => { if (mentions.some((m) => equal(m, mention))) return prev; const newMentions = mention.type == "agent" ? [...mentions.filter((m) => m.type !== "agent"), mention] : [...mentions, mention]; return { threadMentions: { ...prev.threadMentions, [threadId!]: newMentions, }, }; }); }, [mentions, threadId], ); const onSelectWorkflow = useCallback( (workflow: WorkflowSummary) => { addMention({ type: "workflow", name: workflow.name, icon: workflow.icon, workflowId: workflow.id, description: workflow.description, }); }, [addMention], ); const onSelectAgent = useCallback( (agent: AgentSummary) => { appStoreMutate((prev) => { return { threadMentions: { ...prev.threadMentions, [threadId!]: [ { type: "agent", name: agent.name, icon: agent.icon, description: agent.description, agentId: agent.id, }, ], }, }; }); }, [mentions, threadId], ); const onChangeMention = useCallback( (mentions: ChatMention[]) => { let hasAgent = false; [...mentions] .reverse() .filter((m) => { if (m.type == "agent") { if (hasAgent) return false; hasAgent = true; } return true; }) .reverse() .forEach(addMention); }, [addMention], ); const submit = () => { if (isLoading) return; if (uploadedFiles.some((file) => file.isUploading)) { toast.error("Please wait for files to finish uploading before sending."); return; } const userMessage = input?.trim() || ""; if (userMessage.length === 0) return; setInput(""); const attachmentParts = uploadedFiles.reduce< Array >((acc, file) => { const isFileSupported = isFilePartSupported( file.mimeType, supportedFileMimeTypes, ); const link = file.url || file.dataUrl || ""; if (!link) return acc; if (isFileSupported) { acc.push({ type: "file", url: link, mediaType: file.mimeType, filename: file.name, } as FileUIPart); } else { // Use a rich UI part for unsupported file types; will be filtered out for model input acc.push({ type: "source-url", url: link, title: file.name, mediaType: file.mimeType, } as any); } return acc; }, []); if (attachmentParts.length) { const summary = uploadedFiles .map((file, index) => { const type = file.mimeType || "unknown"; return `${index + 1}. ${file.name} (${type})`; }) .join("\n"); attachmentParts.unshift({ type: "text", text: `Attached files:\n${summary}`, ingestionPreview: true, }); } sendMessage({ role: "user", parts: [...attachmentParts, { type: "text", text: userMessage }], }); appStoreMutate((prev) => ({ threadFiles: { ...prev.threadFiles, [threadId!]: [], }, })); }; // Handle ESC key to clear mentions useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( e.key === "Escape" && threadId && (mentions.length > 0 || imageToolModel) ) { e.preventDefault(); e.stopPropagation(); appStoreMutate(() => ({ threadMentions: {}, agentId: undefined, threadImageToolModel: {}, })); editorRef.current?.commands.focus(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [mentions.length, threadId, appStoreMutate, imageToolModel]); // Drag overlay handled globally in ChatBot return (
{mentions.length > 0 && (
{mentions.map((mention, i) => { return (
{mention.type === "workflow" || mention.type === "agent" ? ( {mention.name.slice(0, 1)} ) : ( )}
{mention.name} {mention.description ? ( {mention.description} ) : null}
); })}
)}
fileInputRef.current?.click()} > {t("uploadImage")} {t("generateImage")} handleGenerateImage("google")} className="cursor-pointer" > Gemini (Nano Banana) handleGenerateImage("openai")} className="cursor-pointer" > OpenAI {!toolDisabled && (imageToolModel ? ( ) : ( <> ))}
{!isLoading && !input.length && !voiceDisabled ? ( {t("VoiceChat.title")} ) : (
{ if (isLoading) { onStop(); } else { submit(); } }} className="fade-in animate-in cursor-pointer text-muted-foreground rounded-full p-2 bg-secondary hover:bg-accent-foreground hover:text-accent transition-all duration-200" > {isLoading ? ( ) : ( )}
)}
{/* Uploaded Files Preview - Below Input */} {uploadedFiles.length > 0 && (
{uploadedFiles.map((file) => { const isImage = file.mimeType.startsWith("image/"); const imageSrc = file.previewUrl || file.url || file.dataUrl || ""; const displayName = file.name; const displayExt = file.name.split(".").pop()?.toUpperCase() || "FILE"; const isSummarizable = isIngestSupported(file.mimeType); return (
{isImage ? ( /* eslint-disable-next-line @next/next/no-img-element */ {file.name} ) : (
{displayName} {displayExt}
)} {/* Upload Progress Overlay */} {file.isUploading && (
{file.progress || 0}%
)} {/* Hover Actions */}
{isSummarizable && ( Summarize )}
{/* Cancel Upload Button (Top Right) */} {file.isUploading && ( )}
); })}
)}
); }