import { useState, useRef, useEffect } from "react"; import { API_BASE } from "./config"; import { Markdown } from "./Markdown"; export function Chatbot({ conversationId, setConversationId, onConversationChanged }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [modelList, setModelList] = useState([]); const [selectedModel, setSelectedModel] = useState(""); // "" = auto const [autoModel, setAutoModel] = useState(null); const [think, setThink] = useState(false); // extended thinking, mirrors Settings const [showPicker, setShowPicker] = useState(false); const [copiedIdx, setCopiedIdx] = useState(null); const [lastStats, setLastStats] = useState(null); const [memoryToast, setMemoryToast] = useState(null); const [images, setImages] = useState([]); // {name, b64} for vision models const [activeTool, setActiveTool] = useState(null); // playbook tool currently running const [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no const [editingIdx, setEditingIdx] = useState(null); // user message being edited const [editText, setEditText] = useState(""); const [listening, setListening] = useState(false); // mic dictation active const [transcribing, setTranscribing] = useState(false); // local STT running const [sttLocal, setSttLocal] = useState(false); // backend Whisper available const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud const recognitionRef = useRef(null); const mediaRecRef = useRef(null); // Web Speech API β browser-native, no backend/model. Absent on unsupported browsers. const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition); const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window; const canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder; // Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google). useEffect(() => { fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null) .then(d => setSttLocal(Boolean(d && d.available))).catch(() => {}); }, []); const _appendTranscript = (text) => { if (text) setInput(prev => (prev ? prev + " " : "") + text); }; // Local path: record audio, POST to /stt (faster-whisper transcribes on-device). const startLocalDictation = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const rec = new MediaRecorder(stream); const chunks = []; rec.ondataavailable = e => e.data.size && chunks.push(e.data); rec.onstop = async () => { stream.getTracks().forEach(t => t.stop()); setListening(false); setTranscribing(true); try { const b64 = await new Promise((res) => { const fr = new FileReader(); fr.onload = () => res(String(fr.result).split(",")[1]); fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType })); }); const r = await fetch(`${API_BASE}/stt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ audio: b64 }), }); if (r.ok) _appendTranscript((await r.json()).text); } catch { /* ignore */ } finally { setTranscribing(false); } }; mediaRecRef.current = rec; setListening(true); rec.start(); } catch { setListening(false); } }; const startBrowserDictation = () => { const rec = new SpeechRec(); rec.lang = "en-US"; rec.interimResults = false; rec.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim()); rec.onend = () => setListening(false); rec.onerror = () => setListening(false); recognitionRef.current = rec; setListening(true); rec.start(); }; const toggleMic = () => { if (transcribing) return; if (listening) { if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop(); else recognitionRef.current?.stop(); return; } if (sttLocal && canRecord) startLocalDictation(); else if (SpeechRec) startBrowserDictation(); }; // Read a reply aloud (light markdown strip so symbols aren't spoken). const speak = (idx, text) => { if (!ttsSupported) return; window.speechSynthesis.cancel(); if (speakingIdx === idx) { setSpeakingIdx(null); return; } const clean = text.replace(/[*_`#>]/g, "").replace(/\[(.*?)\]\(.*?\)/g, "$1"); const u = new SpeechSynthesisUtterance(clean); u.onend = () => setSpeakingIdx(null); setSpeakingIdx(idx); window.speechSynthesis.speak(u); }; const abortRef = useRef(null); const messagesEndRef = useRef(null); const pickerRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); useEffect(() => { if (!conversationId) return; if (abortRef.current) abortRef.current.abort(); let cancelled = false; setLastStats(null); fetch(`${API_BASE}/conversations/${conversationId}`) .then(r => r.ok ? r.json() : null) .then(data => { if (cancelled) return; setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []); }) .catch(() => { if (!cancelled) setMessages([]); }); return () => { cancelled = true; }; }, [conversationId]); useEffect(() => { Promise.all([ fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null), fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null), ]).then(([models, settings]) => { if (models?.models) setModelList(models.models); if (models?.selected) setAutoModel(models.selected); if (settings) { setSelectedModel(settings.model || ""); setThink(!!settings.think); } }).catch(() => {}); }, []); useEffect(() => { if (!showPicker) return; const handle = (e) => { if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false); }; document.addEventListener("mousedown", handle); return () => document.removeEventListener("mousedown", handle); }, [showPicker]); const setModelChoice = async (model) => { setSelectedModel(model); setShowPicker(false); try { await fetch(`${API_BASE}/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }), }); } catch { /* persisting the model choice is best-effort */ } }; const toggleThink = async () => { const next = !think; setThink(next); try { await fetch(`${API_BASE}/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ think: next }), }); } catch { /* persisting the toggle is best-effort */ } }; const startNewChat = () => { if (abortRef.current) abortRef.current.abort(); setInput(""); setLoading(false); setLastStats(null); setConversationId(crypto.randomUUID()); }; const onImagePick = (e) => { const files = Array.from(e.target.files || []); files.forEach(file => { const reader = new FileReader(); reader.onload = () => { const b64 = String(reader.result || "").split(",")[1]; // strip data: prefix if (b64) setImages(prev => [...prev, { name: file.name, b64 }]); }; reader.readAsDataURL(file); }); e.target.value = ""; }; // Shared streaming core: POST /chat/stream and fold tokens into the assistant // message at `assistantIndex`. Used by send, regenerate, and edit-and-resend. const streamAssistant = async ({ message, history, images: imgs, assistantIndex }) => { setLoading(true); // Abort previous stream if still open if (abortRef.current) abortRef.current.abort(); const controller = new AbortController(); abortRef.current = controller; try { const response = await fetch(`${API_BASE}/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, conversation_id: conversationId, history, think, ...(imgs && imgs.length ? { images: imgs } : {}), }), signal: controller.signal, }); if (!response.ok) { const text = await response.text(); updateAssistant(assistantIndex, `Error: ${text}`); setLoading(false); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let pendingEventType = null; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (line.startsWith("event: ")) { pendingEventType = line.slice(7).trim(); continue; } if (line.startsWith("data: ")) { const payload = line.slice(6); if (!payload.trim()) { pendingEventType = null; continue; } if (pendingEventType === "meta") { try { const stats = JSON.parse(payload); setLastStats(stats); // Tag this message with the model that answered if (stats.model) { setMessages(prev => { const updated = [...prev]; updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model }; return updated; }); } } catch { /* ignore */ } pendingEventType = null; continue; } if (pendingEventType === "memory") { try { const mem = JSON.parse(payload); setMemoryToast(mem); setTimeout(() => setMemoryToast(null), 5000); } catch { /* ignore */ } pendingEventType = null; continue; } if (pendingEventType === "status") { try { setActiveTool(JSON.parse(payload).tool); } catch { /* ignore */ } pendingEventType = null; continue; } if (pendingEventType === "tool_request") { try { setPendingApproval(JSON.parse(payload)); } catch { /* ignore */ } pendingEventType = null; continue; } if (pendingEventType === "sources") { try { const src = JSON.parse(payload).sources; setMessages(prev => { const updated = [...prev]; updated[assistantIndex] = { ...updated[assistantIndex], sources: src }; return updated; }); } catch { /* ignore */ } pendingEventType = null; continue; } if (pendingEventType === "done") { // Answer is complete; re-enable input while the backend finishes // slow post-processing (title, memory) on the still-open stream. setLoading(false); setActiveTool(null); setPendingApproval(null); pendingEventType = null; continue; } if (pendingEventType === "title") { if (onConversationChanged) onConversationChanged(); pendingEventType = null; continue; } if (pendingEventType === "error") { try { const err = JSON.parse(payload); updateAssistant(assistantIndex, `Error: ${err.detail || payload}`); } catch { updateAssistant(assistantIndex, `Error: ${payload}`); } pendingEventType = null; setLoading(false); return; } pendingEventType = null; let token = payload; try { token = JSON.parse(payload); } catch { /* plain text fallback */ } if (activeTool) setActiveTool(null); // tokens started -> tools done if (pendingApproval) setPendingApproval(null); setMessages(prev => { const updated = [...prev]; updated[assistantIndex] = { ...updated[assistantIndex], content: (updated[assistantIndex].content || "") + token, }; return updated; }); } } } } catch (err) { if (err.name !== "AbortError") { updateAssistant(assistantIndex, `Connection error: ${err.message}`); } } finally { setLoading(false); if (onConversationChanged) onConversationChanged(); } }; const histBefore = (index) => messages.slice(0, index).filter(m => m.content.trim() !== "") .map(m => ({ role: m.role, content: m.content })); const sendMessage = async () => { if ((!input.trim() && images.length === 0) || loading) return; const userMessage = input.trim(); const outImages = images.map(i => i.b64); setInput(""); setImages([]); // Note any attached images so image-only turns aren't blank const shownContent = outImages.length ? `${userMessage}${userMessage ? "\n\n" : ""}π· ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached` : userMessage; const history = histBefore(messages.length); const assistantIndex = messages.length + 1; setMessages(prev => [...prev, { role: "user", content: shownContent }, { role: "assistant", content: "" }]); await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex }); }; const regenerate = async () => { if (loading) return; const lastUserIdx = messages.map(m => m.role).lastIndexOf("user"); if (lastUserIdx < 0) return; const userMessage = messages[lastUserIdx].content; const history = histBefore(lastUserIdx); const base = messages.slice(0, lastUserIdx + 1); // drop the old assistant reply setMessages([...base, { role: "assistant", content: "" }]); await streamAssistant({ message: userMessage, history, images: [], assistantIndex: base.length }); }; const editAndResend = async (index, newText) => { if (loading || !newText.trim()) return; const history = histBefore(index); const base = [...messages.slice(0, index), { role: "user", content: newText.trim() }]; setEditingIdx(null); setMessages([...base, { role: "assistant", content: "" }]); await streamAssistant({ message: newText.trim(), history, images: [], assistantIndex: base.length }); }; // Approve or deny the pending action tool(s); the open chat stream resumes. const resolveApproval = async (approve) => { const req = pendingApproval || []; setPendingApproval(null); const decisions = {}; req.forEach(a => { decisions[a.name] = approve; }); try { await fetch(`${API_BASE}/chat/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversation_id: conversationId, decisions }), }); } catch { /* ignore */ } }; const stopGeneration = () => { if (abortRef.current) abortRef.current.abort(); setLoading(false); setActiveTool(null); if (pendingApproval) resolveApproval(false); // stopping = deny pending actions }; const updateAssistant = (index, text) => { setMessages(prev => { const updated = [...prev]; updated[index] = { ...updated[index], content: text }; return updated; }); }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; return (
Start a conversation with the chatbot...
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
))}