import { useState, useRef } from 'react'; import { api } from '../api'; export default function ChatInput({ onSend, streaming, onStop, ensureSession }) { const [text, setText] = useState(''); const [attachments, setAttachments] = useState([]); const [recording, setRecording] = useState(false); const [processing, setProcessing] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const fileRef = useRef(null); const send = () => { if (!text.trim() && !attachments.length) return; onSend(text, attachments.map(f => f.dataUri).filter(Boolean)); setText(''); setAttachments([]); }; const handleKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; const attachFiles = (files) => { for (const f of files) { const reader = new FileReader(); reader.onload = () => setAttachments(prev => [...prev, { name: f.name, dataUri: reader.result }]); reader.readAsDataURL(f); } }; // ── Voice: record → SSE stream (transcript → answer → audio) ── const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; const mr = new MediaRecorder(stream, { mimeType: mime }); audioChunksRef.current = []; mr.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; mr.onstop = () => { stream.getTracks().forEach(t => t.stop()); processVoice(); }; mr.start(); mediaRecorderRef.current = mr; setRecording(true); } catch (e) { alert('Microphone denied: ' + e.message); } }; const stopRecording = () => { if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop(); setRecording(false); }; const processVoice = async () => { if (!audioChunksRef.current.length) return; setProcessing(true); const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); try { const sid = await ensureSession?.() || ''; const abort = new AbortController(); // Dispatch events to ChatPanel via window for progressive UI updates let transcriptText = ''; let answerText = ''; window.dispatchEvent(new CustomEvent('voice-start')); await api.voiceChatStream(blob, sid, '', true, ({ type, data }) => { switch (type) { case 'transcript': transcriptText = data.text || ''; window.dispatchEvent(new CustomEvent('voice-transcript', { detail: { text: transcriptText } })); break; case 'text_delta': answerText += data.content || ''; window.dispatchEvent(new CustomEvent('voice-delta', { detail: { content: data.content || '' } })); break; case 'tool_call_start': window.dispatchEvent(new CustomEvent('voice-tool', { detail: { tool: data.tool || '' } })); break; case 'audio': if (data.audio) { window.dispatchEvent(new CustomEvent('voice-audio', { detail: { audio: data.audio, format: data.format || 'wav' } })); } break; case 'done': window.dispatchEvent(new CustomEvent('voice-done', { detail: { answer: answerText, transcript: transcriptText } })); break; case 'error': window.dispatchEvent(new CustomEvent('voice-error', { detail: { error: data.error || 'Voice error' } })); break; } }, abort.signal); } catch (e) { if (e.name !== 'AbortError') { window.dispatchEvent(new CustomEvent('voice-error', { detail: { error: e.message } })); } } finally { setProcessing(false); } }; const toggleMic = () => { if (recording) stopRecording(); else if (!processing) startRecording(); }; return (
{recording && (
Recording... click 🎤 again to stop
)} {processing && (
... Processing voice...
)} {attachments.length > 0 && (
{attachments.map((f, i) => ( {f.name} setAttachments(prev => prev.filter((_, j) => j !== i))}>× ))}
)}
{ attachFiles(e.target.files); e.target.value = ''; }} />