Agentic_RAG / frontend /src /components /ChatInput.jsx
H022329's picture
Upload folder using huggingface_hub
6b62834 verified
Raw
History Blame Contribute Delete
5.78 kB
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 (
<div className="chat-input-area">
{recording && (
<div className="recording-bar">
<span className="recording-pulse" />
<span>Recording... click 🎀 again to stop</span>
</div>
)}
{processing && (
<div className="recording-bar processing">
<span className="loading-dots"><span>.</span><span>.</span><span>.</span></span>
<span>Processing voice...</span>
</div>
)}
{attachments.length > 0 && (
<div className="attach-chips-row">
{attachments.map((f, i) => (
<span key={i} className="attach-chip">{f.name} <span className="remove-chip" onClick={() => setAttachments(prev => prev.filter((_, j) => j !== i))}>Γ—</span></span>
))}
</div>
)}
<div className="chat-input-row">
<button className="input-icon-btn" onClick={() => fileRef.current?.click()} title="Attach">πŸ“Ž</button>
<input ref={fileRef} type="file" hidden multiple accept="image/*" onChange={e => { attachFiles(e.target.files); e.target.value = ''; }} />
<textarea rows="1" value={text} onChange={e => setText(e.target.value)}
onKeyDown={handleKey} placeholder="How can I help you today?" />
<button className={`input-icon-btn ${recording ? 'recording' : ''}`}
onClick={toggleMic} disabled={processing}
title={recording ? 'Stop' : 'Voice'}>
{processing ? '⏳' : recording ? 'πŸ”΄' : '🎀'}
</button>
{streaming
? <button className="send-btn stop" onClick={onStop}>β– </button>
: <button className="send-btn" onClick={send} disabled={!text.trim() && !attachments.length}>↑</button>}
</div>
</div>
);
}