import type { KeyboardEvent, RefObject } from 'react'; import { useState, useRef } from 'react'; import { Loader2, Mic, Send, Square } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import type { ModelOption } from '@/state/useModels'; import type { ChatMessage, Attachment } from './types'; import { ModelSelector } from './ModelSelector'; import { AttachmentList } from './AttachmentList'; import { AttachButton } from './AttachButton'; import { WebpageDialog } from './WebpageDialog'; const blobToWav = async (blob: Blob): Promise => { const ctx = new AudioContext(); const buf = await ctx.decodeAudioData(await blob.arrayBuffer()); await ctx.close(); const samples = buf.getChannelData(0); const len = samples.length; const sr = buf.sampleRate; const ab = new ArrayBuffer(44 + len * 2); const v = new DataView(ab); const s = (o: number, str: string) => { for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i)); }; s(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); s(8, 'WAVE'); s(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); s(36, 'data'); v.setUint32(40, len * 2, true); for (let i = 0; i < len; i++) { const val = Math.max(-1, Math.min(1, samples[i]!)); v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true); } return new Blob([ab], { type: 'audio/wav' }); }; type InputAreaProps = { input: string; onInputChange: (value: string) => void; onKeyDown: (ev: KeyboardEvent) => void; onSend: () => void; onStop: () => void; isGenerating: boolean; isConnected: boolean; commandFeedback: string | null; textareaRef: RefObject; messages: ChatMessage[]; availableModels: ModelOption[]; selectedModel: string | null; onModelChange: (modelId: string) => void; model: string | null; attachments: Attachment[]; onAttachWebpage: (url: string) => void; onAttachImage: (file: File) => void; onRemoveAttachment: (index: number) => void; }; export const InputArea = ({ input, onInputChange, onKeyDown, onSend, onStop, isGenerating, isConnected, commandFeedback, textareaRef, messages, availableModels, selectedModel, onModelChange, model, attachments, onAttachWebpage, onAttachImage, onRemoveAttachment, }: InputAreaProps) => { const [urlDialogOpen, setUrlDialogOpen] = useState(false); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const handleMicClick = async () => { if (recording) { const recorder = mediaRecorderRef.current; if (!recorder) return; setRecording(false); try { if (recorder.state === 'inactive') { recorder.stream.getTracks().forEach((t) => t.stop()); return; } const blob = await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000); recorder.onstop = () => { clearTimeout(timeout); resolve(new Blob(chunksRef.current, { type: recorder.mimeType })); chunksRef.current = []; }; recorder.stop(); }); recorder.stream.getTracks().forEach((t) => t.stop()); if (blob.size === 0) { toast.error('No audio was captured'); return; } setTranscribing(true); try { const wav = await blobToWav(blob); const formData = new FormData(); formData.append('file', wav, 'recording.wav'); formData.append('temperature', '0.0'); formData.append('temperature_inc', '0.2'); formData.append('response_format', 'json'); const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData }); if (!res.ok) throw new Error(`Whisper returned ${res.status}`); const json = await res.json(); if (json.error) throw new Error(json.error); const text = (json.text ?? '').trim(); if (text) onInputChange(input + (input.length > 0 ? ' ' : '') + text); } finally { setTranscribing(false); } } catch (err) { recorder.stream?.getTracks().forEach((t) => t.stop()); chunksRef.current = []; toast.error(err instanceof Error ? err.message : 'Recording failed'); } return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); mediaRecorderRef.current = recorder; chunksRef.current = []; recorder.ondataavailable = (ev) => { if (ev.data.size > 0) chunksRef.current.push(ev.data); }; recorder.start(250); setRecording(true); } catch (err) { toast.error(err instanceof Error ? err.message : 'Could not access microphone'); } }; return (
{commandFeedback && (
{commandFeedback}
)}
setUrlDialogOpen(true)} />