import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type ChangeEvent } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Send, Square, Paperclip, X, FileText, Image as ImageIcon, Loader2 } from "lucide-react"; import type { UploadedAttachment } from "@/hooks/use-agentscope"; const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB, matches the backend limit interface PendingAttachment { id: string; file: File; status: "uploading" | "done" | "error"; result?: UploadedAttachment; error?: string; } interface ChatInputProps { onSend: (text: string, attachments?: UploadedAttachment[]) => void; onUpload: (file: File) => Promise; onStop: () => void; isStreaming: boolean; isOnline: boolean; hasEndpoint: boolean; placeholder?: string; } export function ChatInput({ onSend, onUpload, onStop, isStreaming, isOnline, hasEndpoint, placeholder = "Type a message...", }: ChatInputProps) { const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const textareaRef = useRef(null); const fileInputRef = useRef(null); const adjustHeight = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; }, []); useEffect(() => { adjustHeight(); }, [input, adjustHeight]); const isUploading = attachments.some((a) => a.status === "uploading"); const handleFilesSelected = useCallback( (files: FileList | null) => { if (!files || files.length === 0) return; Array.from(files).forEach((file) => { if (file.size > MAX_UPLOAD_BYTES) { const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; setAttachments((prev) => [ ...prev, { id, file, status: "error", error: "File is over the 50MB limit" }, ]); return; } const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; setAttachments((prev) => [...prev, { id, file, status: "uploading" }]); onUpload(file).then((result) => { setAttachments((prev) => prev.map((a) => a.id === id ? result ? { ...a, status: "done", result } : { ...a, status: "error", error: "Upload failed" } : a, ), ); }); }); }, [onUpload], ); const handleFileInputChange = useCallback( (e: ChangeEvent) => { handleFilesSelected(e.target.files); e.target.value = ""; // allow re-selecting the same file later }, [handleFilesSelected], ); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); const handleSend = useCallback(() => { const readyAttachments = attachments .filter((a): a is PendingAttachment & { status: "done"; result: UploadedAttachment } => a.status === "done" && !!a.result, ) .map((a) => a.result); if ((!input.trim() && readyAttachments.length === 0) || isStreaming || isUploading) return; onSend(input.trim(), readyAttachments); setInput(""); setAttachments([]); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } }, [input, attachments, isStreaming, isUploading, onSend]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); const isDisabled = !hasEndpoint || !isOnline; const canSend = (input.trim().length > 0 || attachments.some((a) => a.status === "done")) && !isUploading; return (
{/* Status indicators */}
{!isOnline && ( Offline )} {!hasEndpoint && ( No endpoint configured )} {isStreaming && ( Generating... )}
{/* Attachment previews */} {attachments.length > 0 && ( {attachments.map((att) => (
{att.status === "uploading" ? ( ) : att.file.type.startsWith("image/") ? ( ) : ( )} {att.file.name}
))}
)}
{/* Input area */}
fileInputRef.current?.click()} disabled={isDisabled} className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary transition-all disabled:opacity-40 disabled:cursor-not-allowed" title="Attach a file (up to 50MB)" >