import { useState, useRef, useEffect } from "react"; import { API_BASE } from "./config"; import { Markdown } from "./Markdown"; export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [queue, setQueue] = useState([]); // messages typed while a reply was streaming, sent in order once it's free 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 [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 [approvalToken, setApprovalToken] = useState(null); // single-use token authorizing /chat/approve 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; fetch(`${API_BASE}/conversations/${conversationId}`) .then(r => r.ok ? r.json() : null) .then(data => { if (cancelled) return; // tokens/model are persisted per message; elapsed/rate are live-only. setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content, model: m.model, stats: m.tokens ? { tokens: m.tokens } : undefined, })) || []); }) .catch(() => { if (!cancelled) setMessages([]); }); return () => { cancelled = true; }; }, [conversationId]); // Keyed on `visible`, not []: this component stays mounted while other pages // show (App hides it with display:none so an in-flight reply survives // navigation), so a mount-once fetch left the picker showing whatever was // installed when the tab first opened β€” a model pulled on the Models page // didn't appear here until a full browser reload. useEffect(() => { if (!visible) return; 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(() => {}); }, [visible]); 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]); // Declared ahead of sendMessage: it is called from there, and a const arrow // defined further down is still in the TDZ as far as the linter is concerned. const updateAssistant = (index, text) => { setMessages(prev => { const updated = [...prev]; updated[index] = { ...updated[index], content: text }; return updated; }); }; 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(""); setQueue([]); setLoading(false); 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); // Tag this message with the model that answered and its token stats setMessages(prev => { const updated = [...prev]; updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats }; 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 { const parsed = JSON.parse(payload); setPendingApproval(parsed.actions || []); setApprovalToken(parsed.token || null); } 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 })); // Shared by an immediate send and an auto-flushed queued message. const doSend = async (userMessage, outImages = []) => { // 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 sendMessage = async () => { if ((!input.trim() && images.length === 0) || loading) return; const userMessage = input.trim(); const outImages = images.map(i => i.b64); setInput(""); setImages([]); await doSend(userMessage, outImages); }; // Enter while a reply is streaming queues the message instead of sending it; // it's auto-sent, in order, once the current reply finishes (see the flush effect below). const queueMessage = () => { const text = input.trim(); if (!text) return; setQueue(prev => [...prev, text]); setInput(""); }; const removeQueued = (idx) => setQueue(prev => prev.filter((_, i) => i !== idx)); // Flush one queued message each time the box goes idle. useEffect(() => { if (loading || queue.length === 0) return; const [next, ...rest] = queue; setQueue(rest); doSend(next); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, queue]); 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 || []; const token = approvalToken; setPendingApproval(null); setApprovalToken(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, token, decisions }), }); } catch { /* ignore */ } }; const stopGeneration = () => { if (abortRef.current) abortRef.current.abort(); setLoading(false); setActiveTool(null); if (pendingApproval) resolveApproval(false); // stopping = deny pending actions }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (loading) queueMessage(); else sendMessage(); } }; return (
{/* Header */}
{messages.length === 0 ? "New conversation" : `${Math.ceil(messages.length / 2)} exchange${messages.length > 2 ? "s" : ""}`}
{showPicker && (
setModelChoice("")} style={{ padding: "0.55rem 0.85rem", cursor: "pointer", fontSize: "0.8rem", color: !selectedModel ? "#7aa" : "#888", background: !selectedModel ? "#1a2a2a" : "transparent", borderBottom: "1px solid #262626", display: "flex", justifyContent: "space-between", alignItems: "center", }} > Auto {autoModel ? `(${autoModel})` : ""} {!selectedModel && βœ“}
{modelList.map(m => (
setModelChoice(m)} style={{ padding: "0.55rem 0.85rem", cursor: "pointer", fontSize: "0.8rem", color: selectedModel === m ? "#7aa" : "#ccc", background: selectedModel === m ? "#1a2a2a" : "transparent", display: "flex", justifyContent: "space-between", alignItems: "center", }} > {m} {selectedModel === m && βœ“}
))}
)}
{messages.length === 0 ? (

Start a conversation with the chatbot...

) : ( messages.map((msg, idx) => (
{msg.role === "user" && editingIdx === idx ? (