File size: 5,784 Bytes
6b62834 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | 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>
);
}
|