254 lines
8.3 KiB
TypeScript
254 lines
8.3 KiB
TypeScript
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<Blob> => {
|
|
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<HTMLTextAreaElement>) => void;
|
|
onSend: () => void;
|
|
onStop: () => void;
|
|
isGenerating: boolean;
|
|
isConnected: boolean;
|
|
commandFeedback: string | null;
|
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
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<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
|
|
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<Blob>((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 (
|
|
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
|
|
{commandFeedback && (
|
|
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
|
)}
|
|
|
|
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
|
|
|
<div className="flex items-end gap-1 md:gap-2">
|
|
<AttachButton
|
|
onAttachImage={onAttachImage}
|
|
onAttachWebpage={() => setUrlDialogOpen(true)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={transcribing}
|
|
onClick={handleMicClick}
|
|
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{transcribing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : recording ? (
|
|
<>
|
|
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
|
|
<Square className="h-3.5 w-3.5 text-red-500" />
|
|
</>
|
|
) : (
|
|
<Mic className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={(ev) => onInputChange(ev.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
onPaste={(ev) => {
|
|
const items = ev.clipboardData?.items;
|
|
if (!items) return;
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith('image/')) {
|
|
ev.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) onAttachImage(file);
|
|
return;
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Type a message..."
|
|
rows={1}
|
|
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
|
/>
|
|
{isGenerating ? (
|
|
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
|
<Square className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={onSend}
|
|
disabled={!input.trim() || !isConnected}
|
|
size="icon"
|
|
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<ModelSelector
|
|
messages={messages}
|
|
availableModels={availableModels}
|
|
selectedModel={selectedModel}
|
|
onModelChange={onModelChange}
|
|
model={model}
|
|
isConnected={isConnected}
|
|
isGenerating={isGenerating}
|
|
/>
|
|
|
|
<WebpageDialog
|
|
open={urlDialogOpen}
|
|
onOpenChange={setUrlDialogOpen}
|
|
onSubmit={onAttachWebpage}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|