"use client"; import React, { useCallback, useRef, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { ResetWarningModal } from "@/components/reset-warning-modal"; import { SaveDialog } from "@/components/save-dialog"; import { Loader2, Send, Trash2, Image as ImageIcon, History, Download, } from "lucide-react"; import { toast } from "sonner"; import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { FilePreviewList } from "./file-preview-list"; import { useDiagram } from "@/contexts/diagram-context"; import { HistoryDialog } from "@/components/history-dialog"; import { ErrorToast } from "@/components/error-toast"; const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB const MAX_FILES = 5; function formatFileSize(bytes: number): string { const mb = bytes / 1024 / 1024; if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`; return `${mb.toFixed(2)}MB`; } function showErrorToast(message: React.ReactNode) { toast.custom( (t) => toast.dismiss(t)} />, { duration: 5000 } ); } interface ValidationResult { validFiles: File[]; errors: string[]; } function validateFiles(newFiles: File[], existingCount: number): ValidationResult { const errors: string[] = []; const validFiles: File[] = []; const availableSlots = MAX_FILES - existingCount; if (availableSlots <= 0) { errors.push(`Maximum ${MAX_FILES} files allowed`); return { validFiles, errors }; } for (const file of newFiles) { if (validFiles.length >= availableSlots) { errors.push(`Only ${availableSlots} more file(s) allowed`); break; } if (file.size > MAX_FILE_SIZE) { errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`); } else { validFiles.push(file); } } return { validFiles, errors }; } function showValidationErrors(errors: string[]) { if (errors.length === 0) return; if (errors.length === 1) { showErrorToast({errors[0]}); } else { showErrorToast(
{errors.length} files rejected:
); } } interface ChatInputProps { input: string; status: "submitted" | "streaming" | "ready" | "error"; onSubmit: (e: React.FormEvent) => void; onChange: (e: React.ChangeEvent) => void; onClearChat: () => void; files?: File[]; onFileChange?: (files: File[]) => void; showHistory?: boolean; onToggleHistory?: (show: boolean) => void; sessionId?: string; error?: Error | null; } export function ChatInput({ input, status, onSubmit, onChange, onClearChat, files = [], onFileChange = () => {}, showHistory = false, onToggleHistory = () => {}, sessionId, error = null, }: ChatInputProps) { const { diagramHistory, saveDiagramToFile } = useDiagram(); const textareaRef = useRef(null); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false); // Allow retry when there's an error (even if status is still "streaming" or "submitted") const isDisabled = (status === "streaming" || status === "submitted") && !error; const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, []); useEffect(() => { adjustTextareaHeight(); }, [input, adjustTextareaHeight]); const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); const form = e.currentTarget.closest("form"); if (form && input.trim() && !isDisabled) { form.requestSubmit(); } } }; const handlePaste = async (e: React.ClipboardEvent) => { if (isDisabled) return; const items = e.clipboardData.items; const imageItems = Array.from(items).filter((item) => item.type.startsWith("image/") ); if (imageItems.length > 0) { const imageFiles = (await Promise.all( imageItems.map(async (item, index) => { const file = item.getAsFile(); if (!file) return null; return new File( [file], `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, { type: file.type } ); }) )).filter((f): f is File => f !== null); const { validFiles, errors } = validateFiles(imageFiles, files.length); showValidationErrors(errors); if (validFiles.length > 0) { onFileChange([...files, ...validFiles]); } } }; const handleFileChange = (e: React.ChangeEvent) => { const newFiles = Array.from(e.target.files || []); const { validFiles, errors } = validateFiles(newFiles, files.length); showValidationErrors(errors); if (validFiles.length > 0) { onFileChange([...files, ...validFiles]); } // Reset input so same file can be selected again if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const handleRemoveFile = (fileToRemove: File) => { onFileChange(files.filter((file) => file !== fileToRemove)); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const triggerFileInput = () => { fileInputRef.current?.click(); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (isDisabled) return; const droppedFiles = e.dataTransfer.files; const imageFiles = Array.from(droppedFiles).filter((file) => file.type.startsWith("image/") ); const { validFiles, errors } = validateFiles(imageFiles, files.length); showValidationErrors(errors); if (validFiles.length > 0) { onFileChange([...files, ...validFiles]); } }; const handleClear = () => { onClearChat(); setShowClearDialog(false); }; return (
{/* File previews */} {files.length > 0 && (
)} {/* Input container */}