docmind / write_html.py
mnoorchenar's picture
Update 2026-03-22 22:02:58
4f33d7e
import pathlib
HTML = '''\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>DocMind β€” AI Document Research</title>
<style>
/* ══════════════════════════════════════════
THEME VARIABLES
══════════════════════════════════════════ */
:root{
/* LIGHT theme β€” default */
color-scheme:light;
--bg:#eef1f8;--surface:#ffffff;--card:#ffffff;--card2:#eaf0fb;
--border:#dde2f0;--border2:#c6cedf;
--text:#151829;--sub:#434970;--muted:#7882a0;
--accent:#4a7cf7;--accent2:#2d5fe0;
--green:#15924f;--red:#c93c3c;--teal:#0891b2;--gold:#c97a06;--purple:#6d28d9;
/* surface overlays */
--inp-bg:rgba(0,0,0,.025);--hover-bg:rgba(0,0,0,.04);
--tab-bg:rgba(0,0,0,.03);--trace-bg:rgba(0,0,0,.03);
--shadow-sm:rgba(0,0,0,.08);--shadow-lg:rgba(0,0,0,.13);
--step-border:rgba(0,0,0,.05);--menu-shadow:rgba(0,0,0,.14);
}
:root.dark{
color-scheme:dark;
--bg:#0d0f1a;--surface:#13161f;--card:#181c27;--card2:#1e2230;
--border:#252836;--border2:#2e3244;
--text:#e8eaf2;--sub:#b0b8cc;--muted:#7880a0;
--accent:#5b8ff9;--accent2:#3a6ee8;
--green:#22d47a;--red:#f05c5c;--teal:#29c6d4;--gold:#f5a623;--purple:#a78bfa;
--inp-bg:rgba(255,255,255,.04);--hover-bg:rgba(255,255,255,.05);
--tab-bg:rgba(255,255,255,.03);--trace-bg:rgba(0,0,0,.22);
--shadow-sm:rgba(0,0,0,.3);--shadow-lg:rgba(0,0,0,.5);
--step-border:rgba(255,255,255,.04);--menu-shadow:rgba(0,0,0,.5);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;
transition:background .25s,color .25s}
/* ── ANIMATIONS ── */
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
@keyframes nodeGlow{
0%,100%{box-shadow:0 0 0 2px rgba(74,124,247,0)}
50%{box-shadow:0 0 0 2px rgba(74,124,247,.5),0 0 20px rgba(74,124,247,.25)}
}
@keyframes nodeGlowGreen{
0%,100%{box-shadow:0 0 0 2px rgba(21,146,79,0)}
50%{box-shadow:0 0 0 2px rgba(21,146,79,.5),0 0 20px rgba(21,146,79,.25)}
}
@keyframes nodeGlowGold{
0%,100%{box-shadow:0 0 0 2px rgba(201,122,6,0)}
50%{box-shadow:0 0 0 2px rgba(201,122,6,.5),0 0 20px rgba(201,122,6,.25)}
}
/* flowing dot along the arrow line */
@keyframes flowDot{
0%{left:2px;opacity:0}
15%{opacity:1}
85%{opacity:1}
100%{left:calc(100% - 8px);opacity:0}
}
/* ── HEADER ── */
header{background:var(--surface);border-bottom:1px solid var(--border);
padding:0 20px;height:52px;display:flex;align-items:center;gap:10px;
position:sticky;top:0;z-index:100;transition:background .25s,border-color .25s}
.logo{display:flex;align-items:center;gap:8px;text-decoration:none;flex-shrink:0}
.logo-icon{width:28px;height:28px;background:linear-gradient(135deg,var(--accent),var(--purple));
border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:14px}
.logo-text{font-size:.95rem;font-weight:800;color:var(--text);letter-spacing:-.3px}
.logo-text span{color:var(--accent)}
.hdr-stack{display:flex;gap:5px;align-items:center;margin-left:8px}
.hs{font-size:.62rem;font-weight:700;padding:2px 8px;border-radius:12px;border:1px solid;
white-space:nowrap;letter-spacing:.02em}
.hs-lg{background:rgba(74,124,247,.1);border-color:rgba(74,124,247,.3);color:var(--accent)}
.hs-lc{background:rgba(201,122,6,.1);border-color:rgba(201,122,6,.3);color:var(--gold)}
.hs-fb{background:rgba(8,145,178,.1);border-color:rgba(8,145,178,.3);color:var(--teal)}
#hdr-src{margin-left:auto;font-size:.68rem;padding:3px 10px;border-radius:14px;flex-shrink:0;
background:rgba(120,128,160,.08);border:1px solid var(--border);color:var(--muted)}
#hdr-src.loaded{background:rgba(21,146,79,.08);border-color:rgba(21,146,79,.3);color:var(--green)}
/* theme toggle button */
.theme-btn{margin-left:8px;background:var(--card2);border:1px solid var(--border2);
border-radius:20px;padding:4px 10px;cursor:pointer;font-size:.75rem;
color:var(--sub);display:flex;align-items:center;gap:5px;
transition:all .2s;font-family:inherit;white-space:nowrap;flex-shrink:0}
.theme-btn:hover{border-color:var(--accent);color:var(--text)}
/* ── LAYOUT ── */
main{display:grid;grid-template-columns:290px 1fr;height:calc(100vh - 52px);overflow:hidden}
.panel{padding:18px 16px;overflow-y:auto;height:100%}
.panel-left{border-right:1px solid var(--border);background:var(--surface);
display:flex;flex-direction:column;gap:20px;transition:background .25s,border-color .25s}
.panel-right{background:var(--bg);transition:background .25s}
/* ── SECTION HEADERS ── */
.sec-head{display:flex;align-items:center;gap:7px;margin-bottom:12px}
.sec-icon{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;
justify-content:center;font-size:11px;flex-shrink:0}
.si-blue{background:rgba(74,124,247,.15)}
.si-purple{background:rgba(109,40,217,.15)}
.si-gold{background:rgba(201,122,6,.15)}
.sec-title{font-size:.67rem;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:var(--sub)}
/* ── TABS ── */
.tabs{display:flex;gap:2px;background:var(--tab-bg);border-radius:8px;
padding:3px;margin-bottom:14px;border:1px solid var(--border)}
.tab-btn{flex:1;background:none;border:none;color:var(--muted);font-size:.75rem;
font-weight:600;padding:6px 8px;border-radius:6px;cursor:pointer;
transition:all .15s;font-family:inherit;display:flex;align-items:center;
justify-content:center;gap:5px}
.tab-btn.active{background:var(--card2);color:var(--text);box-shadow:0 1px 5px var(--shadow-sm)}
/* ── DROPZONE ── */
.dropzone{border:2px dashed var(--border2);border-radius:10px;padding:22px 14px;
text-align:center;cursor:pointer;transition:all .22s;position:relative;overflow:hidden}
.dropzone::before{content:"";position:absolute;inset:0;opacity:0;
background:linear-gradient(90deg,transparent,rgba(74,124,247,.07),transparent);
background-size:200% 100%;transition:opacity .3s}
.dropzone:hover::before,.dropzone.drag-over::before{opacity:1;animation:shimmer 1.6s linear infinite}
.dropzone:hover,.dropzone.drag-over{border-color:var(--accent);background:rgba(74,124,247,.04)}
.dz-icon{font-size:1.8rem;margin-bottom:6px;display:block;line-height:1}
.dz-label{font-size:.8rem;color:var(--sub);margin-bottom:3px}
.dz-label strong{color:var(--accent)}
.dz-hint{font-size:.72rem;color:var(--muted)}
/* ── URL ── */
.url-row{display:flex;gap:8px;margin-bottom:6px}
input[type=url]{flex:1;background:var(--inp-bg);border:1px solid var(--border);
border-radius:7px;padding:8px 11px;color:var(--text);font-size:.82rem;
font-family:inherit;outline:none;transition:border-color .2s}
input[type=url]:focus{border-color:var(--accent)}
/* ── SOURCE CARD ── */
#source-card{display:none;background:rgba(21,146,79,.06);border:1px solid rgba(21,146,79,.22);
border-radius:9px;padding:10px 12px;animation:fadeUp .3s ease;margin-top:10px}
.sc-row{display:flex;align-items:center;gap:10px;margin-bottom:4px}
.sc-icon{font-size:1.3rem;flex-shrink:0}
.sc-name{font-size:.82rem;font-weight:700;color:var(--text);overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;max-width:190px}
.sc-meta{font-size:.72rem;color:var(--teal)}
.sc-ready{font-size:.72rem;color:var(--green);font-weight:600}
/* ── TECH STACK GRID ── */
.tech-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.tg{border-radius:7px;padding:7px 9px;border:1px solid;display:flex;
align-items:center;gap:6px;transition:.15s}
.tg:hover{transform:translateY(-1px)}
.tg-icon{font-size:.85rem;flex-shrink:0}
.tg-name{font-size:.69rem;font-weight:700;line-height:1.2}
.tg-sub{font-size:.59rem;color:var(--muted);line-height:1.2}
.tg-lg{background:rgba(74,124,247,.07);border-color:rgba(74,124,247,.2);color:var(--accent)}
.tg-lc{background:rgba(201,122,6,.07);border-color:rgba(201,122,6,.2);color:var(--gold)}
.tg-fb{background:rgba(8,145,178,.07);border-color:rgba(8,145,178,.2);color:var(--teal)}
.tg-emb{background:rgba(21,146,79,.07);border-color:rgba(21,146,79,.2);color:var(--green)}
.tg-fl{background:rgba(201,60,60,.07);border-color:rgba(201,60,60,.2);color:var(--red)}
.tg-dk{background:rgba(6,182,212,.07);border-color:rgba(6,182,212,.2);color:#0891b2}
/* ═══════════════════════════════════════════════
LIVE PIPELINE ARCHITECTURE DIAGRAM
═══════════════════════════════════════════════ */
.arch-card{background:var(--card);border:1px solid var(--border);border-radius:14px;
padding:18px;margin-bottom:16px;
box-shadow:0 2px 12px var(--shadow-sm);
transition:background .25s,border-color .25s}
.arch-card-hdr{display:flex;align-items:center;gap:8px;margin-bottom:16px}
.arch-legend{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.al-item{font-size:.61rem;color:var(--muted);display:flex;align-items:center;gap:4px;font-weight:600}
.al-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
/* rows */
.arch-ingest-row{display:flex;align-items:center;gap:0;margin-bottom:6px;flex-wrap:nowrap;
overflow-x:auto;padding-bottom:4px}
.arch-pipe-row{display:flex;align-items:center;gap:0;flex-wrap:nowrap;
overflow-x:auto;padding-bottom:4px}
.arch-row-label{font-size:.61rem;font-weight:800;text-transform:uppercase;
letter-spacing:.09em;color:var(--muted);margin-bottom:6px;
display:flex;align-items:center;gap:5px}
/* connector between rows */
.arch-connector{display:flex;align-items:center;gap:10px;margin:6px 0 8px 0;padding-left:2px}
.arch-conn-line{width:2px;height:22px;background:linear-gradient(to bottom,var(--teal),var(--accent));
margin-left:44px;border-radius:2px}
.arch-conn-label{font-size:.61rem;color:var(--muted);font-style:italic}
/* ── ARROW: proper CSS line + triangle arrowhead ── */
.arch-arr{
position:relative;
flex-shrink:0;
width:34px;height:2px;
background:var(--border2);
align-self:center;
margin:0 2px;
border-radius:1px;
transition:background .3s;
}
/* arrowhead triangle */
.arch-arr::before{
content:"";position:absolute;
right:-7px;top:-4px;
width:0;height:0;
border-top:5px solid transparent;
border-bottom:5px solid transparent;
border-left:8px solid var(--border2);
transition:border-left-color .3s;
}
/* flowing dot */
.arch-arr::after{
content:"";
position:absolute;
top:-4px;left:2px;
width:8px;height:8px;
border-radius:50%;
background:var(--teal);
box-shadow:0 0 7px var(--teal);
opacity:0;
}
.arch-arr.arr-flowing{
background:var(--teal);
box-shadow:0 0 4px rgba(8,145,178,.4);
}
.arch-arr.arr-flowing::before{border-left-color:var(--teal);}
.arch-arr.arr-flowing::after{animation:flowDot .85s ease-in-out infinite;}
/* node base */
.arch-node{
border-radius:10px;padding:9px 11px;border:1.5px solid;text-align:center;
min-width:88px;transition:all .3s;position:relative;overflow:hidden;cursor:default;
background:var(--card);
flex-shrink:0;
}
/* subtle inner shine on hover */
.arch-node:hover{transform:translateY(-1px)}
.arch-node-icon{font-size:1.1rem;display:block;margin-bottom:3px;line-height:1}
.arch-node-name{font-size:.72rem;font-weight:800;color:var(--text);line-height:1.2}
.arch-node-sub{font-size:.6rem;color:var(--muted);line-height:1.3;margin-top:2px}
.arch-node-sub2{font-size:.56rem;color:var(--muted);line-height:1.3;margin-top:1px;
opacity:.75;font-style:italic}
/* node type colours */
.an-io {background:rgba(8,145,178,.06);border-color:rgba(8,145,178,.25)}
.an-chunk{background:rgba(201,122,6,.06);border-color:rgba(201,122,6,.25)}
.an-idx {background:rgba(8,145,178,.06);border-color:rgba(8,145,178,.25)}
.an-llm {background:rgba(74,124,247,.06);border-color:rgba(74,124,247,.25)}
.an-local{background:rgba(21,146,79,.06);border-color:rgba(21,146,79,.25)}
.an-score{background:rgba(201,122,6,.06);border-color:rgba(201,122,6,.25)}
.an-out {background:rgba(109,40,217,.06);border-color:rgba(109,40,217,.25)}
/* ── node state: running ── */
.arch-node.running-llm{
border-width:2px;border-color:var(--accent);
animation:nodeGlow .9s ease-in-out infinite;
background:rgba(74,124,247,.1);}
.arch-node.running-local{
border-width:2px;border-color:var(--green);
animation:nodeGlowGreen .9s ease-in-out infinite;
background:rgba(21,146,79,.1);}
.arch-node.running-score,.arch-node.running-io,
.arch-node.running-chunk,.arch-node.running-idx{
border-width:2px;border-color:var(--gold);
animation:nodeGlowGold .9s ease-in-out infinite;
background:rgba(201,122,6,.1);}
/* ── node state: done ── */
.arch-node.node-done{opacity:.45;filter:saturate(.4)}
.arch-node.node-done-ok{
border-color:rgba(21,146,79,.6)!important;border-width:2px!important;
background:rgba(21,146,79,.06)!important;opacity:.9}
.arch-node.node-done-ok::after{
content:"βœ“";position:absolute;top:3px;right:5px;
font-size:.62rem;color:var(--green);font-weight:900}
.arch-node.node-err{
border-color:rgba(201,60,60,.55)!important;border-width:2px!important;
background:rgba(201,60,60,.06)!important}
/* ── QUESTION CARD ── */
.q-card{background:var(--card);border:1px solid var(--border);border-radius:10px;
padding:14px;margin-bottom:14px;
box-shadow:0 2px 8px var(--shadow-sm);
transition:background .25s,border-color .25s}
.q-label{font-size:.67rem;font-weight:700;color:var(--muted);text-transform:uppercase;
letter-spacing:.07em;margin-bottom:8px}
textarea{width:100%;background:var(--inp-bg);border:1px solid var(--border);
border-radius:7px;padding:9px 11px;color:var(--text);font-size:.84rem;
font-family:inherit;outline:none;resize:vertical;min-height:72px;
transition:border-color .2s;line-height:1.5}
textarea:focus{border-color:var(--accent)}
.q-footer{display:flex;align-items:center;gap:8px;margin-top:10px}
/* ── MODEL DROPDOWN ── */
.model-sel-wrap{position:relative}
.model-sel-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;
background:var(--card2);border:1.5px solid var(--border2);border-radius:7px;
cursor:pointer;font-size:.76rem;font-weight:600;color:var(--sub);
font-family:inherit;transition:all .15s;white-space:nowrap}
.model-sel-btn:hover{border-color:rgba(74,124,247,.5);color:var(--text)}
.msb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.msb-caret{font-size:.6rem;color:var(--muted);transition:transform .2s;line-height:1}
.model-sel-wrap.open .msb-caret{transform:rotate(180deg)}
.model-menu{display:none;position:absolute;bottom:calc(100% + 6px);left:0;
min-width:260px;background:var(--card);border:1px solid var(--border2);
border-radius:11px;padding:5px;z-index:300;
box-shadow:0 8px 32px var(--menu-shadow);animation:fadeUp .15s ease}
.model-sel-wrap.open .model-menu{display:block}
.mm-header{font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;
color:var(--muted);padding:6px 10px 4px}
.mm-item{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:7px;
cursor:pointer;transition:.15s;position:relative}
.mm-item:hover{background:var(--hover-bg)}
.mm-item.selected{background:rgba(74,124,247,.08)}
.mm-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
.mm-body{flex:1;min-width:0}
.mm-name{font-size:.8rem;font-weight:700;color:var(--text);margin-bottom:1px}
.mm-desc{font-size:.69rem;color:var(--muted)}
.mm-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0}
.mm-params{font-size:.65rem;font-weight:700;padding:1px 6px;border-radius:5px;
background:var(--hover-bg);color:var(--sub)}
.mm-speed{font-size:.65rem;color:var(--gold);letter-spacing:1px}
.mm-check{position:absolute;right:10px;top:50%;transform:translateY(-50%);
font-size:.75rem;color:var(--green);opacity:0;transition:opacity .15s}
.mm-item.selected .mm-check{opacity:1}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:7px;
border:none;font-size:.82rem;font-weight:600;cursor:pointer;
transition:all .15s;font-family:inherit;white-space:nowrap}
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;
box-shadow:0 2px 10px rgba(74,124,247,.3)}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(74,124,247,.4)}
.btn-primary:disabled{opacity:.45;cursor:default;transform:none;box-shadow:none}
.btn-sm{padding:6px 12px;font-size:.76rem}
.btn-ghost{background:var(--hover-bg);color:var(--sub);border:1px solid var(--border)}
.btn-ghost:hover{background:rgba(74,124,247,.07);color:var(--text)}
.spinner{width:13px;height:13px;border:2px solid rgba(255,255,255,.25);
border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
/* ── MESSAGES ── */
.msg{border-radius:7px;padding:8px 12px;font-size:.78rem;margin-top:8px;
line-height:1.5;animation:fadeUp .25s ease}
.msg-ok{background:rgba(21,146,79,.08);border:1px solid rgba(21,146,79,.22);color:var(--green)}
.msg-err{background:rgba(201,60,60,.08);border:1px solid rgba(201,60,60,.22);color:var(--red)}
.msg-info{color:var(--muted);font-size:.74rem;margin-top:6px}
#q-err{font-size:.76rem;color:var(--red)}
/* ── TRACE ── */
#trace-wrap{display:none;margin-bottom:14px;animation:fadeUp .2s ease}
.trace-hdr{display:flex;align-items:center;gap:8px;padding:8px 12px;
background:var(--card);border:1px solid var(--border);border-radius:9px 9px 0 0}
.trace-title{font-size:.7rem;font-weight:700;color:var(--muted);
text-transform:uppercase;letter-spacing:.06em}
#trace-log{background:var(--trace-bg);border:1px solid var(--border);border-top:none;
border-radius:0 0 9px 9px;padding:8px 10px;max-height:200px;overflow-y:auto}
.t-step{display:flex;align-items:flex-start;gap:8px;font-size:.75rem;
padding:5px 0;border-bottom:1px solid var(--step-border);
animation:fadeUp .2s ease}
.t-step:last-child{border-bottom:none}
.t-badge{font-size:.58rem;font-weight:800;text-transform:uppercase;
padding:2px 6px;border-radius:4px;flex-shrink:0;margin-top:1px;letter-spacing:.03em}
.b-planner {background:rgba(74,124,247,.15); color:var(--accent)}
.b-retriever{background:rgba(8,145,178,.15); color:var(--teal)}
.b-grader {background:rgba(201,122,6,.15); color:var(--gold)}
.b-generator{background:rgba(21,146,79,.15); color:var(--green)}
.b-critic {background:rgba(109,40,217,.15); color:var(--purple)}
.b-error {background:rgba(201,60,60,.15); color:var(--red)}
.t-msg{flex:1;color:var(--sub);line-height:1.45}
.t-lat{color:var(--muted);font-size:.62rem;white-space:nowrap;margin-left:4px;opacity:.7}
/* ── ANSWER ── */
#answer-wrap{display:none;background:var(--card);border:1px solid var(--border);
border-radius:10px;overflow:hidden;animation:fadeUp .35s ease;
box-shadow:0 2px 12px var(--shadow-sm)}
.ans-header{display:flex;align-items:center;gap:10px;padding:12px 16px;
border-bottom:1px solid var(--border)}
.ans-label{font-size:.68rem;font-weight:800;text-transform:uppercase;
letter-spacing:.07em;color:var(--muted)}
.ans-actions{margin-left:auto}
#answer-text{padding:16px;font-size:.88rem;line-height:1.75;
white-space:pre-wrap;word-break:break-word;color:var(--text)}
#verdict{margin:0 16px 12px;font-size:.72rem;font-weight:700;
padding:4px 12px;border-radius:5px;display:inline-block}
.v-ok {background:rgba(21,146,79,.1); color:var(--green)}
.v-warn{background:rgba(201,122,6,.1); color:var(--gold)}
@media(max-width:800px){
main{grid-template-columns:1fr;height:auto}
.panel-left{border-right:none;border-bottom:1px solid var(--border)}
.hdr-stack{display:none}
}
</style>
</head>
<body>
<!-- ══════════════════════════════════════════
HEADER
══════════════════════════════════════════ -->
<header>
<a class="logo" href="#">
<div class="logo-icon">&#129504;</div>
<span class="logo-text">Doc<span>Mind</span></span>
</a>
<div class="hdr-stack">
<span class="hs hs-lg">&#128279; LangGraph</span>
<span class="hs hs-lc">&#9961; LangChain LCEL</span>
<span class="hs hs-fb">&#128451; FAISS+BM25+RRF</span>
</div>
<span id="hdr-src">No source loaded</span>
<button class="theme-btn" id="theme-btn" onclick="toggleTheme()">
<span id="theme-icon">&#127769;</span>
<span id="theme-label">Dark</span>
</button>
</header>
<main>
<!-- ══════════════════════════════════════════
LEFT PANEL
══════════════════════════════════════════ -->
<div class="panel panel-left">
<!-- KNOWLEDGE BASE -->
<div>
<div class="sec-head">
<div class="sec-icon si-blue">&#128218;</div>
<span class="sec-title">Knowledge Base</span>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab(this,\'pdf\')">&#128196;&ensp;Upload PDF</button>
<button class="tab-btn" onclick="switchTab(this,\'url\')">&#127760;&ensp;Paste URL</button>
</div>
<div id="tab-pdf">
<div class="dropzone" id="dz"
onclick="document.getElementById(\'fi\').click()"
ondragover="dg(event,true)" ondragleave="dg(event,false)" ondrop="dp(event)">
<span class="dz-icon">&#128196;</span>
<div class="dz-label"><strong>Click to browse</strong> or drag &amp; drop</div>
<div class="dz-hint">PDF only &middot; max 10 MB</div>
</div>
<input type="file" id="fi" accept=".pdf" style="display:none" onchange="fc(event)"/>
<div id="pdf-msg"></div>
</div>
<div id="tab-url" style="display:none">
<div class="url-row">
<input type="url" id="url-inp" placeholder="https://en.wikipedia.org/wiki/..."
onkeydown="if(event.key===\'Enter\')fetchURL()"/>
<button class="btn btn-primary btn-sm" id="url-btn" onclick="fetchURL()">Fetch</button>
</div>
<div class="msg-info" style="margin-bottom:4px">Wikipedia, gov sites &amp; docs work best.</div>
<div id="url-msg"></div>
</div>
<div id="source-card">
<div class="sc-row">
<div class="sc-icon" id="sc-icon">&#128196;</div>
<div>
<div class="sc-name" id="source-name"></div>
<div class="sc-meta" id="source-chunks"></div>
</div>
</div>
<div class="sc-ready">&#10003;&ensp;Ready for questions</div>
</div>
</div>
<!-- TECH STACK -->
<div>
<div class="sec-head">
<div class="sec-icon si-purple">&#128736;</div>
<span class="sec-title">Powered By</span>
</div>
<div class="tech-grid">
<div class="tg tg-lg">
<span class="tg-icon">&#128279;</span>
<div><div class="tg-name">LangGraph 0.2</div><div class="tg-sub">StateGraph &middot; 5 nodes</div></div>
</div>
<div class="tg tg-lc">
<span class="tg-icon">&#9961;</span>
<div><div class="tg-name">LangChain LCEL</div><div class="tg-sub">prompt | llm | parser</div></div>
</div>
<div class="tg tg-fb">
<span class="tg-icon">&#128451;</span>
<div><div class="tg-name">FAISS + BM25</div><div class="tg-sub">RRF hybrid retrieval</div></div>
</div>
<div class="tg tg-emb">
<span class="tg-icon">&#129688;</span>
<div><div class="tg-name">HF Embeddings</div><div class="tg-sub">bge-small-en-v1.5</div></div>
</div>
<div class="tg tg-fl">
<span class="tg-icon">&#127381;</span>
<div><div class="tg-name">Flask 3.1</div><div class="tg-sub">+ Gunicorn WSGI</div></div>
</div>
<div class="tg tg-dk">
<span class="tg-icon">&#128058;</span>
<div><div class="tg-name">Docker</div><div class="tg-sub">HuggingFace Spaces</div></div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════
RIGHT PANEL
══════════════════════════════════════════ -->
<div class="panel panel-right">
<!-- ── LIVE PIPELINE ARCHITECTURE DIAGRAM ── -->
<div class="arch-card">
<div class="arch-card-hdr">
<div class="sec-icon si-blue" style="width:20px;height:20px;font-size:10px">&#128202;</div>
<span class="sec-title">Live Pipeline</span>
<div class="arch-legend">
<span class="al-item"><span class="al-dot" style="background:var(--accent)"></span>LLM Agent</span>
<span class="al-item"><span class="al-dot" style="background:var(--green)"></span>Local</span>
<span class="al-item"><span class="al-dot" style="background:var(--gold)"></span>Score &amp; I/O</span>
</div>
</div>
<!-- Ingestion row -->
<div class="arch-row-label">&#128190; Ingestion</div>
<div class="arch-ingest-row">
<div class="arch-node an-io" id="anode-source">
<span class="arch-node-icon">&#128196;</span>
<div class="arch-node-name">Source</div>
<div class="arch-node-sub">PDF &middot; URL</div>
<div class="arch-node-sub2">PyPDF / BeautifulSoup</div>
</div>
<div class="arch-arr" id="iarr-0"></div>
<div class="arch-node an-chunk" id="anode-chunker">
<span class="arch-node-icon">&#9986;</span>
<div class="arch-node-name">Chunker</div>
<div class="arch-node-sub">RecursiveTextSplitter</div>
<div class="arch-node-sub2">1500 ch &middot; 200 overlap</div>
</div>
<div class="arch-arr" id="iarr-1"></div>
<div class="arch-node an-idx" id="anode-index">
<span class="arch-node-icon">&#128451;</span>
<div class="arch-node-name">Hybrid Index</div>
<div class="arch-node-sub">FAISS + BM25</div>
<div class="arch-node-sub2">768-dim &middot; RRF k=60</div>
</div>
<span style="font-size:.62rem;color:var(--teal);margin-left:10px;align-self:center;white-space:nowrap">
&#8593; feeds Retriever
</span>
</div>
<!-- Connector -->
<div class="arch-connector">
<div class="arch-conn-line"></div>
<span class="arch-conn-label">LangGraph StateGraph β€” research pipeline</span>
</div>
<!-- Agent pipeline row -->
<div class="arch-row-label">&#129302; Research Agents</div>
<div class="arch-pipe-row">
<div class="arch-node an-llm" data-arch="planner" id="anode-planner">
<span class="arch-node-icon">&#127919;</span>
<div class="arch-node-name">Planner</div>
<div class="arch-node-sub">LLM &middot; temp 0.3</div>
<div class="arch-node-sub2">Research plan &middot; 200 tok</div>
</div>
<div class="arch-arr" id="parr-0"></div>
<div class="arch-node an-local" data-arch="retriever" id="anode-retriever">
<span class="arch-node-icon">&#128269;</span>
<div class="arch-node-name">Retriever</div>
<div class="arch-node-sub">FAISS + BM25</div>
<div class="arch-node-sub2">top-k 5 &middot; RRF fusion</div>
</div>
<div class="arch-arr" id="parr-1"></div>
<div class="arch-node an-score" data-arch="grader" id="anode-grader">
<span class="arch-node-icon">&#9878;</span>
<div class="arch-node-name">Grader</div>
<div class="arch-node-sub">Score &middot; ~1 ms</div>
<div class="arch-node-sub2">0.7Γ—vec + 0.3Γ—kw</div>
</div>
<div class="arch-arr" id="parr-2"></div>
<div class="arch-node an-llm" data-arch="generator" id="anode-generator">
<span class="arch-node-icon">&#9997;</span>
<div class="arch-node-name">Generator</div>
<div class="arch-node-sub">LLM &middot; temp 0.4</div>
<div class="arch-node-sub2">Cited answer &middot; 512 tok</div>
</div>
<div class="arch-arr" id="parr-3"></div>
<div class="arch-node an-llm" data-arch="critic" id="anode-critic">
<span class="arch-node-icon">&#128300;</span>
<div class="arch-node-name">Critic</div>
<div class="arch-node-sub">LLM &middot; temp 0.1</div>
<div class="arch-node-sub2">APPROVED / NEEDS&#8209;REVIEW</div>
</div>
<div class="arch-arr" id="parr-4"></div>
<div class="arch-node an-out" id="anode-answer">
<span class="arch-node-icon">&#128203;</span>
<div class="arch-node-name">Answer</div>
<div class="arch-node-sub">Cited &middot; Verified</div>
<div class="arch-node-sub2">inline [Source, p.N]</div>
</div>
</div>
</div>
<!-- ── RESEARCH QUESTION ── -->
<div class="sec-head">
<div class="sec-icon si-purple">&#128269;</div>
<span class="sec-title">Research Query</span>
</div>
<div class="q-card">
<div class="q-label">Your Question</div>
<textarea id="q-inp" rows="3"
placeholder="Ask anything about the loaded document or URL&#10;Press Enter to submit &middot; Shift+Enter for new line"
onkeydown="qk(event)"></textarea>
<div class="q-footer">
<button class="btn btn-primary" id="ask-btn" onclick="ask()">&#9889;&ensp;Ask</button>
<!-- ── MODEL DROPDOWN ── -->
<div class="model-sel-wrap" id="model-sel-wrap">
<button class="model-sel-btn" id="model-sel-btn" onclick="toggleMenu(event)">
<span class="msb-dot" id="msb-dot" style="background:#4a7cf7"></span>
<span id="msb-label">Qwen 2.5&#xB7;7B</span>
<span class="msb-caret">&#9660;</span>
</button>
<div class="model-menu" id="model-menu">
<div class="mm-header">Select LLM Model</div>
<div class="mm-item selected" data-key="qwen-7b" data-color="#4a7cf7"
data-label="Qwen 2.5&#xB7;7B" onclick="pickModel(this)">
<span class="mm-dot" style="background:#4a7cf7"></span>
<div class="mm-body">
<div class="mm-name">Qwen 2.5 &middot; 7B</div>
<div class="mm-desc">Default &middot; fast &amp; free</div>
</div>
<div class="mm-right">
<span class="mm-params">7B</span>
<span class="mm-speed">&#9889;&#9889;&#9889;</span>
</div>
<span class="mm-check">&#10003;</span>
</div>
<div class="mm-item" data-key="mistral-nemo" data-color="#6d28d9"
data-label="Mistral Nemo&#xB7;12B" onclick="pickModel(this)">
<span class="mm-dot" style="background:#6d28d9"></span>
<div class="mm-body">
<div class="mm-name">Mistral Nemo &middot; 12B</div>
<div class="mm-desc">Stronger reasoning</div>
</div>
<div class="mm-right">
<span class="mm-params">12B</span>
<span class="mm-speed">&#9889;&#9889;</span>
</div>
<span class="mm-check">&#10003;</span>
</div>
<div class="mm-item" data-key="phi-3-mini" data-color="#15924f"
data-label="Phi-3.5 Mini&#xB7;3.8B" onclick="pickModel(this)">
<span class="mm-dot" style="background:#15924f"></span>
<div class="mm-body">
<div class="mm-name">Phi-3.5 Mini &middot; 3.8B</div>
<div class="mm-desc">Ultra-fast &amp; focused</div>
</div>
<div class="mm-right">
<span class="mm-params">3.8B</span>
<span class="mm-speed">&#9889;&#9889;&#9889;</span>
</div>
<span class="mm-check">&#10003;</span>
</div>
</div>
</div>
<span id="q-err"></span>
</div>
</div>
<!-- ── TRACE LOG ── -->
<div id="trace-wrap">
<div class="trace-hdr">
<span class="trace-title">&#128240;&ensp;Agent Trace</span>
</div>
<div id="trace-log"></div>
</div>
<!-- ── ANSWER ── -->
<div id="answer-wrap">
<div class="ans-header">
<span class="ans-label">&#129504;&ensp;Answer</span>
<div class="ans-actions">
<button class="btn btn-ghost btn-sm" onclick="copyAns(this)">&#128203;&ensp;Copy</button>
</div>
</div>
<div id="answer-text"></div>
<div id="verdict"></div>
</div>
</div>
</main>
<script>
const esc=s=>String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
let pollTimer=null,seen=0,selectedModel="qwen-7b";
/* ════════════════════════════════════════════
THEME TOGGLE
════════════════════════════════════════════ */
(function initTheme(){
const saved=localStorage.getItem("dm-theme");
if(saved==="dark"){
document.documentElement.classList.add("dark");
document.getElementById("theme-icon").textContent="β˜€οΈ";
document.getElementById("theme-label").textContent="Light";
}
})();
function toggleTheme(){
const html=document.documentElement;
const isDark=html.classList.toggle("dark");
document.getElementById("theme-icon").textContent=isDark?"β˜€οΈ":"πŸŒ™";
document.getElementById("theme-label").textContent=isDark?"Light":"Dark";
localStorage.setItem("dm-theme",isDark?"dark":"light");
}
/* ── Tab switching ──────────────────────────────────────────────────────── */
function switchTab(btn,name){
document.querySelectorAll(".tab-btn").forEach(b=>b.classList.remove("active"));
btn.classList.add("active");
document.getElementById("tab-pdf").style.display=name==="pdf"?"":"none";
document.getElementById("tab-url").style.display=name==="url"?"":"none";
}
/* ── Drag & drop ────────────────────────────────────────────────────────── */
function dg(e,over){e.preventDefault();document.getElementById("dz").classList[over?"add":"remove"]("drag-over");}
function dp(e){e.preventDefault();document.getElementById("dz").classList.remove("drag-over");const f=e.dataTransfer.files[0];if(f)up(f);}
function fc(e){if(e.target.files[0])up(e.target.files[0]);}
/* ════════════════════════════════════════════
INGESTION PIPELINE ANIMATION
════════════════════════════════════════════ */
const INGEST_NODES=[
{id:"anode-source", cls:"running-io"},
{id:"anode-chunker", cls:"running-chunk"},
{id:"anode-index", cls:"running-idx"},
];
const INGEST_ARRS=["iarr-0","iarr-1"];
let _ingestTimers=[];
function ingestStart(){
ingestReset();
INGEST_NODES.forEach((n,i)=>{
_ingestTimers.push(setTimeout(()=>{
if(i>0){
const prev=document.getElementById(INGEST_NODES[i-1].id);
prev.classList.remove(INGEST_NODES[i-1].cls);
prev.classList.add("node-done");
if(i-1<INGEST_ARRS.length)
document.getElementById(INGEST_ARRS[i-1]).classList.remove("arr-flowing");
}
const el=document.getElementById(n.id);
if(el)el.classList.add(n.cls);
if(i<INGEST_ARRS.length)
document.getElementById(INGEST_ARRS[i]).classList.add("arr-flowing");
},i*700));
});
}
function ingestDone(){
_ingestTimers.forEach(clearTimeout);_ingestTimers=[];
INGEST_NODES.forEach(n=>{
const el=document.getElementById(n.id);
if(el){el.classList.remove(n.cls,"node-done","node-err");el.classList.add("node-done-ok");}
});
INGEST_ARRS.forEach(id=>document.getElementById(id).classList.remove("arr-flowing"));
}
function ingestErr(){
_ingestTimers.forEach(clearTimeout);_ingestTimers=[];
INGEST_NODES.forEach(n=>{
const el=document.getElementById(n.id);
if(el){el.classList.remove(n.cls,"node-done","node-done-ok");el.classList.add("node-err");}
});
INGEST_ARRS.forEach(id=>document.getElementById(id).classList.remove("arr-flowing"));
}
function ingestReset(){
_ingestTimers.forEach(clearTimeout);_ingestTimers=[];
INGEST_NODES.forEach(n=>{
const el=document.getElementById(n.id);
if(el)el.className=el.className.replace(/running-\\S+|node-done-ok|node-done|node-err/g,"").trim();
});
INGEST_ARRS.forEach(id=>document.getElementById(id).classList.remove("arr-flowing"));
}
/* ── PDF upload ─────────────────────────────────────────────────────────── */
async function up(file){
if(!file.name.toLowerCase().endsWith(".pdf")){sm("pdf-msg","error","Only PDF files are supported.");return;}
sm("pdf-msg","info","Uploading "+file.name+"…");
ingestStart();
const fd=new FormData();fd.append("file",file);
try{
const r=await fetch("/api/upload",{method:"POST",body:fd});
const d=await r.json();
if(d.error){ingestErr();sm("pdf-msg","error",d.error);return;}
ingestDone();
setSource(d.filename,d.chunks,"pdf");
sm("pdf-msg","ok","&#10003;&ensp;Indexed "+d.chunks+" chunks from \\""+d.filename+"\\"");
}catch(e){ingestErr();sm("pdf-msg","error","Upload failed: "+e.message);}
}
/* ── URL fetch ──────────────────────────────────────────────────────────── */
async function fetchURL(){
const url=document.getElementById("url-inp").value.trim();
if(!url){sm("url-msg","error","Please enter a URL.");return;}
document.getElementById("url-btn").disabled=true;
sm("url-msg","info","Fetching page…");
ingestStart();
try{
const r=await fetch("/api/ingest_url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url})});
const d=await r.json();
if(d.error){ingestErr();sm("url-msg","error",d.error);return;}
ingestDone();
setSource(d.url,d.chunks,"url");
sm("url-msg","ok","&#10003;&ensp;Indexed "+d.chunks+" chunks");
}catch(e){ingestErr();sm("url-msg","error","Failed: "+e.message);}
finally{document.getElementById("url-btn").disabled=false;}
}
/* ── Source card ────────────────────────────────────────────────────────── */
function setSource(name,chunks,type){
document.getElementById("source-name").textContent=name;
document.getElementById("source-chunks").textContent=chunks+" chunks indexed";
document.getElementById("sc-icon").textContent=type==="pdf"?"πŸ“„":"🌐";
document.getElementById("source-card").style.display="block";
const p=document.getElementById("hdr-src");
p.textContent=name.length>26?name.slice(0,26)+"…":name;
p.classList.add("loaded");
}
/* ════════════════════════════════════════════
MODEL DROPDOWN
════════════════════════════════════════════ */
function toggleMenu(e){
e.stopPropagation();
document.getElementById("model-sel-wrap").classList.toggle("open");
}
function pickModel(item){
const key=item.dataset.key,color=item.dataset.color,label=item.dataset.label;
document.querySelectorAll(".mm-item").forEach(i=>i.classList.remove("selected"));
item.classList.add("selected");
selectedModel=key;
document.getElementById("msb-dot").style.background=color;
document.getElementById("msb-label").textContent=label;
document.getElementById("model-sel-wrap").classList.remove("open");
fetch("/api/set_model",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({model:key})}).catch(()=>{});
}
document.addEventListener("click",e=>{
const w=document.getElementById("model-sel-wrap");
if(w&&!w.contains(e.target))w.classList.remove("open");
});
/* ════════════════════════════════════════════
RESEARCH PIPELINE ANIMATION
════════════════════════════════════════════ */
const AGENT_TYPES={
planner:"running-llm",retriever:"running-local",
grader:"running-score",generator:"running-llm",critic:"running-llm"
};
const PIPE_ARRS=["parr-0","parr-1","parr-2","parr-3","parr-4"];
function pipeReset(){
Object.keys(AGENT_TYPES).forEach(a=>{
const n=document.getElementById("anode-"+a);
if(n)n.className=n.className.replace(/running-\\S+|node-done-ok|node-done|node-err/g,"").trim();
});
const out=document.getElementById("anode-answer");
if(out)out.className=out.className.replace(/running-\\S+|node-done-ok|node-done/g,"").trim();
PIPE_ARRS.forEach(id=>document.getElementById(id).classList.remove("arr-flowing"));
}
function pipeSetActive(agent){
const agents=Object.keys(AGENT_TYPES);
agents.forEach((a,idx)=>{
const n=document.getElementById("anode-"+a);if(!n)return;
if(a===agent){
n.className=n.className.replace(/running-\\S+|node-done-ok|node-done/g,"").trim();
n.classList.add(AGENT_TYPES[a]);
if(idx<PIPE_ARRS.length)document.getElementById(PIPE_ARRS[idx]).classList.add("arr-flowing");
}else if(n.className.includes("running-")){
n.className=n.className.replace(/running-\\S+/g,"").trim();
n.classList.add("node-done-ok");
if(idx<PIPE_ARRS.length)document.getElementById(PIPE_ARRS[idx]).classList.remove("arr-flowing");
}
});
}
function pipeAllDone(){
Object.keys(AGENT_TYPES).forEach(a=>{
const n=document.getElementById("anode-"+a);
if(n){n.className=n.className.replace(/running-\\S+/g,"").trim();n.classList.add("node-done-ok");}
});
PIPE_ARRS.forEach(id=>document.getElementById(id).classList.remove("arr-flowing"));
const out=document.getElementById("anode-answer");
if(out){
out.classList.add("running-llm");
setTimeout(()=>{out.classList.remove("running-llm");out.classList.add("node-done-ok");},1200);
}
}
/* ── Ask ────────────────────────────────────────────────────────────────── */
function qk(e){if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();ask();}}
async function ask(){
const q=document.getElementById("q-inp").value.trim();
document.getElementById("q-err").textContent="";
if(!q){document.getElementById("q-err").textContent="Please enter a question.";return;}
const btn=document.getElementById("ask-btn");
btn.disabled=true;
btn.innerHTML=\'<span class="spinner"></span>&ensp;Thinking…\';
document.getElementById("trace-wrap").style.display="block";
document.getElementById("trace-log").innerHTML=
\'<div class="t-step"><span class="t-msg" style="color:var(--muted)">Starting agents…</span></div>\';
document.getElementById("answer-wrap").style.display="none";
pipeReset();seen=0;clearInterval(pollTimer);
try{
const r=await fetch("/api/research",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({question:q,model:selectedModel})});
const d=await r.json();
if(d.error){traceErr(d.error);resetBtn();return;}
pollTimer=setInterval(()=>poll(d.query_id),1500);
}catch(e){traceErr("Network error: "+e.message);resetBtn();}
}
function resetBtn(){
const btn=document.getElementById("ask-btn");
btn.disabled=false;btn.innerHTML=\'&#9889;&ensp;Ask\';
}
/* ── Polling ────────────────────────────────────────────────────────────── */
async function poll(qid){
try{
const r=await fetch("/api/trace/"+qid);
if(!r.ok){traceErr("Server error "+r.status);clearInterval(pollTimer);resetBtn();return;}
const d=await r.json();
renderTrace(d.trace||[]);
if(["complete","error"].includes(d.status)){
clearInterval(pollTimer);resetBtn();
if(d.status==="complete"&&d.result){renderAnswer(d.result);pipeAllDone();}
else if(d.status==="error"&&d.result)traceErr(d.result.error||"An error occurred.");
}
}catch(e){traceErr("Poll error: "+e.message);clearInterval(pollTimer);resetBtn();}
}
function traceErr(msg){
const log=document.getElementById("trace-log");
log.innerHTML+=\'<div class="t-step"><span class="t-badge b-error">error</span><span class="t-msg" style="color:var(--red)">\'+esc(msg)+\'</span></div>\';
log.scrollTop=log.scrollHeight;
}
function renderTrace(steps){
if(!steps.length)return;
const log=document.getElementById("trace-log");
if(seen===0)log.innerHTML="";
for(let i=seen;i<steps.length;i++){
const s=steps[i];
pipeSetActive(s.agent);
const lat=s.latency_ms>0?\'<span class="t-lat">\'+s.latency_ms+\'ms</span>\':"";
log.innerHTML+=\'<div class="t-step"><span class="t-badge b-\'+s.agent+\'">\'+s.agent+
\'</span><span class="t-msg">\'+esc(s.message)+\'</span>\'+lat+\'</div>\';
}
seen=steps.length;log.scrollTop=log.scrollHeight;
}
function renderAnswer(result){
document.getElementById("answer-wrap").style.display="block";
document.getElementById("answer-text").textContent=result.generation||"No answer generated.";
const v=document.getElementById("verdict");
if(result.verdict==="APPROVED"){v.className="v-ok";v.textContent="βœ“ High confidence";}
else if(result.verdict){v.className="v-warn";v.textContent="⚠ Low confidence β€” verify with source";}
else v.textContent="";
document.getElementById("answer-wrap").scrollIntoView({behavior:"smooth",block:"nearest"});
}
function copyAns(btn){
navigator.clipboard.writeText(document.getElementById("answer-text").textContent)
.then(()=>{btn.textContent="βœ“ Copied!";setTimeout(()=>{btn.innerHTML="&#128203;&ensp;Copy";},1800);});
}
function sm(id,type,msg){
const el=document.getElementById(id);
if(type==="ok")el.innerHTML=\'<div class="msg msg-ok">\'+msg+\'</div>\';
else if(type==="error")el.innerHTML=\'<div class="msg msg-err">\'+esc(msg)+\'</div>\';
else el.innerHTML=\'<div class="msg-info">\'+esc(msg)+\'</div>\';
}
</script>
</body>
</html>
'''
out = pathlib.Path(__file__).parent / "templates" / "index.html"
out.write_text(HTML, encoding="utf-8")
print(f"Done, size: {len(HTML)}")