const { useState, useEffect, useRef, useMemo } = React; // ---- tiny markdown → HTML (safe subset) ---- function mdToHtml(src) { if (!src) return ""; let s = src .replace(/&/g, "&") .replace(//g, ">"); s = s.replace(/^###\s+(.+)$/gm, "

$1

"); s = s.replace(/^##\s+(.+)$/gm, "

$1

"); s = s.replace(/\*\*(.+?)\*\*/g, "$1"); s = s.replace(/\*(.+?)\*/g, "$1"); s = s.replace(/`([^`]+)`/g, "$1"); s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1'); // lists: group consecutive "- " lines s = s.replace(/(^|\n)([-*]) (.+?)(?=\n(?![-*])|$)/gs, (m, pre, _b, body) => { const items = body.split(/\n[-*] /).map(x => `
  • ${x.trim()}
  • `).join(""); return `${pre}`; }); // paragraphs s = s.split(/\n{2,}/).map(p => { if (/^(${p.replace(/\n/g, "
    ")}

    `; }).join("\n"); return s; } // ---- streaming helpers ---- async function* streamNDJSON(response) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n")) !== -1) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (line) { try { yield JSON.parse(line); } catch {} } } } if (buf.trim()) { try { yield JSON.parse(buf); } catch {} } } async function postChatStream({ message, history }) { const headers = { "Content-Type": "application/json" }; const t = window.MGR_API && window.MGR_API.token(); if (t) headers["Authorization"] = "Bearer " + t; const resp = await fetch("/api/chat/stream", { method: "POST", headers, body: JSON.stringify({ message, history }), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`API ${resp.status}: ${text || resp.statusText}`); } return resp; } // ---- icons (inline SVG, same visual language) ---- const Ico = { spark: (p = {}) => ( ), send: (p = {}) => ( ), plus: (p = {}) => ( ), mic: (p = {}) => ( ), alert: (p = {}) => ( ), note: (p = {}) => ( ), }; // ---- conversation storage ---- const STORE_KEY = "mgr_chat_conversations_v1"; function loadConvos() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || []; } catch { return []; } } function saveConvos(list) { try { localStorage.setItem(STORE_KEY, JSON.stringify(list)); } catch {} } // ---- Voice orb: particle field / orbital ---- function VoiceOrbWeb({ state }) { const particles = useMemo(() => { const N = 48; const arr = []; for (let i = 0; i < N; i++) { const angle = (i / N) * 360 + (Math.random() * 10 - 5); const shell = Math.random(); const radius = 70 + shell * 95; const size = 2.5 + Math.random() * Math.random() * 6; const duration = 7 + Math.random() * 10; const delay = -(Math.random() * duration); const opacity = 0.45 + Math.random() * 0.5; const durT = 2.2 + Math.random() * 1.8; const pulseDur = 0.55 + Math.random() * 0.5; arr.push({ angle, radius, size, duration, delay, opacity, durT, pulseDur }); } return arr; }, []); return (
    {particles.map((p, i) => ( ))}
    ); } function fmtElapsed(s) { return `${String(Math.floor(s/60)).padStart(2,"0")}:${String(s%60).padStart(2,"0")}`; } function VoiceView({ conv, onAppendTurn, onAppendEvidence, onUrgency }) { const [state, setState] = useState("idle"); const [elapsed, setElapsed] = useState(0); const [liveCaption, setLiveCaption] = useState({ who: "idle", text: "" }); const [error, setError] = useState(null); const [muted, setMuted] = useState(false); const sessionRef = useRef(null); useEffect(() => { let iv; if (sessionRef.current) iv = setInterval(() => setElapsed(e => e + 1), 1000); return () => { if (iv) clearInterval(iv); }; }, [sessionRef.current]); useEffect(() => { return () => { if (sessionRef.current) sessionRef.current.stop(); }; }, []); async function start() { if (sessionRef.current) return; setError(null); setElapsed(0); setLiveCaption({ who: "idle", text: "Connecting…" }); const s = new window.MGR_VOICE.VoiceSession({ wsUrl: window.MGR_VOICE.buildWsUrl(), onState: (st) => setState(st === "connecting" ? "thinking" : st), onEvent: (e) => { switch (e.type) { case "session_ready": setLiveCaption({ who: "ready", text: "I'm listening — what's going on?" }); break; case "assistant_transcript_delta": setLiveCaption({ who: "assistant", text: e.full }); break; case "assistant_transcript_done": onAppendTurn && onAppendTurn("assistant", e.text); setLiveCaption({ who: "assistant", text: e.text }); break; case "user_transcript": onAppendTurn && onAppendTurn("user", e.text); setLiveCaption({ who: "user", text: e.text }); break; case "tool_call": setLiveCaption({ who: "tool", text: `Searching the graph for: "${e.query}"` }); break; case "tool_result": onAppendEvidence && onAppendEvidence(e.chunks); break; case "session_timeout": setError("Session ended at the 5-minute cap."); break; case "server_error": setError(e.error && e.error.message ? e.error.message : "Server error"); break; case "_closed": sessionRef.current = null; setState("idle"); onUrgency && onUrgency(null); break; default: break; } }, }); try { await s.start(); sessionRef.current = s; } catch (err) { setError(err && err.message ? err.message : "Could not start session"); sessionRef.current = null; } } function stop() { if (sessionRef.current) sessionRef.current.stop(); sessionRef.current = null; setState("idle"); } function toggleMute() { const next = !muted; setMuted(next); if (sessionRef.current) sessionRef.current.toggleMute(next); } const stateCopy = { idle: { label: "Ready", dot: "#a3a597", caption: "Tap Start call to begin" }, listening: { label: "Listening", dot: "#1a9eab", caption: "Go ahead, I'm listening" }, thinking: { label: "Thinking", dot: "#d49341", caption: "Searching the knowledge graph…" }, speaking: { label: "Speaking", dot: "#5a9e7a", caption: "" }, }; const sc = stateCopy[state] || stateCopy.idle; const showCaption = liveCaption.text || sc.caption; const isLive = !!sessionRef.current; const displayCaption = (showCaption || "").slice(-220); return (
    {sc.label}
    {fmtElapsed(elapsed)} · {isLive ? "live" : "ready"} · shimmer
    {error &&
    {error}
    }
    {liveCaption.who === "user" ? "You said" : liveCaption.who === "assistant" ? "MedGraphRag" : liveCaption.who === "tool" ? "Consulting sources" : liveCaption.who === "ready" ? "Ready" : state === "idle" ? "Ready" : state}
    {displayCaption ? ( <> {showCaption && showCaption.length > 220 && } {displayCaption} ) : } {(state === "listening" || state === "speaking") && liveCaption.text && ( )}
    ); } const QUICK_PROMPTS = [ "I've had a headache on the right side for two days — should I worry?", "I have a fever of 38.5°C and a cough. What should I do?", "Can I take ibuprofen if I have high blood pressure?", "My throat hurts and I feel tired. Is this worth seeing a doctor?", ]; function App() { const [convos, setConvos] = useState(() => loadConvos()); const [activeId, setActiveId] = useState(() => { const c = loadConvos(); return c[0]?.id || null; }); const [draft, setDraft] = useState(""); const [streaming, setStreaming] = useState(false); const [error, setError] = useState(null); const [view, setView] = useState(() => (window.location.hash === "#voice" ? "voice" : "chat")); const threadRef = useRef(null); const taRef = useRef(null); useEffect(() => { const onHash = () => setView(window.location.hash === "#voice" ? "voice" : "chat"); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); const active = useMemo(() => convos.find(c => c.id === activeId) || null, [convos, activeId]); useEffect(() => { saveConvos(convos); }, [convos]); useEffect(() => { if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight; }, [active, streaming]); function newConvo(seedMessage) { const id = `c_${Date.now()}`; const now = Date.now(); const c = { id, title: "New chat", createdAt: now, updatedAt: now, messages: seedMessage ? [{ role: "user", content: seedMessage, t: now }] : [], urgency: null, }; setConvos(prev => [c, ...prev]); setActiveId(id); return c; } function updateConvo(id, patch) { setConvos(prev => prev.map(c => c.id === id ? { ...c, ...patch } : c)); } function appendAssistantDelta(id, delta) { setConvos(prev => prev.map(c => { if (c.id !== id) return c; const msgs = [...c.messages]; const last = msgs[msgs.length - 1]; if (last && last.role === "assistant") { msgs[msgs.length - 1] = { ...last, content: (last.content || "") + delta }; } else { msgs.push({ role: "assistant", content: delta, t: Date.now() }); } return { ...c, messages: msgs, updatedAt: Date.now() }; })); } function setAssistantRetrieval(id, chunks) { setConvos(prev => prev.map(c => { if (c.id !== id) return c; const msgs = [...c.messages]; msgs.push({ role: "assistant", content: "", chunks, t: Date.now() }); return { ...c, messages: msgs }; })); } function finalizeAssistant(id, urgency) { setConvos(prev => prev.map(c => { if (c.id !== id) return c; const msgs = [...c.messages]; const last = msgs[msgs.length - 1]; if (last && last.role === "assistant") { msgs[msgs.length - 1] = { ...last, urgency }; } return { ...c, urgency, messages: msgs }; })); } function voiceAppendTurn(role, text) { if (!text || !text.trim()) return; let conv = active; if (!conv) conv = newConvo(); setConvos(prev => prev.map(c => { if (c.id !== conv.id) return c; const msgs = [...c.messages, { role, content: text.trim(), t: Date.now(), via: "voice" }]; return { ...c, messages: msgs, updatedAt: Date.now(), title: c.messages.length === 0 && role === "user" ? text.slice(0, 60) : c.title }; })); } function voiceAppendEvidence(chunks) { if (!chunks || !chunks.length) return; let conv = active; if (!conv) conv = newConvo(); const normalized = chunks.map(c => ({ chunk_id: c.chunk_id, title: c.title, source: c.source, url: c.url, path: c.retrieval_path || c.path, snippet: (c.text || c.snippet || "").slice(0, 320), })); setConvos(prev => prev.map(c => { if (c.id !== conv.id) return c; const msgs = [...c.messages]; const last = msgs[msgs.length - 1]; if (last && last.role === "assistant" && last.via === "voice") { msgs[msgs.length - 1] = { ...last, chunks: [...(last.chunks || []), ...normalized] }; } else { msgs.push({ role: "assistant", content: "", chunks: normalized, t: Date.now(), via: "voice" }); } return { ...c, messages: msgs, updatedAt: Date.now() }; })); } async function send(text) { const t = (text ?? draft).trim(); if (!t || streaming) return; setError(null); setDraft(""); let conv = active; if (!conv) conv = newConvo(t); else updateConvo(conv.id, { messages: [...conv.messages, { role: "user", content: t, t: Date.now() }], updatedAt: Date.now(), title: conv.messages.length === 0 ? t.slice(0, 60) : conv.title, }); const history = (conv?.messages || []) .filter(m => m.content && m.role !== "system") .map(m => ({ role: m.role, content: m.content })); setStreaming(true); try { const resp = await postChatStream({ message: t, history }); for await (const evt of streamNDJSON(resp)) { if (evt.type === "retrieval") { setAssistantRetrieval(conv.id, evt.chunks || []); } else if (evt.type === "delta") { appendAssistantDelta(conv.id, evt.content || ""); } else if (evt.type === "urgency") { finalizeAssistant(conv.id, evt.level); } else if (evt.type === "error") { setError(evt.message || "Stream error"); } } } catch (e) { setError(e.message || String(e)); } finally { setStreaming(false); } } function onKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } } useEffect(() => { if (taRef.current) { taRef.current.style.height = "auto"; taRef.current.style.height = Math.min(taRef.current.scrollHeight, 180) + "px"; } }, [draft]); const lastAssistantChunks = useMemo(() => { if (!active) return []; for (let i = active.messages.length - 1; i >= 0; i--) { const m = active.messages[i]; if (m.role === "assistant" && m.chunks && m.chunks.length) return m.chunks; } return []; }, [active]); const urgencyBanner = active?.urgency === "emergency" ? { cls: "emergency", text: "The assistant suggested this may be an emergency. If in doubt, call your local emergency number now." } : active?.urgency === "urgent" ? { cls: "urgent", text: "The assistant suggested same-day / urgent care. Consider contacting a clinician today." } : null; return (
    {/* LEFT: conversations */} {/* CENTER: chat */}

    {active?.title || "New chat"}

    GraphRAG · Shimmer
    {urgencyBanner && (
    {urgencyBanner.text}
    )} {view === "voice" && ( {}} /> )} {view === "chat" && ( <>
    {(!active || active.messages.length === 0) && (
    What can I help with today?
    I'm a voice-first health guidance assistant. Ask me a question and I'll search a medical knowledge graph for grounded answers. I'm not a doctor — always verify with a clinician.
    )} {active?.messages.map((m, i) => { if (m.role === "user") return (
    {m.content}
    ); const html = mdToHtml(m.content || ""); const isStreaming = streaming && i === active.messages.length - 1 && !m.urgency; return (
    M
    {m.content ? (
    ' : "") }}/> ) : (
    {m.chunks && m.chunks.length ? `Reviewing ${m.chunks.length} source${m.chunks.length>1?"s":""}…` : "Searching the knowledge graph…"}
    )} {m.chunks && m.chunks.length > 0 && m.content && (
    {m.chunks.slice(0, 4).map((c, j) => ( {c.title || c.source || "source"} {c.path || "vector"} ))}
    )}
    ); })} {error &&
    Error: {error}
    }