Spaces:
Paused
Paused
| 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<UploadedAttachment | null>; | |
| 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<PendingAttachment[]>([]); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => { | |
| 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<HTMLTextAreaElement>) => { | |
| 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 ( | |
| <div className="border-t border-border bg-background/80 backdrop-blur-sm"> | |
| <div className="max-w-3xl mx-auto px-4 py-3"> | |
| {/* Status indicators */} | |
| <div className="flex items-center gap-3 mb-2 px-1"> | |
| {!isOnline && ( | |
| <span className="flex items-center gap-1.5 text-xs text-destructive/80"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-destructive" /> | |
| Offline | |
| </span> | |
| )} | |
| {!hasEndpoint && ( | |
| <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" /> | |
| No endpoint configured | |
| </span> | |
| )} | |
| {isStreaming && ( | |
| <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-foreground/40 animate-pulse" /> | |
| Generating... | |
| </span> | |
| )} | |
| </div> | |
| {/* Attachment previews */} | |
| <AnimatePresence initial={false}> | |
| {attachments.length > 0 && ( | |
| <motion.div | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: "auto" }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="flex flex-wrap gap-2 mb-2 px-1 overflow-hidden" | |
| > | |
| {attachments.map((att) => ( | |
| <div | |
| key={att.id} | |
| className={`flex items-center gap-1.5 rounded-lg border px-2 py-1.5 text-xs max-w-[220px] ${ | |
| att.status === "error" | |
| ? "border-destructive/30 bg-destructive/5 text-destructive" | |
| : "border-border bg-secondary/50 text-foreground" | |
| }`} | |
| title={att.status === "error" ? att.error : att.file.name} | |
| > | |
| {att.status === "uploading" ? ( | |
| <Loader2 className="w-3.5 h-3.5 flex-shrink-0 animate-spin text-muted-foreground" /> | |
| ) : att.file.type.startsWith("image/") ? ( | |
| <ImageIcon className="w-3.5 h-3.5 flex-shrink-0" /> | |
| ) : ( | |
| <FileText className="w-3.5 h-3.5 flex-shrink-0" /> | |
| )} | |
| <span className="truncate">{att.file.name}</span> | |
| <button | |
| onClick={() => removeAttachment(att.id)} | |
| className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Remove" | |
| > | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ))} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Input area */} | |
| <div className="relative flex items-end gap-2"> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileInputChange} | |
| disabled={isDisabled} | |
| /> | |
| <motion.button | |
| whileTap={{ scale: 0.95 }} | |
| onClick={() => 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)" | |
| > | |
| <Paperclip className="w-4 h-4" /> | |
| </motion.button> | |
| <div className="flex-1 relative"> | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| isDisabled | |
| ? !hasEndpoint | |
| ? "Configure API endpoint in Settings" | |
| : "You are offline" | |
| : placeholder | |
| } | |
| disabled={isDisabled} | |
| rows={1} | |
| className="w-full resize-none rounded-lg border border-border bg-secondary/50 px-4 py-3 pr-12 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-foreground/20 focus:border-foreground/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all" | |
| /> | |
| </div> | |
| <motion.button | |
| whileTap={{ scale: 0.95 }} | |
| onClick={isStreaming ? onStop : handleSend} | |
| disabled={isDisabled && !isStreaming} | |
| className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center transition-all ${ | |
| isStreaming | |
| ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" | |
| : canSend | |
| ? "bg-foreground text-background hover:bg-foreground/90" | |
| : "bg-secondary text-muted-foreground cursor-not-allowed" | |
| }`} | |
| title={isStreaming ? "Stop generating" : "Send message"} | |
| > | |
| {isStreaming ? ( | |
| <Square className="w-4 h-4 fill-current" /> | |
| ) : ( | |
| <Send className="w-4 h-4" /> | |
| )} | |
| </motion.button> | |
| </div> | |
| {/* Hint */} | |
| {!isStreaming && hasEndpoint && ( | |
| <p className="mt-1.5 text-[10px] text-muted-foreground/40 text-center"> | |
| Press Enter to send · Shift+Enter for new line · Attach files up to 50MB | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |