import { useState, useRef, useEffect } from 'react'; import { FiSend, FiPaperclip, FiMic, FiX, FiCheck } from 'react-icons/fi'; import { FileUpload } from './file-upload'; import { FilePreview, FileMetadata } from './file-preview'; import { AudioWaveform } from './audio-waveform'; import { useAudioVisualizer } from '../../hooks/use-audio-visualizer'; import { Spinner } from '../common/spinner'; /** * ChatMessageInput - Componente completo e independiente para el envío de mensajes en chats * * Este componente maneja todo el flujo de envío de mensajes: * - Input de texto con auto-resize y diseño minimalista * - Grabación de audio con visualización de ondas en tiempo real * - Transcripción de audio a texto mediante callback externo (opcional) * - Adjuntar archivos múltiples * - Preview de archivos adjuntos * - Preview de audio grabado (cuando no se usa transcripción) * - Envío de mensajes (texto, archivos y/o audio) * * El componente es completamente independiente del layout padre y usa `flex-shrink-0` * para mantenerse fijo en la parte inferior. No requiere configuración especial del contenedor padre. * * **Transcripción de Audio:** * Si se proporciona `onAudioTranscribe`, cuando se detiene la grabación de audio, el componente: * 1. Muestra un spinner mientras transcribe * 2. Llama al callback con el Blob de audio * 3. Establece automáticamente el texto transcrito en el input * 4. Limpia el audio grabado (ya que se usa el texto) * * Si no se proporciona `onAudioTranscribe`, el comportamiento es el mismo que antes: * el audio se mantiene como Blob y se envía junto con el mensaje. */ export interface ChatMessageInputProps { value: string; onChange: (value: string) => void; onSend: (message: string, files?: File[], audioBlob?: Blob) => void; onFileChange?: (files: File[]) => void; /** * Optional callback to transcribe audio to text. * If provided, when audio recording stops, it will be called to transcribe the audio. * The transcribed text will be automatically set in the input field. * * @param audioBlob - The recorded audio blob * @returns Promise - The transcribed text */ onAudioTranscribe?: (audioBlob: Blob) => Promise; disabled?: boolean; isLoading?: boolean; placeholder?: string; } export function ChatMessageInput({ value, onChange, onSend, onFileChange, onAudioTranscribe, disabled = false, isLoading = false, placeholder = 'Type your message...', }: ChatMessageInputProps) { const [files, setFiles] = useState([]); const [showFileUpload, setShowFileUpload] = useState(false); const [isRecording, setIsRecording] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false); const [recordedAudio, setRecordedAudio] = useState(null); const [recordingTime, setRecordingTime] = useState(0); const textareaRef = useRef(null); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const streamRef = useRef(null); const timerRef = useRef(null); const { audioData, startVisualization, stopVisualization } = useAudioVisualizer(); useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; const scrollHeight = textareaRef.current.scrollHeight; const minHeight = 24; const maxHeight = 200; const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); textareaRef.current.style.height = `${newHeight}px`; if (scrollHeight > maxHeight) { textareaRef.current.style.overflowY = 'auto'; } else { textareaRef.current.style.overflowY = 'hidden'; } } }, [value]); const handleSend = () => { if ((!value.trim() && files.length === 0 && !recordedAudio) || disabled || isLoading) { return; } onSend(value.trim(), files.length > 0 ? files : undefined, recordedAudio || undefined); onChange(''); setFiles([]); setRecordedAudio(null); setShowFileUpload(false); if (onFileChange) { onFileChange([]); } }; const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleFilesChange = (newFiles: File[]) => { setFiles(newFiles); if (onFileChange) { onFileChange(newFiles); } }; const removeFile = (index: number) => { const updatedFiles = files.filter((_, i) => i !== index); setFiles(updatedFiles); if (onFileChange) { onFileChange(updatedFiles); } }; const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; startVisualization(stream); const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm', }); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; mediaRecorder.ondataavailable = event => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); } }; mediaRecorder.onstop = async () => { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); stopVisualization(); if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } if (onAudioTranscribe && audioBlob.size > 0) { setIsTranscribing(true); try { const transcribedText = await onAudioTranscribe(audioBlob); onChange(transcribedText); setRecordedAudio(null); } catch (error) { console.error('Error transcribing audio:', error); setRecordedAudio(audioBlob); } finally { setIsTranscribing(false); } } else { setRecordedAudio(audioBlob); } }; mediaRecorder.start(); setIsRecording(true); setRecordingTime(0); timerRef.current = setInterval(() => { setRecordingTime(prev => prev + 1); }, 1000); } catch (error) { console.error('Error starting recording:', error); stopVisualization(); } }; const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } } }; const cancelRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); audioChunksRef.current = []; } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } stopVisualization(); setIsRecording(false); setRecordingTime(0); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; const removeRecordedAudio = () => { setRecordedAudio(null); }; const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; useEffect(() => { return () => { if (timerRef.current) { clearInterval(timerRef.current); } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } stopVisualization(); }; }, []); return (
{files.length > 0 && (
{files.length} {files.length === 1 ? 'file' : 'files'} selected
{files.map((file, index) => { const fileMetadata: FileMetadata = { type: file.type.startsWith('audio/') ? 'audio' : file.type.startsWith('image/') ? 'image' : file.type.includes('pdf') || file.type.includes('word') || file.type.includes('text') ? 'document' : file.type.includes('spreadsheet') || file.type.includes('excel') || file.name.endsWith('.csv') ? 'spreadsheet' : 'other', mimeType: file.type, size: file.size, originalName: file.name, url: URL.createObjectURL(file), }; return ( removeFile(index)} showRemove={true} compact={true} /> ); })}
)} {recordedAudio && !isRecording && (
)} {showFileUpload && (

Upload Files

)}
{isRecording ? (
{formatTime(recordingTime)}
) : (