| 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); |
| } |
| }; |
|
|
| |
|
|
| 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(); |
|
|
| |
| 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> |
| ); |
| } |
|
|