doc.ai your gentle health companion
welcome

your records, kept gently in one place.

private, calm, and always at your fingertips.

const API_BASE = (window.__API_BASE__ || "").replace(/\/+$/, ""); const apiUrl = (path) => API_BASE + path; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const state = { token: localStorage.getItem("hkb_token") || null, user: JSON.parse(localStorage.getItem("hkb_user") || "null"), view: "chat", conversations: [], // [{id, title, updated_at}] activeConversationId: localStorage.getItem("hkb_active_conv") || null, docPolls: new Map(), streaming: false, activeAbort: null, // AbortController for the chat fetch }; function authHeaders() { return state.token ? { "Authorization": "Bearer " + state.token } : {}; } function fmtDate(s) { if (!s) return "—"; try { return new Date(s).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } catch (_) { return s; } } function escapeHtml(s) { return (s ?? "").toString().replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); } if (window.marked) marked.setOptions({ breaks: true, gfm: true }); /* ════════════════════════════════════════════════════════════════ Auth + view switching ════════════════════════════════════════════════════════════════ */ function renderAuth() { const authed = !!state.token; $("#auth-view").classList.toggle("hidden", authed); $("#tabs").classList.toggle("hidden", !authed); $("#bottom-tabs").classList.toggle("hidden", !authed); $("#who-area").classList.toggle("hidden", !authed); if (authed && state.user) $("#who").textContent = state.user.full_name; if (authed) { switchView(state.view); bootstrapConversations(); } else { hideAllViews(); } } function hideAllViews() { ["#view-chat", "#view-docs", "#view-memory", "#view-trends"].forEach(s => $(s).classList.add("hidden")); } function switchView(v) { state.view = v; hideAllViews(); $$("#tabs button, #bottom-tabs button").forEach(b => b.classList.toggle("active", b.dataset.view === v)); if (v === "chat") $("#view-chat").classList.remove("hidden"); else if (v === "docs") { $("#view-docs").classList.remove("hidden"); refreshDocs(); } else if (v === "memory") { $("#view-memory").classList.remove("hidden"); refreshMemory(); } else if (v === "trends") { $("#view-trends").classList.remove("hidden"); refreshTrends(); } window.scrollTo({ top: 0, behavior: "smooth" }); } $$("#tabs button, #bottom-tabs button").forEach(b => b.addEventListener("click", () => switchView(b.dataset.view))); $("#logout").addEventListener("click", () => { for (const id of state.docPolls.values()) clearInterval(id); state.docPolls.clear(); state.token = null; state.user = null; state.conversations = []; state.activeConversationId = null; localStorage.removeItem("hkb_token"); localStorage.removeItem("hkb_user"); localStorage.removeItem("hkb_active_conv"); $("#chat-log").innerHTML = ""; $("#conv-title").textContent = "let's talk"; renderAuth(); }); $("#tab-login").addEventListener("click", () => { $("#tab-login").classList.add("active"); $("#tab-register").classList.remove("active"); $("#login-form").classList.remove("hidden"); $("#register-form").classList.add("hidden"); }); $("#tab-register").addEventListener("click", () => { $("#tab-register").classList.add("active"); $("#tab-login").classList.remove("active"); $("#register-form").classList.remove("hidden"); $("#login-form").classList.add("hidden"); }); async function authPost(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); throw new Error(err.detail || "Request failed"); } return r.json(); } async function persistAuth(tok) { state.token = tok.access_token; state.user = { user_id: tok.user_id, username: tok.username, full_name: tok.full_name }; localStorage.setItem("hkb_token", state.token); localStorage.setItem("hkb_user", JSON.stringify(state.user)); renderAuth(); } $("#login-form").addEventListener("submit", async (e) => { e.preventDefault(); $("#login-err").textContent = ""; try { const tok = await authPost("/api/v1/auth/login", { username: $("#login-username").value, password: $("#login-password").value, }); await persistAuth(tok); } catch (err) { $("#login-err").textContent = err.message; } }); $("#register-form").addEventListener("submit", async (e) => { e.preventDefault(); $("#reg-err").textContent = ""; try { const tok = await authPost("/api/v1/auth/register", { username: $("#reg-username").value, password: $("#reg-password").value, full_name: $("#reg-full-name").value, email: $("#reg-email").value || null, }); await persistAuth(tok); } catch (err) { $("#reg-err").textContent = err.message; } }); /* ════════════════════════════════════════════════════════════════ Documents ════════════════════════════════════════════════════════════════ */ function fileTypeKey(t) { const k = (t || "").toLowerCase(); if (k.includes("pdf")) return "pdf"; if (k.includes("image")) return "image"; if (k.includes("fhir")) return "fhir"; if (k.includes("hl7")) return "hl7"; return "other"; } function fileIconName(t) { const k = fileTypeKey(t); if (k === "image") return "image"; if (k === "fhir" || k === "hl7") return "code"; return "file-text"; } async function refreshDocs() { try { const r = await fetch(apiUrl("/api/v1/documents"), { headers: authHeaders() }); if (!r.ok) return; const docs = await r.json(); const list = $("#doc-list"); const empty = $("#doc-empty"); empty.classList.toggle("hidden", docs.length > 0); list.innerHTML = ""; for (const d of docs) { list.appendChild(renderDocRow(d)); if ((d.status === "pending" || d.status === "processing") && !state.docPolls.has(d.id)) { const intv = setInterval(() => pollDoc(d.id), 3000); state.docPolls.set(d.id, intv); } } inflateIcons(list); } catch (e) { console.error(e); } } async function pollDoc(id) { try { const r = await fetch(apiUrl(`/api/v1/documents/${id}/status`), { headers: authHeaders() }); if (!r.ok) return; const s = await r.json(); if (s.status === "completed" || s.status === "failed") { const intv = state.docPolls.get(id); if (intv) { clearInterval(intv); state.docPolls.delete(id); } refreshDocs(); } } catch (_) {} } function renderDocRow(d) { const row = document.createElement("div"); row.className = "doc-row"; const tk = fileTypeKey(d.document_type); const isWorking = d.status === "pending" || d.status === "processing"; const displayName = d.friendly_title || d.filename; const showOriginal = d.friendly_title && d.friendly_title !== d.filename; row.innerHTML = `
${escapeHtml(displayName)}
${d.summary ? `
${escapeHtml(d.summary)}
` : (isWorking ? `
reading the document…
` : "")} ${showOriginal ? `
${escapeHtml(d.filename)}
` : ""}
${escapeHtml((d.document_type || "").toUpperCase())} · added ${fmtDate(d.created_at)} ${d.document_date ? `·doc ${fmtDate(d.document_date)}` : ""} ${d.source_institution ? `·${escapeHtml(d.source_institution)}` : ""}
${isWorking ? '' : ''} ${escapeHtml(d.status)}
${d.processing_error ? `
${escapeHtml(d.processing_error)}
` : ""} `; row.querySelector("[data-del]").addEventListener("click", async (e) => { e.stopPropagation(); if (!confirm(`Delete "${d.filename}"?`)) return; const r = await fetch(apiUrl(`/api/v1/documents/${d.id}`), { method: "DELETE", headers: authHeaders() }); if (r.status === 204) refreshDocs(); else alert("Delete failed"); }); if (d.status === "completed") loadDocStats(d.id, row); return row; } async function loadDocStats(id, row) { try { const r = await fetch(apiUrl(`/api/v1/documents/${id}`), { headers: authHeaders() }); if (!r.ok) return; const d = await r.json(); const stats = row.querySelector(`[data-stats="${id}"]`); if (!stats) return; const parts = []; if (d.diagnosis_count) parts.push(`${d.diagnosis_count} dx`); if (d.medication_count) parts.push(`${d.medication_count} meds`); if (d.lab_result_count) parts.push(`${d.lab_result_count} labs`); if (d.vital_count) parts.push(`${d.vital_count} vitals`); if (d.procedure_count) parts.push(`${d.procedure_count} procs`); if (d.allergy_count) parts.push(`${d.allergy_count} allergies`); if (parts.length) stats.innerHTML = "·" + parts.join(" · ") + ""; } catch (_) {} } $("#refresh-docs").addEventListener("click", refreshDocs); const dropzone = $("#dropzone"); const fileInput = $("#file-input"); $("#pick-files").addEventListener("click", e => { e.preventDefault(); fileInput.click(); }); dropzone.addEventListener("click", () => fileInput.click()); ["dragenter", "dragover"].forEach(ev => dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.add("drag"); })); ["dragleave", "drop"].forEach(ev => dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.remove("drag"); })); dropzone.addEventListener("drop", e => uploadFiles(e.dataTransfer.files)); fileInput.addEventListener("change", e => uploadFiles(e.target.files)); async function uploadFiles(filelist) { const files = Array.from(filelist || []); if (!files.length) return; const status = $("#upload-status"); let done = 0, failed = 0; status.innerHTML = `Uploading ${files.length} file${files.length > 1 ? "s" : ""}…`; for (const f of files) { const fd = new FormData(); fd.append("file", f); try { const r = await fetch(apiUrl("/api/v1/documents/upload"), { method: "POST", headers: authHeaders(), body: fd, }); if (r.ok) done++; else failed++; } catch (_) { failed++; } } fileInput.value = ""; status.innerHTML = `${done} uploaded${failed ? `, ${failed} failed` : ""} — processing in background.`; refreshDocs(); } /* ════════════════════════════════════════════════════════════════ Chat (text + voice unified) ════════════════════════════════════════════════════════════════ */ function appendUserBubble(text) { const log = $("#chat-log"); const turn = document.createElement("div"); turn.className = "turn user"; const b = document.createElement("div"); b.className = "bubble user"; b.textContent = text; turn.appendChild(b); log.appendChild(turn); scrollChat(); } function newAssistantTurn() { const log = $("#chat-log"); const turn = document.createElement("div"); turn.className = "turn assistant"; const toolStrip = document.createElement("div"); toolStrip.className = "tool-strip hidden"; turn.appendChild(toolStrip); const bubble = document.createElement("div"); bubble.className = "bubble assistant streaming"; bubble.innerHTML = "thinking…"; turn.appendChild(bubble); log.appendChild(turn); scrollChat(); return { turn, toolStrip, bubble, raw: "", toolCalls: new Map() }; } function scrollChat() { const log = $("#chat-log"); const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80; if (nearBottom) log.scrollTop = log.scrollHeight; } function renderMarkdown(target, raw) { if (!raw) { target.innerHTML = "thinking…"; return; } if (!window.marked) { target.textContent = raw; return; } target.innerHTML = marked.parse(raw); // Tables: wrap in scrollable container so wide tables don't overflow. target.querySelectorAll("table").forEach(t => { if (t.parentElement && t.parentElement.classList.contains("table-wrap")) return; const wrap = document.createElement("div"); wrap.className = "table-wrap"; t.parentNode.insertBefore(wrap, t); wrap.appendChild(t); }); // Images: lazy-load, no-referrer, gracefully hide on broken src. target.querySelectorAll("img").forEach(img => { img.loading = "lazy"; img.decoding = "async"; img.referrerPolicy = "no-referrer"; img.addEventListener("error", () => { const alt = img.getAttribute("alt") || "image"; const fallback = document.createElement("span"); fallback.className = "muted"; fallback.style.fontStyle = "italic"; fallback.textContent = `(image: ${alt})`; img.replaceWith(fallback); }); }); // External links open in a new tab. target.querySelectorAll("a").forEach(a => { const href = a.getAttribute("href") || ""; if (/^https?:/i.test(href)) { a.target = "_blank"; a.rel = "noopener noreferrer"; } }); } function attachToolCall(ctx, name, args) { ctx.toolStrip.classList.remove("hidden"); const id = name + "-" + ctx.toolCalls.size; const wrap = document.createElement("div"); const chip = document.createElement("span"); chip.className = "tool-chip"; chip.dataset.status = "running"; chip.innerHTML = `${iconSVG("loader", 12)}${escapeHtml(name)}`; const detail = document.createElement("div"); detail.className = "tool-detail hidden"; detail.textContent = "args " + JSON.stringify(args, null, 2); chip.addEventListener("click", () => detail.classList.toggle("hidden")); wrap.appendChild(chip); wrap.appendChild(detail); ctx.toolStrip.appendChild(wrap); ctx.toolCalls.set(id, { chip, detail, name }); return id; } function attachToolResult(ctx, name, preview) { for (const [id, slot] of ctx.toolCalls) { if (slot.name === name && slot.chip.dataset.status === "running") { slot.chip.dataset.status = "done"; slot.chip.querySelector(".badge-ico").innerHTML = iconSVG("check", 12); slot.detail.textContent += "\n\nresult\n" + preview; return; } } } async function streamChat(userMessage, handlers = {}) { const { onToken, onToolCall, onToolResult, onError, onConversation, onDone } = handlers; let full = ""; const ctrl = new AbortController(); state.activeAbort = ctrl; try { const r = await fetch(apiUrl("/api/v1/chat"), { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ conversation_id: state.activeConversationId, message: userMessage, language: $("#voice-lang").dataset.lang || "ml-IN", }), signal: ctrl.signal, }); if (!r.ok || !r.body) { const err = await r.json().catch(() => ({ detail: r.statusText })); onError && onError(err.detail || "request failed"); return null; } const reader = r.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\n")) !== -1) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); const lines = raw.split("\n"); let event = "message", data = ""; for (const ln of lines) { if (ln.startsWith("event: ")) event = ln.slice(7).trim(); else if (ln.startsWith("data: ")) data += ln.slice(6); } let payload = {}; try { payload = JSON.parse(data); } catch (_) {} if (event === "token") { full += payload.delta || ""; onToken && onToken(payload.delta || "", full); } else if (event === "tool_call") { onToolCall && onToolCall(payload.name, payload.arguments); } else if (event === "tool_result") { onToolResult && onToolResult(payload.name, payload.result_preview || ""); } else if (event === "conversation"){ onConversation && onConversation(payload); } else if (event === "error") { onError && onError(payload.message); } else if (event === "done") { onDone && onDone(full); } } } return full; } catch (err) { if (err && err.name === "AbortError") return full; // user-stopped onError && onError(err.message); return null; } finally { if (state.activeAbort === ctrl) state.activeAbort = null; } } /* Auto-resize textarea + enter-to-send */ const chatInput = $("#chat-input"); const sendBtn = $("#chat-send"); function refreshSendButton() { const isActive = state.streaming || voice.state === "speaking" || voice.state === "busy"; const ico = sendBtn.querySelector("[data-icon]"); if (isActive) { sendBtn.disabled = false; sendBtn.classList.add("stop-mode"); sendBtn.title = "Stop"; sendBtn.setAttribute("aria-label", "Stop"); if (ico.dataset.icon !== "square") { ico.dataset.icon = "square"; inflateIcons(sendBtn); } } else { sendBtn.classList.remove("stop-mode"); sendBtn.title = "Send"; sendBtn.setAttribute("aria-label", "Send"); if (ico.dataset.icon !== "arrow-up") { ico.dataset.icon = "arrow-up"; inflateIcons(sendBtn); } sendBtn.disabled = !chatInput.value.trim(); } } const updateSendEnabled = refreshSendButton; // back-compat for existing call sites function autosize() { chatInput.style.height = "auto"; chatInput.style.height = Math.min(chatInput.scrollHeight, 140) + "px"; } chatInput.addEventListener("input", () => { autosize(); refreshSendButton(); }); chatInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); $("#chat-form").dispatchEvent(new Event("submit", { cancelable: true })); } }); function stopActiveTurn() { if (state.activeAbort) { try { state.activeAbort.abort(); } catch (_) {} state.activeAbort = null; } ttsReset(); if (voice.state === "busy" || voice.state === "speaking") setMicState("idle"); state.streaming = false; refreshSendButton(); } $("#chat-form").addEventListener("submit", async (e) => { e.preventDefault(); // If a turn is active, the button acts as a stop. if (state.streaming || voice.state === "speaking" || voice.state === "busy") { stopActiveTurn(); return; } // Prime the audio element on this user gesture (iOS Safari). unlockAudioOnce(); const text = chatInput.value.trim(); if (!text) return; chatInput.value = ""; autosize(); refreshSendButton(); await sendUserTurn(text, { useMicState: false }); chatInput.focus(); }); async function sendUserTurn(text, { useMicState = false } = {}) { text = (text || "").trim(); if (!text) { if (useMicState) setMicState("idle"); return; } ttsReset(); appendUserBubble(text); const ctx = newAssistantTurn(); if (useMicState) setMicState("busy", "Thinking…"); state.streaming = true; refreshSendButton(); const ttsCtx = isReadAloud ? ttsBegin(useMicState) : null; let full = null; try { full = await streamChat(text, { onToken: (_d, all) => { ctx.raw = all; renderMarkdown(ctx.bubble, ctx.raw); scrollChat(); if (ttsCtx) ttsFlushFrom(ttsCtx, all, false); }, onToolCall: (name, args) => { attachToolCall(ctx, name, args); scrollChat(); }, onToolResult: (name, prev) => attachToolResult(ctx, name, prev), onConversation: (payload) => { setActiveConversation(payload.id, payload.title || "New chat", { addLocal: true }); }, onError: (msg) => { ctx.raw += (ctx.raw ? "\n\n" : "") + "**Error:** " + msg; renderMarkdown(ctx.bubble, ctx.raw); }, }); } finally { state.streaming = false; refreshSendButton(); } ctx.bubble.classList.remove("streaming"); if (!ctx.raw) ctx.bubble.innerHTML = "(no response)"; // If user stopped mid-stream, drop any TTS in progress. if (full === null) { ttsReset(); if (useMicState) setMicState("idle"); } else if (ttsCtx && full) { ttsFlushFrom(ttsCtx, full, true); ttsCtx.done = true; ttsTryPlay(); if (useMicState && !tts.queue.length && !tts.playing) setMicState("idle"); } else if (useMicState) { setMicState("idle"); } if (state.activeConversationId) refreshConversationsList({ silent: true }); } /* ════════════════════════════════════════════════════════════════ Voice ════════════════════════════════════════════════════════════════ */ const voice = { recorder: null, chunks: [], state: "idle", // idle | recording | busy | speaking }; const LANGS = [ { code: "ml-IN", label: "മലയാളം" }, { code: "en-IN", label: "English" }, { code: "hi-IN", label: "हिन्दी" }, ]; $("#voice-lang").addEventListener("click", () => { const cur = $("#voice-lang").dataset.lang; const idx = LANGS.findIndex(l => l.code === cur); const next = LANGS[(idx + 1) % LANGS.length]; $("#voice-lang").dataset.lang = next.code; $("#voice-lang-label").textContent = next.label; }); let isReadAloud = true; const speakToggle = $("#speak-toggle"); speakToggle.addEventListener("click", () => { unlockAudioOnce(); isReadAloud = !isReadAloud; speakToggle.dataset.on = isReadAloud ? "true" : "false"; speakToggle.setAttribute("aria-pressed", String(isReadAloud)); speakToggle.querySelector("[data-icon]").dataset.icon = isReadAloud ? "volume-2" : "volume-x"; inflateIcons(speakToggle); if (!isReadAloud) { ttsReset(); if (voice.state === "speaking") setMicState("idle"); } }); function setMicState(s, statusText) { voice.state = s; const btn = $("#mic-btn"); btn.dataset.state = s; const hint = $("#mic-hint"); hint.dataset.state = s; hint.textContent = statusText || ({ idle: "press & hold the mic to speak · or type and press enter", recording: "i'm listening… release when you're done", busy: "thinking…", speaking: "speaking…", }[s] || ""); // swap icon const ico = btn.querySelector("[data-icon]"); if (s === "recording") ico.dataset.icon = "x"; else if (s === "speaking") ico.dataset.icon = "volume-2"; else if (s === "busy") ico.dataset.icon = "loader"; else ico.dataset.icon = "mic"; inflateIcons(btn); // Reflect state in the send/stop button. if (typeof refreshSendButton === "function") refreshSendButton(); } function showVoiceError(msg) { const el = $("#voice-error"); el.textContent = msg; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 8000); } /* ── Streaming TTS — Web Audio API engine ── * iOS Safari rejects