cliniq / frontend /index.html
karthikmulugu08's picture
Add OCR: image + scanned PDF support via tesseract + pdf2image
8b1bbb0 verified
Raw
History Blame Contribute Delete
18.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>ClinIQ — Clinical Document Assistant</title>
<link rel="stylesheet" href="/style.css"/>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
let client = null;
let docsReady = false;
async function gc() {
if (!client) client = await Client.connect(window.location.origin);
return client;
}
function setStatus(msg, type = "info") {
const el = document.getElementById("status");
el.innerHTML = msg;
el.className = "status " + type;
el.style.display = "block";
}
function appendBubble(role, html, id) {
const chat = document.getElementById("chat");
const wrap = document.createElement("div");
wrap.className = "bubble-wrap " + role;
if (id) wrap.id = id;
wrap.innerHTML = `<div class="bubble">${html}</div>`;
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
return wrap.querySelector(".bubble");
}
function typewrite(el, text, speed = 10) {
return new Promise(res => {
let i = 0;
el.textContent = "";
el.classList.add("typing");
const t = () => {
if (i < text.length) {
el.textContent += text[i++];
el.closest(".chat").scrollTop = el.closest(".chat").scrollHeight;
setTimeout(t, speed);
} else { el.classList.remove("typing"); res(); }
};
t();
});
}
function esc(t) {
return String(t||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function markBold(t) {
// Escape HTML first, then apply bold markdown
return esc(t).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
function renderTable(data) {
if (!data) return "";
if (Array.isArray(data) && data.length) {
const keys = Object.keys(data[0]);
return `<div class="tbl-wrap"><table class="tbl">
<thead><tr>${keys.map(k=>`<th>${k}</th>`).join("")}</tr></thead>
<tbody>${data.map(r=>`<tr>${keys.map(k=>`<td>${r[k]||"—"}</td>`).join("")}</tr>`).join("")}</tbody>
</table></div>`;
}
if (typeof data === "object") {
return `<div class="tbl-wrap"><table class="tbl"><tbody>
${Object.entries(data).map(([k,v])=>`<tr><th>${k}</th><td>${v||"—"}</td></tr>`).join("")}
</tbody></table></div>`;
}
return "";
}
// ── Safety panel ───────────────────────────────────────────────────────────
function showSafetyPanel(report) {
const panel = document.getElementById("safety-panel");
const body = document.getElementById("safety-body");
if (report.error) {
body.innerHTML = `<p class="safety-note">${report.error}</p>`;
panel.className = "safety-panel review";
document.getElementById("safety-status-icon").textContent = "—";
document.getElementById("safety-status-label").textContent = "Not available";
panel.style.display = "block";
return;
}
const statusMap = {
SAFE: { cls: "safe", label: "All Clear" },
REVIEW_NEEDED: { cls: "review", label: "Review Needed" },
URGENT: { cls: "danger", label: "Urgent — Action Required" },
};
const s = statusMap[report.overall_safety] || statusMap.REVIEW_NEEDED;
const alertsHtml = (report.alerts || []).map(a => {
const cls = a.level === "DANGER" ? "alert-danger"
: a.level === "WARNING" ? "alert-warn" : "alert-info";
const tag = a.level === "DANGER" ? "DANGER"
: a.level === "WARNING" ? "WARNING" : "INFO";
return `<div class="safety-alert ${cls}">
<div class="alert-title"><span class="alert-tag">${tag}</span> ${a.title}</div>
<div class="alert-detail">${a.detail}</div>
<div class="alert-rec"><strong>Action:</strong> ${a.recommendation}</div>
</div>`;
}).join("");
const medsHtml = report.medications?.length
? `<details class="extracted-details"><summary>${report.medications.length} Medication(s) extracted</summary>
${renderTable(report.medications)}</details>` : "";
const allergyHtml = report.allergies?.length
? `<details class="extracted-details"><summary>${report.allergies.length} Allergy/Allergies extracted</summary>
${renderTable(report.allergies)}</details>` : "";
body.innerHTML = alertsHtml + medsHtml + allergyHtml
|| `<p class="no-alerts">No safety concerns found.</p>`;
panel.className = `safety-panel ${s.cls}`;
document.getElementById("safety-status-icon").textContent = "";
document.getElementById("safety-status-label").textContent = s.label;
panel.style.display = "block";
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ── Agent trace ────────────────────────────────────────────────────────────
function clearTrace() {
document.getElementById("trace-steps").innerHTML = "";
document.getElementById("trace-url-link").style.display = "none";
document.getElementById("excerpts-list").innerHTML = "";
}
function addTraceStep(step) {
const ol = document.getElementById("trace-steps");
const li = document.createElement("li");
li.className = "trace-step entering";
li.innerHTML = `
<div class="step-body">
<span class="step-name">${step.step}</span>
<span class="step-detail">${markBold(step.detail)}</span>
${step.sub_queries ? `<ul class="sub-qs">${step.sub_queries.map(q=>`<li>"${esc(q)}"</li>`).join("")}</ul>` : ""}
${step.sources ? `<div class="step-srcs">${step.sources.map(s=>`<span class="src-chip">${esc(s)}</span>`).join("")}</div>` : ""}
<span class="step-ms">${step.ms}ms</span>
</div>`;
ol.appendChild(li);
requestAnimationFrame(() => li.classList.remove("entering"));
}
function setExcerpts(chunks) {
document.getElementById("excerpts-list").innerHTML = (chunks || []).map(c =>
`<div class="excerpt-card">
<div class="excerpt-src">${c.source}</div>
<div class="excerpt-txt">${c.excerpt}...</div>
</div>`).join("");
}
// ── Doc loading ────────────────────────────────────────────────────────────
async function runSafetyCheck() {
document.getElementById("safety-panel").style.display = "none";
document.getElementById("safety-loading").style.display = "flex";
try {
const c = await gc();
const res = await c.predict("/safety_check", {});
const report = JSON.parse(res.data[0]);
showSafetyPanel(report);
} catch(e) {
showSafetyPanel({ error: "Safety check unavailable: " + e.message });
} finally {
document.getElementById("safety-loading").style.display = "none";
}
}
function onDocsLoaded(names) {
docsReady = true;
document.getElementById("ask-input").disabled = false;
document.getElementById("ask-btn").disabled = false;
document.querySelectorAll(".tpl-btn").forEach(b => b.disabled = false);
document.getElementById("docs-ready").style.display = "flex";
document.getElementById("doc-names").textContent = names.join(" · ");
runSafetyCheck();
}
document.getElementById("load-samples").addEventListener("click", async () => {
setStatus("Loading demo documents...", "info");
try {
const c = await gc();
const res = await c.predict("/load_samples", {});
const d = JSON.parse(res.data[0]);
if (d.status === "ok") {
setStatus(d.files + " document(s) loaded — " + d.chunks + " chunks ready", "success");
onDocsLoaded(d.names || ["demo docs"]);
} else setStatus("Error: " + d.message, "error");
} catch(e) { setStatus("Error: " + e.message, "error"); }
});
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("file-input");
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
dropzone.addEventListener("dragleave", ()=> dropzone.classList.remove("drag"));
dropzone.addEventListener("drop", e => { e.preventDefault(); dropzone.classList.remove("drag"); handleFiles(e.dataTransfer.files); });
fileInput.addEventListener("change", () => handleFiles(fileInput.files));
async function handleFiles(files) {
if (!files.length) return;
setStatus("Indexing " + files.length + " file(s)...", "info");
const items = [];
for (const f of files) {
const buf = await f.arrayBuffer();
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
items.push({ name: f.name, content_b64: b64 });
}
try {
const c = await gc();
const res = await c.predict("/ingest", { files_json: JSON.stringify(items) });
const d = JSON.parse(res.data[0]);
if (d.status === "ok") {
setStatus(d.files + " file(s) indexed — " + d.chunks + " chunks", "success");
onDocsLoaded(d.names || items.map(i => i.name));
} else setStatus("Error: " + (d.message || "Unknown error"), "error");
} catch(e) { setStatus("Error: " + e.message, "error"); }
}
// ── Ask ────────────────────────────────────────────────────────────────────
async function ask(question) {
if (!question.trim() || !docsReady) return;
const inp = document.getElementById("ask-input");
inp.value = ""; inp.disabled = true;
document.getElementById("ask-btn").disabled = true;
clearTrace();
appendBubble("user", question.replace(/</g,"&lt;"));
const thinkId = "think-" + Date.now();
appendBubble("bot", '<span class="dots"><span></span><span></span><span></span></span>', thinkId);
let finalAnswer = "", structuredData = null, chunks = [];
try {
const c = await gc();
for await (const msg of c.submit("/ask_stream", { question })) {
if (!msg.data) continue;
const ev = JSON.parse(msg.data[0]);
if (ev.type === "trace_step") addTraceStep(ev.step);
else if (ev.type === "answer") { finalAnswer = ev.answer; structuredData = ev.structured_data; chunks = ev.chunks || []; }
else if (ev.type === "done" && ev.trace_url) {
const lnk = document.getElementById("trace-url-link");
lnk.href = ev.trace_url; lnk.style.display = "inline-flex";
}
else if (ev.type === "error") finalAnswer = ev.message;
}
} catch(_) {
try {
const c2 = await gc();
const res = await c2.predict("/ask", { question });
const d = JSON.parse(res.data[0]);
if (d.error) { finalAnswer = d.error; }
else { finalAnswer = d.answer || ""; structuredData = d.structured_data; chunks = d.chunks || []; (d.trace||[]).forEach(addTraceStep); }
} catch(e2) { finalAnswer = e2.message; }
}
const wrap = document.getElementById(thinkId);
if (wrap) {
const b = wrap.querySelector(".bubble");
const tbl = structuredData ? renderTable(structuredData) : "";
// If structured data rendered a table, suppress the raw JSON text answer
const displayText = (tbl && structuredData) ? "" : finalAnswer;
b.innerHTML = `<div class="answer-txt"></div>${tbl}`;
if (displayText) await typewrite(b.querySelector(".answer-txt"), displayText);
}
setExcerpts(chunks);
inp.disabled = false;
document.getElementById("ask-btn").disabled = false;
inp.focus();
}
document.getElementById("ask-btn").addEventListener("click",
() => ask(document.getElementById("ask-input").value.trim()));
document.getElementById("ask-input").addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(e.target.value.trim()); }
});
document.querySelectorAll(".tpl-btn").forEach(btn =>
btn.addEventListener("click", () => { document.getElementById("ask-input").value = btn.dataset.q; ask(btn.dataset.q); }));
document.getElementById("clear-btn").addEventListener("click", () => {
document.getElementById("chat").innerHTML =
`<div class="welcome"><div class="welcome-logo">&#x1FA7A;</div>
<p>Load patient documents, then ask any question.<br/>
<em>Try: "Do any medications interact with the patient's allergies?"</em></p></div>`;
clearTrace();
});
document.getElementById("theme-btn").addEventListener("click", () => {
document.documentElement.classList.toggle("dark");
document.getElementById("theme-btn").textContent =
document.documentElement.classList.contains("dark") ? "Light" : "Dark";
});
</script>
</head>
<body>
<header>
<div class="hdr">
<div class="logo">
<span class="logo-icon">&#x1FA7A;</span>
<div>
<h1>ClinIQ</h1>
<p class="tagline">On-device clinical document assistant · Qwen2.5-3B · llama.cpp · Modal</p>
</div>
</div>
<div class="hdr-right">
<div class="badges">
<span class="badge b-purple">Tiny Titan 3B</span>
<span class="badge b-teal">llama.cpp</span>
<span class="badge b-blue">Modal A10G</span>
<span class="badge b-gold">Build Small 2026</span>
</div>
<button id="theme-btn" class="icon-btn">Dark</button>
</div>
</div>
</header>
<div class="privacy-bar">
Privacy guarantee: Documents are indexed locally. Only relevant excerpts — never full records — reach the GPU.
</div>
<main>
<aside class="sidebar">
<div class="card">
<h2>Documents</h2>
<div id="dropzone" class="dropzone">
<span class="drop-icon">&#x1F4C4;</span>
<span class="drop-txt">Drop PDFs, TXT, or Images here<br/><small>or click to browse</small></span>
<input type="file" id="file-input" multiple accept=".pdf,.txt,.md,.jpg,.jpeg,.png,.tiff,.tif,.bmp,.webp" hidden/>
</div>
<button id="load-samples" class="btn secondary full">Load Demo Patient Docs</button>
<div id="status" class="status info">Upload documents or load demo docs to begin.</div>
<div id="docs-ready" class="docs-ok" style="display:none">
<span class="pulse-dot"></span>
<span id="doc-names" class="doc-names-txt">Documents loaded</span>
</div>
</div>
<div class="card safety-card-wrap">
<div class="safety-card-header">
<h2>Drug Safety Check</h2>
<span class="safety-auto-badge">Auto on upload</span>
</div>
<p class="card-hint">Proactive drug-allergy and interaction alerts — no question needed.</p>
<div id="safety-loading" class="safety-loading" style="display:none">
<div class="spinner"></div>
<span>Analysing medications and allergies...</span>
</div>
<div id="safety-panel" class="safety-panel" style="display:none">
<div class="safety-status-bar">
<span id="safety-status-icon"></span>
<span id="safety-status-label">All Clear</span>
</div>
<div id="safety-body"></div>
</div>
</div>
<div class="card">
<h2>Smart Templates</h2>
<div class="templates">
<button class="tpl-btn" disabled data-q="List all medications with doses and frequency">Medication List</button>
<button class="tpl-btn" disabled data-q="List all allergies and reactions">Allergy Summary</button>
<button class="tpl-btn" disabled data-q="What are all the diagnoses?">Diagnoses</button>
<button class="tpl-btn" disabled data-q="What follow-up appointments are scheduled?">Follow-up Plan</button>
<button class="tpl-btn" disabled data-q="Do any medications interact with the patient's allergies or other medications?">Drug Interactions</button>
<button class="tpl-btn" disabled data-q="Compare the medications listed in each document — did anything change?">Compare Across Visits</button>
</div>
</div>
</aside>
<div class="right-col">
<div class="card chat-card">
<div class="chat-hdr">
<h2>Clinical Q&amp;A</h2>
<button id="clear-btn" class="icon-btn sm">Clear</button>
</div>
<div id="chat" class="chat">
<div class="welcome">
<div class="welcome-logo">&#x1FA7A;</div>
<p>Load patient documents, then ask any question.<br/>
<em>Try: "Do any medications interact with the patient's allergies?"</em></p>
</div>
</div>
<div class="input-row">
<textarea id="ask-input" rows="2" disabled
placeholder="Ask a clinical question... (Enter to send)"></textarea>
<button id="ask-btn" class="btn primary ask-btn" disabled>Ask</button>
</div>
</div>
<div class="card trace-card">
<div class="trace-hdr">
<h2>Agent Trace</h2>
<a id="trace-url-link" href="#" target="_blank" class="share-btn" style="display:none">View on HF Hub</a>
</div>
<ol id="trace-steps" class="trace-steps"></ol>
<h3>Retrieved Excerpts</h3>
<div id="excerpts-list"></div>
</div>
</div>
</main>
<footer>
Built for <strong>Gradio Build Small Hackathon 2026</strong> by
<a href="https://huggingface.co/karthikmulugu08" target="_blank">Karthik Mulugu</a> ·
<a href="https://huggingface.co/Qwen/Qwen2.5-3B-Instruct" target="_blank">Qwen2.5-3B-Instruct</a>
via <a href="https://modal.com" target="_blank">Modal</a> (llama.cpp Q4_K_M) ·
<a href="https://huggingface.co/datasets/karthikmulugu08/cliniq-traces" target="_blank">Trace Dataset</a>
</footer>
</body>
</html>