import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, Paperclip, X, FileText } from 'lucide-react'; interface FileAttachment { id: string; file: File; base64: string; // raw base64 (no data: prefix) mimeType: string; preview?: string; // data url thumbnail for images } interface Props { onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void; onAbort: () => void; isGenerating: boolean; disabled: boolean; } const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat) const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; const base64 = dataUrl.split(',')[1] || ''; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(file); }); } function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); let { width, height } = img; // Downscale if needed if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) { const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, width, height); // Try JPEG at decreasing quality until base64 length is under limit for (let q = 0.85; q >= 0.2; q -= 0.05) { const dataUrl = canvas.toDataURL('image/jpeg', q); const b64 = dataUrl.split(',')[1] || ''; if (b64.length <= maxBase64Chars) { return resolve({ base64: b64, mimeType: 'image/jpeg' }); } } // Last resort: further downscale const scale = 0.5; canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL('image/jpeg', 0.3); resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' }); }; img.onerror = reject; img.src = url; }); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) { const [text, setText] = useState(''); const [files, setFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'; } }, [text]); const addFiles = useCallback(async (fileList: FileList | File[]) => { const newFiles: FileAttachment[] = []; for (const file of Array.from(fileList)) { if (file.size > 20 * 1024 * 1024) continue; // 20MB max const isImage = file.type.startsWith('image/'); let base64: string; let mimeType: string; if (isImage) { // Compress images to fit WS payload limit const compressed = await compressImage(file, MAX_BASE64_CHARS); base64 = compressed.base64; mimeType = compressed.mimeType; } else { base64 = await fileToBase64(file); mimeType = file.type || 'application/octet-stream'; } newFiles.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file, base64, mimeType, preview: isImage ? `data:${mimeType};base64,${base64}` : undefined, }); } setFiles(prev => [...prev, ...newFiles]); }, []); const removeFile = useCallback((id: string) => { setFiles(prev => prev.filter(f => f.id !== id)); }, []); const handleSubmit = () => { const trimmed = text.trim(); if ((!trimmed && files.length === 0) || disabled) return; const attachments = files.length > 0 ? files.map(f => ({ mimeType: f.mimeType, fileName: f.file.name, content: f.base64, })) : undefined; onSend(trimmed || ' ', attachments); setText(''); setFiles([]); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const pastedFiles: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) pastedFiles.push(file); } } if (pastedFiles.length > 0) { e.preventDefault(); addFiles(pastedFiles); } }, [addFiles]); // Drag & drop handlers on the wrapper const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files.length > 0) { addFiles(e.dataTransfer.files); } }, [addFiles]); return (
{/* File previews */} {files.length > 0 && (
{files.map(f => (
{f.preview ? ( ) : ( )}
{f.file.name}
{formatSize(f.file.size)}
))}
)}
{/* File picker button */} { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml" />