RAG-Chat-Visualizer / index.html
quickgrid's picture
possible fix
08118b2 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAG Pipeline Visualizer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root{
--bg:#030810;--bg2:#06101e;--surface:#0a1829;--surface2:#0f2035;
--surface3:#142a45;--border:#1a3050;--border2:#24405e;
--cyan:#00e5ff;--cyan-d:rgba(0,229,255,.1);--purple:#a855f7;--purple-d:rgba(168,85,247,.1);
--green:#00ff88;--green-d:rgba(0,255,136,.08);--amber:#ffaa00;--amber-d:rgba(255,170,0,.1);
--red:#ff4455;--pink:#f472b6;--orange:#fb923c;
--text:#c8dff5;--text2:#7a9ab8;--text3:#3d5a7a;
--mono:'JetBrains Mono',monospace;--sans:'DM Sans',system-ui,sans-serif;
--sidebar-w:36px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--sans);background:var(--bg);color:var(--text);display:flex;flex-direction:column}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
input:focus,textarea:focus,select:focus{outline:none}
/* ═══ HEADER ═══ */
#header{display:flex;align-items:center;height:40px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;gap:10px;position:relative;z-index:50}
#header::after{content:'';position:absolute;bottom:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--cyan),var(--purple),transparent);opacity:.3}
.hdr-title{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:.08em;background:linear-gradient(90deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;white-space:nowrap}
.hdr-sep{width:1px;height:18px;background:var(--border);flex-shrink:0}
.hdr-btn{background:none;border:1px solid var(--border);color:var(--text3);width:28px;height:28px;border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;transition:all .2s;flex-shrink:0}
.hdr-btn:hover{border-color:var(--border2);color:var(--text);background:var(--surface2)}
.hdr-spacer{flex:1}
.hdr-signature{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:10px;color:var(--text3);white-space:nowrap}
.hdr-sig-icon{width:20px;height:20px;border-radius:50%;background:linear-gradient(135deg,var(--cyan),var(--purple));display:flex;align-items:center;justify-content:center;font-size:9px;color:#fff;font-weight:700;box-shadow:0 0 12px rgba(168,85,247,.25)}
.hdr-sig-text{background:linear-gradient(90deg,var(--text3),var(--text2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:500}
.hdr-sig-line{width:24px;height:1px;background:linear-gradient(90deg,var(--cyan),transparent)}
.dl-btn{position:relative;width:28px;height:28px;border-radius:50%;border:1.5px solid var(--border);background:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}
.dl-btn:hover{border-color:var(--cyan)}
.dl-btn i{font-size:10px;color:var(--text3);transition:color .2s}
.dl-btn:hover i{color:var(--cyan)}
.dl-btn.active{border-color:var(--amber)}
.dl-btn.active i{color:var(--amber);animation:dlPulse 1.5s infinite}
@keyframes dlPulse{0%,100%{opacity:1}50%{opacity:.4}}
.dl-ring{position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--amber);opacity:0;pointer-events:none}
.dl-btn.active .dl-ring{opacity:1;animation:dlRing 1s linear infinite}
@keyframes dlRing{to{transform:rotate(360deg)}}
.load-hdr-btn{padding:4px 14px;border-radius:5px;border:1.5px solid var(--cyan);background:var(--cyan-d);color:var(--cyan);font-family:var(--mono);font-size:10px;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap;display:flex;align-items:center;gap:5px;letter-spacing:.04em}
.load-hdr-btn:hover{background:rgba(0,229,255,.2);box-shadow:0 0 16px rgba(0,229,255,.12)}
.load-hdr-btn:disabled{opacity:.35;cursor:not-allowed;box-shadow:none}
.load-hdr-btn .lh-dot{width:5px;height:5px;border-radius:50%;background:var(--amber);display:none}
.load-hdr-btn.loading .lh-dot{display:block;animation:lhDot 1s infinite}
.load-hdr-btn.ready{border-color:var(--green);color:var(--green);background:var(--green-d)}
.load-hdr-btn.ready .lh-dot{display:block;background:var(--green);animation:none}
@keyframes lhDot{0%,100%{opacity:1}50%{opacity:.2}}
/* ═══ NODE BAR ═══ */
#node-bar{height:48px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 14px;gap:5px;flex-shrink:0;overflow-x:auto}
.nb-node{display:flex;align-items:center;gap:4px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);background:var(--surface);font-family:var(--mono);font-size:9px;color:var(--text3);transition:all .3s;cursor:pointer;white-space:nowrap;flex-shrink:0;position:relative}
.nb-node .nb-icon{font-size:12px}
.nb-node .nb-dot{position:absolute;top:2px;right:3px;width:5px;height:5px;border-radius:50%;background:var(--border2);transition:all .3s}
.nb-node.active{border-color:var(--cyan);color:var(--cyan);background:var(--cyan-d);animation:nbBlink 1.2s infinite}
.nb-node.active .nb-dot{background:var(--cyan);box-shadow:0 0 6px var(--cyan)}
.nb-node.done{border-color:var(--green);color:var(--green);background:var(--green-d)}
.nb-node.done .nb-dot{background:var(--green)}
.nb-node.disabled{opacity:.3;cursor:default}
.nb-node.disabled .nb-dot{background:var(--red)}
@keyframes nbBlink{0%,100%{opacity:1;box-shadow:0 0 8px var(--cyan)}50%{opacity:.55;box-shadow:0 0 2px var(--cyan)}}
.nb-arrow{color:var(--border2);font-size:9px;flex-shrink:0}
.nb-sep{flex:1}
.nb-open-btn{padding:4px 10px;border-radius:5px;border:1px solid var(--border);background:var(--surface);color:var(--text2);font-family:var(--mono);font-size:9px;cursor:pointer;transition:all .2s;white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:4px}
.nb-open-btn:hover{border-color:var(--cyan);color:var(--cyan)}
/* ═══ MAIN PANELS ═══ */
#main{flex:1;display:flex;overflow:hidden;min-height:0;position:relative}
.panel{display:flex;flex-direction:column;min-width:180px;min-height:0;overflow:hidden;background:var(--bg);position:relative;transition:all .25s ease}
.panel+.panel{border-left:1px solid var(--border)}
.panel-hdr{display:flex;align-items:center;height:30px;padding:0 10px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;gap:5px;cursor:grab;user-select:none}
.panel-hdr:active{cursor:grabbing}
.ph-drag{color:var(--text3);font-size:7px;opacity:.5}
.ph-title{font-family:var(--mono);font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--text2);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ph-badge{font-family:var(--mono);font-size:8px;color:var(--text3);padding:1px 5px;border-radius:3px;background:var(--surface2)}
.ph-btn{background:none;border:none;color:var(--text3);font-size:10px;cursor:pointer;padding:2px 4px;border-radius:3px;transition:all .15s}
.ph-btn:hover{color:var(--text);background:var(--surface2)}
.panel-body{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}
.panel.minimized{min-width:var(--sidebar-w)!important;max-width:var(--sidebar-w)!important;width:var(--sidebar-w)!important;border-left-color:var(--border)!important;border-right-color:var(--border)!important}
.panel.minimized .panel-hdr{writing-mode:vertical-rl;text-orientation:mixed;padding:6px 4px;height:100%;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px;border-bottom:none;border-right:1px solid var(--border)}
.panel.minimized .panel-hdr .ph-drag{display:none}
.panel.minimized .panel-hdr .ph-title{font-size:7px;letter-spacing:.15em;text-align:center}
.panel.minimized .panel-hdr .ph-badge{display:none}
.panel.minimized .panel-body{display:none}
.panel.minimized .resize-h{display:none}
.panel.maximized{position:absolute!important;inset:0;z-index:40;border:none!important;min-width:0!important;max-width:none!important;width:auto!important}
.resize-h{position:absolute;right:-2px;top:0;bottom:0;width:5px;cursor:col-resize;z-index:5}
.resize-h:hover,.resize-h.active{background:var(--cyan);opacity:.3}
/* ═══ RIGHT GRID ═══ */
#right-grid{flex:1;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;min-height:0;overflow:hidden;border-left:1px solid var(--border)}
#right-grid>.panel{min-width:0!important;width:auto!important;flex:none!important;border-left:none!important}
#right-grid>.panel[data-panel="retrieval"]{grid-column:1;grid-row:1}
#right-grid>.panel[data-panel="reranking"]{grid-column:1;grid-row:2}
#right-grid>.panel[data-panel="tools"]{grid-column:2;grid-row:1}
#right-grid>.panel[data-panel="vdb"]{grid-column:2;grid-row:2}
#right-grid .resize-h{display:none}
#right-grid>.panel.hidden{display:none}
#right-grid>.panel.full-height{grid-row:1/3}
/* ═══ CHAT ═══ */
#chat-messages{flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:6px;min-height:0}
.msg{display:flex;flex-direction:column;gap:2px}
.msg.user{align-items:flex-end}
.msg.assistant{align-items:flex-start}
.msg-bubble{max-width:90%;padding:7px 10px;border-radius:7px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.msg.user .msg-bubble{background:linear-gradient(135deg,var(--purple),#7c3aed);color:#fff;border-radius:7px 7px 2px 7px}
.msg.assistant .msg-bubble{background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:7px 7px 7px 2px}
.msg-meta{display:flex;align-items:center;gap:5px;padding:0 4px;flex-wrap:wrap}
.msg-time{font-family:var(--mono);font-size:8px;color:var(--text3)}
.msg-ctx{display:flex;align-items:center;gap:3px;font-family:var(--mono);font-size:8px;padding:1px 6px;border-radius:10px}
.msg-ctx.vdb{color:var(--cyan);background:var(--cyan-d);border:1px solid rgba(0,229,255,.2)}
.msg-ctx.own{color:var(--amber);background:var(--amber-d);border:1px solid rgba(255,170,0,.2)}
.msg-ctx-tokens{font-family:var(--mono);font-size:8px;color:var(--text3)}
.typing{display:flex;gap:4px;align-items:center;padding:6px}
.typing i{width:5px;height:5px;border-radius:50%;background:var(--purple);animation:tdot 1s infinite}
.typing i:nth-child(2){animation-delay:.15s}
.typing i:nth-child(3){animation-delay:.3s}
@keyframes tdot{0%,100%{transform:translateY(0);opacity:.4}50%{transform:translateY(-5px);opacity:1}}
#chat-input-wrap{flex-shrink:0;display:flex;gap:5px;padding:7px 8px;border-top:1px solid var(--border);background:var(--surface);align-items:flex-end}
#chat-input{flex:1;background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:11px;font-family:var(--sans);transition:border-color .2s;resize:none;min-height:36px;line-height:1.4;overflow-y:hidden}
#chat-input:focus{border-color:var(--purple)}
#chat-input::placeholder{color:var(--text3)}
#chat-input:disabled{opacity:.3;cursor:not-allowed}
.chat-toggles{display:flex;gap:3px;align-items:center}
.toggle-btn{padding:0 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface3);color:var(--text3);font-family:var(--mono);font-size:8px;cursor:pointer;transition:all .2s;white-space:nowrap;height:36px;display:inline-flex;align-items:center;justify-content:center}
.toggle-btn.on{border-color:var(--cyan);color:var(--cyan);background:var(--cyan-d)}
.toggle-btn.rigid.on{border-color:var(--red);color:var(--red);background:rgba(255,68,85,.1)}
#send-btn{flex-shrink:0;height:36px;display:inline-flex;align-items:center;justify-content:center}
/* ═══ RETRIEVAL ═══ */
.ret-section{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.ret-section+.ret-section{border-top:1px solid var(--border)}
.ret-hdr{padding:4px 8px;font-family:var(--mono);font-size:8px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--text3);background:var(--surface2);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
.ret-list{flex:1;overflow-y:auto;padding:5px;display:flex;flex-direction:column;gap:4px;min-height:0}
.ret-item{padding:7px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;font-size:10px;position:relative;transition:all .3s}
.ret-item.lit{border-color:var(--cyan);box-shadow:0 0 0 1px var(--cyan-d),inset 0 0 15px var(--cyan-d);animation:rpop .3s ease}
.ret-item.sel{border-color:var(--green);box-shadow:0 0 0 1px var(--green-d),inset 0 0 15px var(--green-d);animation:rpop .3s ease}
@keyframes rpop{0%{transform:scale(.96);opacity:.4}100%{transform:scale(1);opacity:1}}
.ret-rank{position:absolute;top:-7px;left:7px;font-family:var(--mono);font-size:8px;font-weight:700;padding:0 4px;border-radius:3px;background:var(--amber);color:var(--bg)}
.ret-item.sel .ret-rank{background:var(--green)}
.ret-text{color:var(--text);font-size:10px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:4px}
.ret-foot{display:flex;align-items:center;gap:5px}
.ret-meta{font-family:var(--mono);font-size:8px;color:var(--text3);white-space:nowrap}
.score-bar{flex:1;height:2px;background:var(--border);border-radius:1px;overflow:hidden}
.score-fill{height:100%;border-radius:1px;transition:width .5s;background:linear-gradient(90deg,var(--purple),var(--cyan))}
.ret-item.sel .score-fill{background:linear-gradient(90deg,var(--green),var(--cyan))}
.score-pct{font-family:var(--mono);font-size:8px;color:var(--text2);white-space:nowrap}
/* ═══ TOOLS PANEL ═══ */
.tools-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
.tools-tab{flex:1;padding:6px;text-align:center;font-family:var(--mono);font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;transition:all .2s}
.tools-tab:hover{color:var(--text2)}
.tools-tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
.tools-pane{flex:1;overflow-y:auto;display:none;flex-direction:column;min-height:0;padding:8px;gap:8px}
.tools-pane.active{display:flex}
/* Tokenizer */
.tok-stats-row{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--border);flex-shrink:0}
.tok-stat-card{padding:7px 8px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);position:relative;overflow:hidden}
.tok-stat-card:nth-child(2n){border-right:none}
.tok-stat-card:nth-child(3),.tok-stat-card:nth-child(4){border-bottom:none}
.tok-stat-label{font-size:8px;font-family:var(--mono);color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
.tok-stat-value{font-family:var(--mono);font-size:16px;font-weight:700;color:var(--text);line-height:1}
.tok-stat-card:nth-child(1) .tok-stat-value{color:var(--cyan)}
.tok-stat-card:nth-child(2) .tok-stat-value{color:var(--green)}
.tok-stat-card:nth-child(3) .tok-stat-value{color:var(--amber)}
.tok-stat-card:nth-child(4) .tok-stat-value{color:var(--purple)}
.tok-stat-sub{font-size:8px;color:var(--text3);font-family:var(--mono);margin-top:2px}
.tok-view-toggle{display:flex;padding:6px 8px;border-bottom:1px solid var(--border);gap:2px;align-items:center;flex-shrink:0}
.tok-toggle-group{display:flex;gap:2px;background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px}
.tok-toggle-btn{padding:3px 8px;border-radius:3px;border:none;background:transparent;color:var(--text3);font-family:var(--mono);font-size:9px;font-weight:500;cursor:pointer;transition:all .15s}
.tok-toggle-btn.active{background:var(--surface3);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.3)}
.token-display{flex:1;overflow-y:auto;padding:8px;min-height:0}
.token-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:80px;gap:8px;color:var(--text3);font-family:var(--mono);font-size:10px;text-align:center}
.token-text-view{font-family:var(--mono);font-size:12px;line-height:2.2;word-break:break-all}
.tok{display:inline;border-radius:3px;padding:1px 1px;cursor:default;transition:filter .15s;position:relative;margin:0 1px}
.tok:hover{filter:brightness(1.3)}
.tok-tooltip{display:none;position:absolute;bottom:110%;left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border2);border-radius:4px;padding:3px 6px;font-size:9px;white-space:nowrap;z-index:50;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.5);font-family:var(--mono)}
.tok:hover .tok-tooltip{display:block}
.tok-tooltip-id{color:var(--cyan);font-weight:700}
.tok-tooltip-text{color:var(--text2)}
.token-id-view{display:flex;flex-wrap:wrap;gap:4px}
.tok-id-card{display:flex;flex-direction:column;align-items:center;border-radius:5px;overflow:hidden;border:1px solid;cursor:default;transition:transform .15s,box-shadow .15s;min-width:40px}
.tok-id-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.4)}
.tok-id-top{padding:2px 4px;font-family:var(--mono);font-size:9px;font-weight:600;width:100%;text-align:center;border-bottom:1px solid rgba(255,255,255,.06)}
.tok-id-bottom{padding:1px 4px 2px;font-family:var(--mono);font-size:7px;color:rgba(255,255,255,.4);width:100%;text-align:center}
.token-split-view{display:flex;flex-direction:column;gap:2px}
.tok-split-row{display:flex;align-items:stretch;border-radius:4px;overflow:hidden;border:1px solid;font-family:var(--mono);font-size:10px}
.tok-split-idx{width:30px;text-align:center;padding:3px 2px;font-size:8px;color:rgba(255,255,255,.3);border-right:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center}
.tok-split-text{flex:1;padding:3px 5px;font-size:11px}
.tok-split-id{padding:3px 5px;font-size:9px;color:rgba(255,255,255,.45);border-left:1px solid rgba(255,255,255,.06);display:flex;align-items:center}
/* VDB Table in Tools */
.vdb-tools-section{flex:1;min-height:0;display:flex;flex-direction:column;border-top:1px solid var(--border);overflow:hidden}
.vdb-tools-hdr{padding:5px 8px;font-family:var(--mono);font-size:8px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--text3);background:var(--surface2);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
.vdb-tools-list{flex:1;overflow-y:auto;padding:4px;display:flex;flex-direction:column;gap:3px;min-height:0}
.vdb-tools-item{padding:5px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:3px;font-size:9px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:6px}
.vdb-tools-item:hover{border-color:var(--border2);background:var(--surface3)}
.vdb-tools-item.selected{border-color:var(--cyan);background:var(--cyan-d)}
.vdb-tools-num{width:16px;text-align:center;font-family:var(--mono);font-size:8px;color:var(--text3);flex-shrink:0}
.vdb-tools-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.vdb-tools-meta{font-family:var(--mono);font-size:7px;color:var(--text3);flex-shrink:0;white-space:nowrap}
.vdb-tools-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);font-size:9px;gap:4px;padding:10px;text-align:center}
/* ═══ MODALS ═══ */
.modal-overlay{position:fixed;inset:0;background:rgba(3,8,16,.82);z-index:100;display:none;align-items:center;justify-content:center;backdrop-filter:blur(3px)}
.modal-overlay.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:10px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 0 60px rgba(0,0,0,.6);position:relative}
.modal::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--cyan),var(--purple),transparent);opacity:.5}
.modal-hdr{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
.modal-title{font-family:var(--mono);font-size:12px;font-weight:600;letter-spacing:.04em;display:flex;align-items:center;gap:6px}
.modal-close{background:none;border:none;color:var(--text3);font-size:16px;cursor:pointer;padding:2px 6px;border-radius:4px;transition:all .15s;line-height:1}
.modal-close:hover{color:var(--text);background:var(--surface2)}
.modal-body{overflow-y:auto;padding:16px;flex:1}
/* Load Models */
.load-model-grid{display:flex;flex-direction:column;gap:12px}
.lm-row{display:flex;flex-direction:column;gap:3px}
.lm-label{font-family:var(--mono);font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--text3)}
.lm-row select,.lm-row input{background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:7px 10px;border-radius:5px;font-family:var(--mono);font-size:11px;width:100%}
.lm-row select:focus,.lm-row input:focus{border-color:var(--cyan)}
.lm-row select option{background:var(--surface2);color:var(--text)}
.lm-info{font-family:var(--mono);font-size:8px;color:var(--text3)}
.load-progress{margin-top:10px;display:flex;flex-direction:column;gap:4px}
.lp-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-family:var(--mono);font-size:10px;color:var(--text3)}
.lp-item .lp-icon{width:16px;text-align:center}
.lp-item.active{color:var(--amber)}
.lp-item.done{color:var(--green)}
.lp-item.error{color:var(--red)}
.lp-bar{height:2px;background:var(--border);border-radius:1px;overflow:hidden;margin-top:4px}
.lp-fill{height:100%;background:linear-gradient(90deg,var(--cyan),var(--purple));width:0;transition:width .4s;border-radius:1px}
/* Node Editor */
.ne-canvas-wrap{position:relative;width:100%;height:480px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;overflow:visible}
.ne-nodes{position:absolute;inset:0;overflow:visible}
.ne-node{position:absolute;width:120px;padding:7px;background:var(--surface);border:1.5px solid var(--border);border-radius:7px;cursor:grab;user-select:none;transition:border-color .2s,box-shadow .2s;z-index:2}
.ne-node:active{cursor:grabbing}
.ne-node.active-node{border-color:var(--cyan);box-shadow:0 0 15px var(--cyan-d)}
.ne-node.disabled-node{opacity:.35;border-style:dashed}
.ne-node-head{display:flex;align-items:center;gap:4px;margin-bottom:3px}
.ne-node-icon{font-size:14px}
.ne-node-label{font-family:var(--mono);font-size:8px;font-weight:600;color:var(--text2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ne-node-toggle{background:none;border:1px solid var(--border);color:var(--text3);font-size:7px;padding:1px 4px;border-radius:3px;cursor:pointer;font-family:var(--mono)}
.ne-node-toggle.on{border-color:var(--green);color:var(--green)}
.ne-node-info{font-family:var(--mono);font-size:7px;color:var(--text3);line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ne-port{position:absolute;width:12px;height:12px;border-radius:50%;background:var(--border2);border:2px solid var(--surface);cursor:crosshair;z-index:3;transition:all .15s}
.ne-port:hover{transform:scale(1.4);background:var(--cyan)}
.ne-port.out{right:-6px;top:50%;transform:translateY(-50%)}
.ne-port.out:hover{transform:translateY(-50%) scale(1.4)}
.ne-port.in{left:-6px;top:50%;transform:translateY(-50%)}
.ne-port.in:hover{transform:translateY(-50%) scale(1.4)}
.ne-svg{position:absolute;inset:0;pointer-events:none;z-index:1;overflow:visible}
.ne-svg path{fill:none;stroke:var(--border2);stroke-width:1.5;transition:stroke .2s}
.ne-svg path.hl{stroke:var(--cyan);filter:drop-shadow(0 0 4px var(--cyan))}
.ne-legend{display:flex;gap:12px;padding:6px 0;font-family:var(--mono);font-size:8px;color:var(--text3);flex-wrap:wrap}
/* VDB Modal */
.vdb-layout{display:flex;height:62vh}
.vdb-table-wrap{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
.vdb-search{padding:8px;flex-shrink:0}
.vdb-search input{width:100%;background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:5px;font-size:11px;font-family:var(--sans)}
.vdb-search input:focus{border-color:var(--green)}
.vdb-tbl{flex:1;overflow:auto}
.vdb-tbl table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:9px}
.vdb-tbl th{padding:5px 7px;background:var(--surface3);border-bottom:1px solid var(--border);text-align:left;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;position:sticky;top:0;z-index:1;cursor:pointer;white-space:nowrap;user-select:none}
.vdb-tbl th:hover{color:var(--text2)}
.vdb-tbl th .sa{margin-left:3px;font-size:7px}
.vdb-tbl td{padding:4px 7px;border-bottom:1px solid rgba(26,48,80,.4);color:var(--text);vertical-align:top}
.vdb-tbl tr{transition:background .2s;cursor:pointer}
.vdb-tbl tr:hover td{background:var(--surface2)}
.vdb-tbl tr.selected td{background:var(--cyan-d)}
.vec-chip{font-size:8px;color:var(--cyan);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:80px;display:inline-block}
.tag{display:inline-block;padding:0 4px;border-radius:3px;font-size:8px;background:var(--surface3);border:1px solid var(--border);color:var(--text3)}
.vdb-actions{padding:8px;border-top:1px solid var(--border);display:flex;gap:5px;flex-shrink:0;flex-wrap:wrap;align-items:center}
.vdb-detail{width:300px;border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.vdb-detail-hdr{padding:10px;font-family:var(--mono);font-size:10px;font-weight:600;color:var(--text2);border-bottom:1px solid var(--border);flex-shrink:0}
.vdb-detail-body{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px}
.vd-row{display:flex;flex-direction:column;gap:2px}
.vd-label{font-family:var(--mono);font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text3)}
.vd-value{font-size:11px;color:var(--text);line-height:1.4;word-break:break-all}
.vd-vec{font-family:var(--mono);font-size:8px;color:var(--cyan);line-height:1.3;max-height:100px;overflow-y:auto;background:var(--surface3);padding:6px;border-radius:4px;border:1px solid var(--border)}
.vd-actions{display:flex;gap:5px;margin-top:auto;padding-top:8px;border-top:1px solid var(--border)}
.vd-textarea{background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:6px 8px;border-radius:4px;font-size:11px;font-family:var(--sans);resize:vertical;min-height:55px;width:100%;line-height:1.4}
.vd-textarea:focus{border-color:var(--green)}
/* Dl Modal */
.dl-list{display:flex;flex-direction:column;gap:6px}
.dl-item{display:flex;align-items:center;gap:10px;padding:8px;background:var(--surface2);border:1px solid var(--border);border-radius:6px}
.dl-item-icon{font-size:14px;width:20px;text-align:center}
.dl-item-info{flex:1;min-width:0}
.dl-item-name{font-family:var(--mono);font-size:10px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dl-item-status{font-family:var(--mono);font-size:8px;color:var(--text3)}
.dl-item-bar{height:3px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:3px}
.dl-item-fill{height:100%;border-radius:2px;background:var(--cyan);width:0;transition:width .3s}
/* Settings */
.settings-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border)}
.settings-row:last-child{border-bottom:none}
.settings-label{font-size:12px;color:var(--text)}
.settings-desc{font-family:var(--mono);font-size:8px;color:var(--text3);margin-top:2px}
/* ═══ BUTTONS ═══ */
.btn{padding:6px 12px;border-radius:5px;border:none;cursor:pointer;font-family:var(--mono);font-size:10px;font-weight:600;transition:all .18s;letter-spacing:.03em}
.btn-cyan{background:var(--cyan);color:var(--bg)}
.btn-cyan:hover{filter:brightness(1.15);transform:translateY(-1px)}
.btn-cyan:disabled{opacity:.3;cursor:not-allowed;transform:none;filter:none}
.btn-ghost{background:var(--surface3);color:var(--text2);border:1px solid var(--border)}
.btn-ghost:hover{border-color:var(--border2);color:var(--text)}
.btn-green{background:var(--green);color:var(--bg)}
.btn-green:hover{filter:brightness(1.1)}
.btn-green:disabled{opacity:.3;cursor:not-allowed;filter:none}
.btn-red{background:var(--red);color:#fff}
.btn-red:hover{filter:brightness(1.1)}
.btn-sm{padding:3px 8px;font-size:9px}
.btn:disabled{opacity:.3;cursor:not-allowed}
/* ═══ EMPTY ═══ */
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);font-size:10px;gap:6px;padding:16px;text-align:center}
.empty-icon{font-size:22px;opacity:.4}
/* ═══ TOAST ═══ */
#toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%) translateY(60px);padding:7px 14px;background:var(--surface3);border:1px solid var(--border2);border-radius:7px;font-family:var(--mono);font-size:10px;color:var(--text);transition:transform .3s;z-index:999;pointer-events:none;white-space:nowrap;max-width:90vw;overflow:hidden;text-overflow:ellipsis}
#toast.show{transform:translateX(-50%) translateY(0)}
/* ═══ FADE ═══ */
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.fade-in{animation:fadeIn .25s ease}
/* ═══ ALIGNMENT FIX ═══ */
.panel[data-panel="retrieval"] .panel-body::before{content:'';height:0px;flex-shrink:0}
</style>
<base target="_blank">
</head>
<body>
<!-- ═══ HEADER ═══ -->
<div id="header">
<span class="hdr-title">RAG/VISUALIZER</span>
<div class="hdr-sep"></div>
<button class="load-hdr-btn" id="load-hdr-btn"><div class="lh-dot"></div><i class="fa-solid fa-microchip"></i> Load Models</button>
<button class="hdr-btn" id="settings-btn" title="Settings"><i class="fa-solid fa-gear"></i></button>
<button class="dl-btn" id="dl-btn" title="Download Progress"><i class="fa-solid fa-download"></i><div class="dl-ring"></div></button>
<div class="hdr-spacer"></div>
<div class="hdr-signature">
<div class="hdr-sig-line"></div>
<span class="hdr-sig-text"><a href="https://quickgrid.github.io/">Made by Asif Ahmed</a></span>
<div class="hdr-sig-line" style="transform:scaleX(-1)"></div>
</div>
</div>
<!-- ═══ NODE BAR ═══ -->
<div id="node-bar">
<div class="nb-node" data-node="query"><span class="nb-icon">📝</span>Query<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="embed"><span class="nb-icon">🔢</span>Embed<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="search"><span class="nb-icon">🗄️</span>Search<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="topk"><span class="nb-icon">📊</span>Top-K<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="rerank"><span class="nb-icon">🔄</span>Rerank<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="llm"><span class="nb-icon">🤖</span>LLM<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="tokenizer"><span class="nb-icon">✂️</span>Token<span class="nb-dot"></span></div>
<span class="nb-arrow"><i class="fa-solid fa-chevron-right"></i></span>
<div class="nb-node" data-node="answer"><span class="nb-icon">💬</span>Answer<span class="nb-dot"></span></div>
<div class="nb-sep"></div>
<button class="nb-open-btn" id="open-node-editor"><i class="fa-solid fa-diagram-project"></i> Node Editor</button>
<button class="nb-open-btn" id="open-vdb-btn"><i class="fa-solid fa-database"></i> Vector DB</button>
</div>
<!-- ═══ MAIN PANELS ═══ -->
<div id="main">
<div class="panel" data-panel="chat" style="flex: 1.2; max-width: 44%;">
<div class="panel-hdr">
<span class="ph-drag"><i class="fa-solid fa-grip-vertical"></i></span>
<span class="ph-title">Chat</span>
<span class="ph-badge" id="chat-status">no models</span>
<button class="ph-btn" data-action="minimize" title="Minimize"><i class="fa-solid fa-minus"></i></button>
<button class="ph-btn" data-action="maximize" title="Maximize"><i class="fa-solid fa-expand"></i></button>
</div>
<div class="panel-body">
<div id="chat-messages">
<div class="empty" id="chat-empty"><div class="empty-icon">🤖</div><span>Click "Load Models" above to get started.<br>The full UI is ready — models load on demand.</span></div>
</div>
<div id="chat-input-wrap">
<div class="chat-toggles">
<button class="toggle-btn on" id="toggle-vdb" title="Toggle Vector DB">VDB</button>
<button class="toggle-btn" id="toggle-rigid" title="Rigid Mode (VDB only)">Rigid</button>
</div>
<textarea id="chat-input" placeholder="Load models first…" disabled rows="1"></textarea>
<button class="btn btn-cyan" id="send-btn" disabled>Send</button>
</div>
</div>
<div class="resize-h"></div>
</div>
<div id="right-grid">
<div class="panel" data-panel="retrieval">
<div class="panel-hdr">
<span class="ph-drag"><i class="fa-solid fa-grip-vertical"></i></span>
<span class="ph-title">Retrieval</span>
<span class="ph-badge" id="ret-status">idle</span>
<button class="ph-btn" data-action="minimize"><i class="fa-solid fa-minus"></i></button>
<button class="ph-btn" data-action="maximize"><i class="fa-solid fa-expand"></i></button>
</div>
<div class="panel-body">
<div class="ret-section">
<div class="ret-hdr"><span>Retrieved (Top-K)</span><span id="ret-k" style="color:var(--cyan);font-size:8px"></span></div>
<div class="ret-list" id="ret-list"><div class="empty"><div class="empty-icon">📚</div><span>Retrieved chunks appear here</span></div></div>
</div>
</div>
<div class="resize-h"></div>
</div>
<div class="panel" data-panel="reranking">
<div class="panel-hdr">
<span class="ph-drag"><i class="fa-solid fa-grip-vertical"></i></span>
<span class="ph-title">Reranking</span>
<span class="ph-badge" id="rr-status" style="display:none"></span>
<button class="ph-btn" data-action="minimize"><i class="fa-solid fa-minus"></i></button>
<button class="ph-btn" data-action="maximize"><i class="fa-solid fa-expand"></i></button>
</div>
<div class="panel-body">
<div class="ret-section">
<div class="ret-hdr"><span>After Reranking</span><span id="rr-k" style="color:var(--green);font-size:8px"></span></div>
<div class="ret-list" id="rr-list"><div class="empty"><div class="empty-icon">🔄</div><span>Reranked context appears here</span></div></div>
</div>
</div>
<div class="resize-h"></div>
</div>
<div class="panel" data-panel="tools">
<div class="panel-hdr">
<span class="ph-drag"><i class="fa-solid fa-grip-vertical"></i></span>
<span class="ph-title">Tokenizer</span>
<button class="ph-btn" data-action="minimize"><i class="fa-solid fa-minus"></i></button>
<button class="ph-btn" data-action="maximize"><i class="fa-solid fa-expand"></i></button>
</div>
<div class="panel-body">
<div class="tools-tabs">
<div class="tools-tab active" data-tab="tokenizer">Tokenizer</div>
</div>
<div class="tools-pane active" id="pane-tokenizer">
<div class="tok-stats-row">
<div class="tok-stat-card">
<div class="tok-stat-label">Tokens</div>
<div class="tok-stat-value" id="tok-stat-tokens"></div>
<div class="tok-stat-sub" id="tok-stat-model">no model</div>
</div>
<div class="tok-stat-card">
<div class="tok-stat-label">Characters</div>
<div class="tok-stat-value" id="tok-stat-chars"></div>
<div class="tok-stat-sub">total input</div>
</div>
<div class="tok-stat-card">
<div class="tok-stat-label">Words</div>
<div class="tok-stat-value" id="tok-stat-words"></div>
<div class="tok-stat-sub">approx</div>
</div>
<div class="tok-stat-card">
<div class="tok-stat-label">Chars/Token</div>
<div class="tok-stat-value" id="tok-stat-ratio"></div>
<div class="tok-stat-sub">efficiency</div>
</div>
</div>
<div class="tok-view-toggle">
<div class="tok-toggle-group" id="tok-toggle-group">
<button class="tok-toggle-btn active" data-view="text">Text View</button>
<button class="tok-toggle-btn" data-view="ids">ID Grid</button>
<button class="tok-toggle-btn" data-view="list">Token List</button>
</div>
</div>
<div class="token-display" id="token-display">
<div class="token-placeholder" id="token-placeholder">
<div class="empty-icon">✂️</div>
<span>Load models and type in the chat<br>to see real-time tokenization</span>
</div>
</div>
</div>
</div>
<div class="resize-h"></div>
</div>
<div class="panel" data-panel="vdb">
<div class="panel-hdr">
<span class="ph-drag"><i class="fa-solid fa-grip-vertical"></i></span>
<span class="ph-title">Vector DB</span>
<span class="ph-badge" id="vdb-tools-count">0 entries</span>
<button class="ph-btn" data-action="minimize"><i class="fa-solid fa-minus"></i></button>
<button class="ph-btn" data-action="maximize"><i class="fa-solid fa-expand"></i></button>
</div>
<div class="panel-body">
<div class="vdb-tools-list" id="vdb-tools-list">
<div class="vdb-tools-empty">
<div class="empty-icon">📭</div>
<span>No entries in vector database</span>
</div>
</div>
</div>
<div class="resize-h"></div>
</div>
</div>
</div>
<!-- ═══ MODALS ═══ -->
<!-- Load Models -->
<div class="modal-overlay" id="load-modal">
<div class="modal" style="width:480px">
<div class="modal-hdr">
<span class="modal-title"><i class="fa-solid fa-microchip"></i> Load Models</span>
<button class="modal-close" data-close="load-modal">&times;</button>
</div>
<div class="modal-body">
<div style="font-family:var(--mono);font-size:9px;color:var(--text3);margin-bottom:12px;display:flex;align-items:center;gap:6px" id="gpu-info-hdr"><i class="fa-solid fa-display"></i> Detecting GPU…</div>
<div class="load-model-grid">
<div class="lm-row">
<div class="lm-label">LLM Model</div>
<select id="lm-llm">
<option value="onnx-community/Qwen3.5-0.8B-ONNX" selected>Qwen 3.5 0.8B (Q4F16 ONNX — WebGPU)</option>
<option value="Xenova/LaMini-Flan-T5-248M">LaMini-Flan-T5-248M</option>
</select>
<div class="lm-info">Qwen requires WebGPU + discrete GPU. Falls back to WASM if unavailable.</div>
</div>
<div class="lm-row">
<div class="lm-label">Embedding Model</div>
<select id="lm-embed">
<option value="Xenova/all-MiniLM-L6-v2" selected>all-MiniLM-L6-v2 (384 dims)</option>
</select>
<div class="lm-info">Text embedding for cosine similarity vector search</div>
</div>
<div class="lm-row">
<div class="lm-label">Reranking Model</div>
<select id="lm-rerank">
<option value="Xenova/ms-marco-MiniLM-L-6-v2" selected>ms-marco-MiniLM-L-6-v2</option>
</select>
<div class="lm-info">Cross-encoder reranker — disable in Node Editor to skip</div>
</div>
</div>
<div style="margin-top:12px">
<label style="font-family:var(--mono);font-size:9px;color:var(--text3);display:flex;align-items:center;gap:5px;cursor:pointer">
<input type="checkbox" id="lm-webgpu" checked style="accent-color:var(--cyan)"> Prefer WebGPU (discrete GPU)
</label>
</div>
<div class="load-progress" id="load-progress" style="display:none;margin-top:12px">
<div class="lp-item" id="lp-embed"><span class="lp-icon"></span><span>Loading embedder…</span></div>
<div class="lp-item" id="lp-rerank"><span class="lp-icon"></span><span>Loading reranker…</span></div>
<div class="lp-item" id="lp-llm"><span class="lp-icon"></span><span>Loading LLM…</span></div>
<div class="lp-bar"><div class="lp-fill" id="lp-fill"></div></div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-ghost" data-close="load-modal">Cancel</button>
<button class="btn btn-cyan" id="do-load-btn"><i class="fa-solid fa-download"></i> Load All Models</button>
</div>
</div>
</div>
</div>
<!-- Node Editor -->
<div class="modal-overlay" id="node-modal">
<div class="modal" style="width:860px">
<div class="modal-hdr">
<span class="modal-title"><i class="fa-solid fa-diagram-project"></i> Pipeline Node Editor</span>
<button class="modal-close" data-close="node-modal">&times;</button>
</div>
<div class="modal-body" style="padding:10px">
<div class="ne-legend">
<span><span style="color:var(--green)"></span> Enabled</span>
<span><span style="color:var(--red)"></span> Disabled</span>
<span>Drag to move · Click ports to connect · Double-click to configure</span>
</div>
<div class="ne-canvas-wrap" id="ne-wrap">
<svg class="ne-svg" id="ne-svg" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="ne-nodes" id="ne-nodes"></div>
</div>
<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-ghost btn-sm" id="ne-reset-btn">Reset Layout</button>
<button class="btn btn-cyan btn-sm" data-close="node-modal">Done</button>
</div>
</div>
</div>
</div>
<!-- Node Config -->
<div class="modal-overlay" id="node-config-modal">
<div class="modal" style="width:380px">
<div class="modal-hdr">
<span class="modal-title" id="nc-title">Node Config</span>
<button class="modal-close" data-close="node-config-modal">&times;</button>
</div>
<div class="modal-body" id="nc-body"></div>
</div>
</div>
<!-- Vector DB -->
<div class="modal-overlay" id="vdb-modal">
<div class="modal" style="width:880px">
<div class="modal-hdr">
<span class="modal-title"><i class="fa-solid fa-database"></i> Vector Database</span>
<button class="modal-close" data-close="vdb-modal">&times;</button>
</div>
<div class="modal-body" style="padding:0">
<div class="vdb-layout">
<div class="vdb-table-wrap">
<div class="vdb-search"><input id="vdb-search" placeholder="Search entries…"></div>
<div class="vdb-tbl" id="vdb-tbl-wrap">
<table><thead><tr>
<th data-col="id"># <span class="sa"></span></th>
<th data-col="text">Text <span class="sa"></span></th>
<th data-col="source">Source <span class="sa"></span></th>
<th data-col="category">Category <span class="sa"></span></th>
<th data-col="date">Date <span class="sa"></span></th>
</tr></thead><tbody id="vdb-tbody"></tbody></table>
</div>
<div class="vdb-actions">
<button class="btn btn-green btn-sm" id="vdb-add-btn"><i class="fa-solid fa-plus"></i> Add New</button>
<button class="btn btn-ghost btn-sm" id="vdb-del-btn" disabled><i class="fa-solid fa-trash"></i> Delete</button>
<span style="flex:1"></span>
<span style="font-family:var(--mono);font-size:8px;color:var(--text3)" id="vdb-count-modal">0 entries</span>
</div>
</div>
<div class="vdb-detail" id="vdb-detail">
<div class="vdb-detail-hdr">Entry Detail</div>
<div class="vdb-detail-body" id="vdb-detail-body"><div class="empty"><div class="empty-icon">📋</div><span>Select an entry</span></div></div>
</div>
</div>
</div>
</div>
</div>
<!-- VDB Edit -->
<div class="modal-overlay" id="vdb-edit-modal">
<div class="modal" style="width:420px">
<div class="modal-hdr">
<span class="modal-title" id="ve-title">Add Entry</span>
<button class="modal-close" data-close="vdb-edit-modal">&times;</button>
</div>
<div class="modal-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="lm-row"><div class="lm-label">Text</div><textarea class="vd-textarea" id="ve-text" rows="4" placeholder="Enter text…"></textarea></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div class="lm-row"><div class="lm-label">Source</div><input id="ve-source" placeholder="doc1.pdf" style="background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:6px 8px;border-radius:4px;font-size:11px;width:100%"></div>
<div class="lm-row"><div class="lm-label">Category</div><input id="ve-category" placeholder="science" style="background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:6px 8px;border-radius:4px;font-size:11px;width:100%"></div>
</div>
<div id="ve-progress" style="display:none"><div class="lp-bar"><div class="lp-fill" id="ve-fill"></div></div><div style="font-family:var(--mono);font-size:8px;color:var(--green);margin-top:2px" id="ve-prog-txt">Embedding…</div></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-ghost" data-close="vdb-edit-modal">Cancel</button>
<button class="btn btn-green" id="ve-save-btn">Save & Embed</button>
</div>
</div>
</div>
</div>
</div>
<!-- Download Progress -->
<div class="modal-overlay" id="dl-modal">
<div class="modal" style="width:440px">
<div class="modal-hdr">
<span class="modal-title"><i class="fa-solid fa-download"></i> Download Progress</span>
<button class="modal-close" data-close="dl-modal">&times;</button>
</div>
<div class="modal-body"><div class="dl-list" id="dl-list"></div></div>
</div>
</div>
<!-- Settings -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:400px">
<div class="modal-hdr">
<span class="modal-title"><i class="fa-solid fa-gear"></i> Settings</span>
<button class="modal-close" data-close="settings-modal">&times;</button>
</div>
<div class="modal-body">
<div class="settings-row">
<div><div class="settings-label">Tokenizer Visualizer</div><div class="settings-desc">Real-time tokenization display in Tools panel</div></div>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="toggle-tok-viz" checked style="accent-color:var(--green)">
<span style="font-size:11px" id="toggle-tok-viz-label">Enabled</span>
</label>
</div>
<div class="settings-row">
<div><div class="settings-label">Clear Model Cache</div><div class="settings-desc">Remove all downloaded ONNX model files from browser cache</div></div>
<button class="btn btn-red btn-sm" id="clear-cache-btn"><i class="fa-solid fa-trash"></i> Clear</button>
</div>
<div class="settings-row">
<div><div class="settings-label">Clear Vector Database</div><div class="settings-desc">Delete all stored vectors from IndexedDB</div></div>
<button class="btn btn-red btn-sm" id="clear-vdb-btn"><i class="fa-solid fa-database"></i> Clear</button>
</div>
<div class="settings-row">
<div><div class="settings-label">WebGPU / GPU Status</div><div class="settings-desc" id="settings-gpu">Checking…</div></div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script type="module">
import { pipeline, env, AutoTokenizer }
from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0';
env.allowLocalModels = false;
env.useBrowserCache = true;
/* ═══════════════════════════════════════════════════════════
CONFIG & STATE
═══════════════════════════════════════════════════════════ */
const CFG = {
llmModel:'onnx-community/Qwen3.5-0.8B-ONNX',
embedModel:'Xenova/all-MiniLM-L6-v2',
rerankModel:'Xenova/ms-marco-MiniLM-L-6-v2',
topK:5, topKRerank:3, maxTokens:200,
useWebGPU:true,
};
const ST = {
embedder:null, reranker:null, generator:null, tokenizer:null,
db:[], busy:false, modelsLoaded:false, loading:false,
vdbOn:true, rigidMode:false,
tokenizerVizEnabled:true,
nodeEnabled:{query:true,embed:true,search:true,topk:true,rerank:true,llm:true,tokenizer:true,answer:true},
selectedVdbId:null, sortCol:'date', sortDir:-1, vdbFilter:'',
dlProgress:{},
panelStates:{},
tokView:'text',
};
const NODES=[
{id:'query',label:'Query',icon:'📝',color:'#a855f7',settings:{Type:'User Input',Description:'Raw question'}},
{id:'embed',label:'Embed',icon:'🔢',color:'#00e5ff',settings:{Model:'',Dims:384,Pooling:'mean',Normalize:true}},
{id:'search',label:'Search',icon:'🗄️',color:'#ffaa00',settings:{Metric:'Cosine','Top-K':5,Index:'Flat'}},
{id:'topk',label:'Top-K',icon:'📊',color:'#f472b6',settings:{K:5,Threshold:'0.0'}},
{id:'rerank',label:'Rerank',icon:'🔄',color:'#00ff88',settings:{Model:'',Type:'Cross-Encoder',Keep:3}},
{id:'llm',label:'LLM',icon:'🤖',color:'#fb923c',settings:{Model:'',MaxTokens:200}},
{id:'tokenizer',label:'Tokenize',icon:'✂️',color:'#38bdf8',settings:{Model:'',VocabSize:'—'}},
{id:'answer',label:'Answer',icon:'💬',color:'#34d399',settings:{Format:'Text',Grounded:true}},
];
const EDGES=[['query','embed'],['embed','search'],['search','topk'],['topk','rerank'],['rerank','llm'],['llm','answer'],['query','tokenizer'],['tokenizer','llm']];
/* ═══════════════════════════════════════════════════════════
UTILITIES
═══════════════════════════════════════════════════════════ */
const $=id=>document.getElementById(id);
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
let toastT;
function toast(m,d=3000){const e=$('toast');e.textContent=m;e.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>e.classList.remove('show'),d)}
function openModal(id){$(id).classList.add('show')}
function closeModal(id){$(id).classList.remove('show')}
function escH(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function fmtD(iso){const d=new Date(iso);return`${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`}
function vecP(v){return`[${v.slice(0,4).map(x=>x.toFixed(3)).join(', ')}, …]`}
function estTok(t){return Math.ceil(t.length/3.5)}
document.querySelectorAll('[data-close]').forEach(b=>b.addEventListener('click',()=>closeModal(b.dataset.close)));
document.querySelectorAll('.modal-overlay').forEach(o=>o.addEventListener('click',e=>{if(e.target===o)o.classList.remove('show')}));
/* ═══════════════════════════════════════════════════════════
GPU DETECTION
═══════════════════════════════════════════════════════════ */
async function detectGPU(){
let txt='WebGPU not available — WASM fallback'; let ok=false;
if(navigator.gpu){
try{
const adapter=await navigator.gpu.requestAdapter();
if(adapter){
const info=await adapter.requestAdapterInfo?.()||{};
const disc=info.deviceType==='discrete-gpu'||(info.vendor||'').toLowerCase().includes('nvidia');
txt=disc?`Discrete GPU: ${info.description||info.vendor||'NVIDIA'}`:`Integrated: ${info.description||info.vendor||'GPU'} (discrete recommended)`;
ok=true; ST.useWebGPU=ST.useWebGPU&&ok;
} else { txt='No GPU adapter — WASM fallback'; ST.useWebGPU=false; }
}catch(e){ txt='GPU error — WASM fallback'; ST.useWebGPU=false; }
} else { ST.useWebGPU=false; }
$('gpu-info-hdr').innerHTML=`<i class="fa-solid fa-display" style="color:${ok?'var(--green)':'var(--amber)'}"></i> ${txt}`;
$('settings-gpu').textContent=txt;
}
/* ═══════════════════════════════════════════════════════════
INDEXEDDB
═══════════════════════════════════════════════════════════ */
let idb=null;
async function openIDB(){return new Promise((r,j)=>{const q=indexedDB.open('rag-viz-v3',1);q.onupgradeneeded=e=>e.target.result.createObjectStore('vectors',{keyPath:'id'});q.onsuccess=e=>{idb=e.target.result;r()};q.onerror=j})}
async function idbAll(){return new Promise((r,j)=>{const t=idb.transaction('vectors','readonly');const q=t.objectStore('vectors').getAll();q.onsuccess=e=>r(e.target.result||[]);q.onerror=j})}
async function idbPut(e){return new Promise((r,j)=>{const t=idb.transaction('vectors','readwrite');t.objectStore('vectors').put(e);t.oncomplete=r;t.onerror=j})}
async function idbDel(id){return new Promise((r,j)=>{const t=idb.transaction('vectors','readwrite');t.objectStore('vectors').delete(id);t.oncomplete=r;t.onerror=j})}
/* ═══════════════════════════════════════════════════════════
VECTOR MATH
═══════════════════════════════════════════════════════════ */
function cosineSim(a,b){let dot=0,na=0,nb=0;for(let i=0;i<a.length;i++){dot+=a[i]*b[i];na+=a[i]*a[i];nb+=b[i]*b[i]}return dot/(Math.sqrt(na)*Math.sqrt(nb)+1e-9)}
function vecSearch(qv,k){return ST.db.map(e=>({...e,score:cosineSim(qv,e.vec)})).sort((a,b)=>b.score-a.score).slice(0,k)}
/* ═══════════════════════════════════════════════════════════
DOWNLOAD TRACKING
═══════════════════════════════════════════════════════════ */
function trackDl(p){
if(!p.file)return;
const k=p.file;
if(!ST.dlProgress[k])ST.dlProgress[k]={status:'downloading',progress:0,total:0};
const d=ST.dlProgress[k];
if(p.status==='progress'){d.status='downloading';d.progress=p.progress||0;d.total=p.total||0}
if(p.status==='done'){d.status='done';d.progress=d.total||100}
if(p.status==='error'){d.status='error'}
updateDlBtn(); renderDlModal();
}
function updateDlBtn(){
const btn=$('dl-btn');
const active=Object.values(ST.dlProgress).some(d=>d.status==='downloading');
btn.classList.toggle('active',active);
}
function renderDlModal(){
const el=$('dl-list');
const entries=Object.entries(ST.dlProgress);
if(!entries.length){el.innerHTML='<div class="empty"><div class="empty-icon">📥</div><span>No downloads tracked yet</span></div>';return}
el.innerHTML=entries.map(([name,d])=>{
const pct=d.total?Math.round(d.progress/d.total*100):0;
const icon=d.status==='done'?'✅':d.status==='error'?'⚠️':'⏳';
const stTxt=d.status==='done'?'Complete':d.status==='error'?'Error':`${pct}%`;
return`<div class="dl-item"><div class="dl-item-icon">${icon}</div><div class="dl-item-info"><div class="dl-item-name">${name.split('/').pop()}</div><div class="dl-item-status">${stTxt}${d.total?' · '+(d.progress/1e6).toFixed(1)+'/'+(d.total/1e6).toFixed(1)+' MB':''}</div>${d.status!=='done'?`<div class="dl-item-bar"><div class="dl-item-fill" style="width:${pct}%"></div></div>`:''}</div></div>`
}).join('');
}
/* ═══════════════════════════════════════════════════════════
MODEL LOADING
═══════════════════════════════════════════════════════════ */
function getModelOpts(){
const o={progress_callback:trackDl};
if(ST.useWebGPU&&$('lm-webgpu')?.checked!==false){o.device='webgpu';o.dtype='q4f16'}
return o;
}
function lpS(id,st){const e=$(id);if(!e)return;e.className='lp-item '+st;const i=e.querySelector('.lp-icon');if(st==='active')i.textContent='⏳';if(st==='done')i.textContent='✅';if(st==='error')i.textContent='⚠️'}
async function loadAllModels(){
if(ST.loading)return;
ST.loading=true;
const btn=$('load-hdr-btn');const doBtn=$('do-load-btn');
btn.classList.add('loading');btn.innerHTML='<div class="lh-dot"></div><i class="fa-solid fa-spinner fa-spin"></i> Loading…';
btn.disabled=true; doBtn.disabled=true;
CFG.llmModel=$('lm-llm').value;
CFG.embedModel=$('lm-embed').value;
CFG.rerankModel=$('lm-rerank').value;
ST.useWebGPU=$('lm-webgpu').checked;
$('load-progress').style.display='block';
const opts=getModelOpts();
const bar=p=>{$('lp-fill').style.width=p+'%'};
lpS('lp-embed','active');bar(5);
try{ST.embedder=await pipeline('feature-extraction',CFG.embedModel,opts);lpS('lp-embed','done');bar(25);NODES.find(n=>n.id==='embed').settings.Model=CFG.embedModel}
catch(e){lpS('lp-embed','error');console.error(e);toast('Embedder failed: '+e.message,5000)}
lpS('lp-rerank','active');bar(30);
try{ST.reranker=await pipeline('text-classification',CFG.rerankModel,opts);lpS('lp-rerank','done');bar(45);NODES.find(n=>n.id==='rerank').settings.Model=CFG.rerankModel}
catch(e){lpS('lp-rerank','error');ST.reranker=null;console.warn('Reranker unavailable:',e.message);toast('Reranker unavailable — cosine fallback',4000)}
lpS('lp-llm','active');bar(70);
try{
ST.generator=await pipeline('text-generation',CFG.llmModel,opts);
lpS('lp-llm','done');bar(100);
NODES.find(n=>n.id==='llm').settings.Model=CFG.llmModel;
}catch(e){lpS('lp-llm','error');ST.generator=null;console.error('LLM failed:',e.message);toast('LLM failed — template answers only',5000)}
try{ST.tokenizer=await AutoTokenizer.from_pretrained(CFG.llmModel);NODES.find(n=>n.id==='tokenizer').settings.Model=CFG.llmModel;if(ST.tokenizer?.vocab_size)NODES.find(n=>n.id==='tokenizer').settings.VocabSize=ST.tokenizer.vocab_size}
catch(e){console.warn('Tokenizer failed:',e.message);ST.tokenizer=null}
ST.loading=false; ST.modelsLoaded=true;
btn.classList.remove('loading');btn.classList.add('ready');
btn.innerHTML='<div class="lh-dot"></div><i class="fa-solid fa-check"></i> Models Ready';
doBtn.disabled=false;
if(ST.embedder){
$('chat-input').disabled=false;$('send-btn').disabled=false;
$('chat-input').placeholder='Ask a question…';
$('chat-status').textContent='ready';
$('chat-empty')?.remove();
if(ST.db.length===0)insertSamples();
}
toast('Models loaded successfully');
updateDlBtn();
closeModal('load-modal');
}
/* ═══════════════════════════════════════════════════════════
EMBED / RERANK / GENERATE
═══════════════════════════════════════════════════════════ */
async function embedText(t){const o=await ST.embedder(t,{pooling:'mean',normalize:true});return Array.from(o.data)}
async function rerankRes(q,res){
if(!ST.reranker||!ST.nodeEnabled.rerank)return[];
try{
const outputs=await ST.reranker(res.map(r=>q+' </s> '+r.text));
const scores=outputs.map(out=>{
let s=0;
if(typeof out==='number')s=out;
else if(Array.isArray(out)&&out.length>0)s=out[0]?.score??(typeof out[0]==='number'?out[0]:0);
else if(out&&typeof out.score==='number')s=out.score;
if(s<0||s>1)s=1/(1+Math.exp(-s));
return s;
});
const mn=Math.min(...scores),mx=Math.max(...scores);
let norm;
if(mx>mn)norm=scores.map(s=>(s-mn)/(mx-mn));
else{
const orig=res.map(r=>r.score);
const om=Math.min(...orig),ox=Math.max(...orig);
norm=ox>om?orig.map(s=>(s-om)/(ox-om)):orig.map((_,i)=>(orig.length-i-1)/(orig.length-1));
}
return res.map((r,i)=>({...r,rerankScore:norm[i]})).sort((a,b)=>b.rerankScore-a.rerankScore).slice(0,CFG.topKRerank);
}catch(e){console.warn('Rerank err:',e);return[]}
}
async function genAnswer(q,ctx){
const useVdb=ST.vdbOn&&ctx.length>0;
let messages;
if(useVdb){
const ctxText=ctx.map((c,i)=>`[${i+1}] ${c.text}`).join('\n');
messages=[
{role:'system',content:'You must answer using ONLY the provided context. Do not use your own knowledge.'},
{role:'user',content:`Context:\n${ctxText}\n\nQuestion: ${q}`}
];
} else if(ST.rigidMode){
return'[Rigid Mode] No relevant context found in vector database. Cannot answer.';
} else {
messages=[{role:'user',content:q}];
}
if(!ST.generator){return useVdb?ctx.slice(0,2).map(c=>'• '+c.text.slice(0,120)).join('\n'):'LLM not loaded.'}
try{
const output=await ST.generator(messages,{max_new_tokens:CFG.maxTokens,do_sample:false});
const lastMsg=output[0]?.generated_text;
if(Array.isArray(lastMsg)){
return lastMsg[lastMsg.length-1]?.content?.trim()||'No answer generated.';
}
return lastMsg?.trim()||'No answer generated.';
}catch(e){console.error(e);return useVdb?ctx[0]?.text||'Error':'Generation error.'}
}
/* ═══════════════════════════════════════════════════════════
NODE BAR
═══════════════════════════════════════════════════════════ */
function setNBS(id,st){const e=document.querySelector(`.nb-node[data-node="${id}"]`);if(!e)return;e.classList.remove('active','done');if(st==='active')e.classList.add('active');if(st==='done')e.classList.add('done')}
function allNBIdle(){document.querySelectorAll('.nb-node').forEach(e=>e.classList.remove('active','done'))}
function updateNBDisabled(){document.querySelectorAll('.nb-node').forEach(e=>{e.classList.toggle('disabled',!ST.nodeEnabled[e.dataset.node])})}
/* ═══════════════════════════════════════════════════════════
CHAT UI
═══════════════════════════════════════════════════════════ */
function chatMsg(role,text,ctx=[],tc=0){
const c=$('chat-messages');
const div=document.createElement('div');div.className=`msg ${role} fade-in`;
const uc=ST.vdbOn&&ctx.length>0;
let chip='';
if(role==='assistant'){
chip=uc?'<span class="msg-ctx vdb"><i class="fa-solid fa-database"></i> VDB ('+ctx.length+')</span>':'<span class="msg-ctx own"><i class="fa-solid fa-brain"></i> Own Knowledge</span>';
if(tc)chip+=`<span class="msg-ctx-tokens">${tc} tok</span>`;
}
div.innerHTML=`<div class="msg-bubble">${escH(text).replace(/\n/g,'<br>')}</div><div class="msg-meta">${chip}<span class="msg-time">${new Date().toLocaleTimeString()}</span></div>`;
c.appendChild(div);c.scrollTop=c.scrollHeight;
}
function showTyping(){const c=$('chat-messages');const d=document.createElement('div');d.className='msg assistant';d.id='__typing';d.innerHTML='<div class="msg-bubble"><div class="typing"><i></i><i></i><i></i></div></div>';c.appendChild(d);c.scrollTop=c.scrollHeight}
function rmTyping(){$('__typing')?.remove()}
/* ═══════════════════════════════════════════════════════════
RETRIEVAL UI
═══════════════════════════════════════════════════════════ */
function renderRet(items,lid,rr=false){
const el=$(lid);el.innerHTML='';
if(!items?.length){el.innerHTML=`<div class="empty"><div class="empty-icon">${rr?'🔄':'📚'}</div><span>No results</span></div>`;return}
items.forEach((item,i)=>{
const d=document.createElement('div');d.className=`ret-item ${rr?'sel':''}`;
const sc=rr?(item.rerankScore??item.score):item.score;
d.innerHTML=`<div class="ret-rank">#${i+1}</div><div class="ret-text">${escH(item.text)}</div><div class="ret-foot"><span class="ret-meta">${item.source||'?'} · ${item.category||'?'}</span><div class="score-bar"><div class="score-fill" style="width:0"></div></div><span class="score-pct">${(Math.abs(sc)*100).toFixed(1)}%</span></div>`;
el.appendChild(d);
setTimeout(()=>{d.classList.add(rr?'sel':'lit');d.querySelector('.score-fill').style.width=Math.min(100,Math.abs(sc)*100)+'%'},i*80);
});
}
/* ═══════════════════════════════════════════════════════════
RAG PIPELINE
═══════════════════════════════════════════════════════════ */
async function runRAG(q){
if(ST.busy||!ST.embedder)return;
ST.busy=true;$('send-btn').disabled=true;
$('chat-status').textContent='processing…';$('ret-status').textContent='searching…';
chatMsg('user',q);
showTyping();
$('ret-list').innerHTML='<div class="empty"><div class="empty-icon">⏳</div><span>Searching…</span></div>';
$('rr-list').innerHTML='<div class="empty"><div class="empty-icon">⏳</div><span>Waiting…</span></div>';
allNBIdle();
try{
if(ST.nodeEnabled.query){setNBS('query','active');await sleep(120);setNBS('query','done')}
let queryEmbedding=null;
if(ST.nodeEnabled.embed){
setNBS('embed','active');$('chat-status').textContent='embedding…';
queryEmbedding=await embedText(q);
setNBS('embed','done');
}
if(queryEmbedding && ST.nodeEnabled.search && ST.vdbOn){
setNBS('search','active');$('chat-status').textContent='searching…';await sleep(80);
const ret=vecSearch(queryEmbedding,CFG.topK);
setNBS('search','done');
if(ST.nodeEnabled.topk){setNBS('topk','active');$('ret-k').textContent=`k=${ret.length}`;renderRet(ret,'ret-list');await sleep(250);setNBS('topk','done')}
let ctx=ret;
if(ST.nodeEnabled.rerank){setNBS('rerank','active');$('ret-status').textContent='reranking…';const rr=await rerankRes(q,ret);$('rr-k').textContent=`k=${rr.length}`;renderRet(rr,'rr-list',true);ctx=rr;setNBS('rerank','done')}
else{$('rr-k').textContent='';$('rr-list').innerHTML='<div class="empty"><div class="empty-icon">🔄</div><span>Reranking disabled</span></div>';}
if(ST.nodeEnabled.tokenizer && ST.tokenizer){setNBS('tokenizer','active');try{await ST.tokenizer(q)}catch(e){}setNBS('tokenizer','done')}
if(ST.nodeEnabled.llm){
setNBS('llm','active');$('chat-status').textContent='generating…';
const answer=await genAnswer(q,ctx);
const tc=estTok(ctx.map(c=>c.text).join(' '))+estTok(answer);
setNBS('llm','done');setNBS('answer','active');rmTyping();chatMsg('assistant',answer,ctx,tc);setNBS('answer','done');
}
}else if(ST.nodeEnabled.llm){
setNBS('llm','active');$('chat-status').textContent='generating…';
const ans=await genAnswer(q,[]);
setNBS('llm','done');setNBS('answer','active');rmTyping();chatMsg('assistant',ans,[],estTok(ans));setNBS('answer','done');
}
}catch(e){rmTyping();chatMsg('assistant','Pipeline error: '+e.message);console.error(e)}
$('send-btn').disabled=false;$('chat-status').textContent='ready';$('ret-status').textContent='idle';
ST.busy=false;setTimeout(allNBIdle,1500);
}
/* ═══════════════════════════════════════════════════════════
SAMPLE DATA
═══════════════════════════════════════════════════════════ */
async function insertSamples(){
toast('Loading sample documents…',4000);
const samples=[
{text:'The author of this app is Asif Ahmed. Link: https://quickgrid.github.io/',source:'about.txt',category:'app_info'},
{text:'The Eiffel Tower in Paris was built in 1889 by Gustave Eiffel for the World Fair. It stands 330 metres tall and was the tallest man-made structure for 41 years.',source:'wiki.txt',category:'history'},
{text:'Machine learning is a branch of AI that enables systems to learn from data without being explicitly programmed. Supervised, unsupervised, and reinforcement learning are the main paradigms.',source:'ml-intro.md',category:'tech'},
{text:'The Amazon River carries more water than any other river on Earth, discharging about 209,000 cubic metres per second into the Atlantic Ocean.',source:'geo.txt',category:'geography'},
{text:'Python is a high-level interpreted language prized for readability. It is widely used in data science, web development, automation, and artificial intelligence.',source:'prog.md',category:'tech'},
{text:'The speed of light in a vacuum is exactly 299,792,458 metres per second. This is a fundamental constant denoted by c in physics equations.',source:'physics.txt',category:'science'},
{text:'Transformers are a neural network architecture introduced in "Attention is All You Need" (2017). Self-attention mechanisms allow modeling relationships between all positions in a sequence.',source:'dl-paper.txt',category:'AI'},
{text:'Retrieval-Augmented Generation (RAG) combines a retriever to find relevant passages from a knowledge base with a language model to generate grounded answers.',source:'rag-overview.md',category:'AI'},
];
for(const s of samples){
try{const vec=await embedText(s.text);const e={id:Date.now().toString(36)+Math.random().toString(36).slice(2,5),text:s.text,vec,source:s.source,category:s.category,date:new Date().toISOString()};ST.db.push(e);await idbPut(e)}catch(e){console.error(e)}
await sleep(60);
}
toast('Sample documents loaded — try asking a question!',4000);
renderVdbTools();
}
/* ═══════════════════════════════════════════════════════════
VDB MODAL (CRUD)
═══════════════════════════════════════════════════════════ */
function getFilteredDB(){
let d=[...ST.db];
if(ST.vdbFilter){const f=ST.vdbFilter.toLowerCase();d=d.filter(e=>e.text.toLowerCase().includes(f)||(e.source||'').toLowerCase().includes(f)||(e.category||'').toLowerCase().includes(f))}
d.sort((a,b)=>{let va=a[ST.sortCol],vb=b[ST.sortCol];if(typeof va==='string')va=va.toLowerCase();if(typeof vb==='string')vb=vb.toLowerCase();return(va<vb?-1:va>vb?1:0)*ST.sortDir});
return d;
}
function renderVdbTbl(){
const d=getFilteredDB();const tb=$('vdb-tbody');tb.innerHTML='';
d.forEach((e,i)=>{const tr=document.createElement('tr');tr.dataset.id=e.id;if(e.id===ST.selectedVdbId)tr.classList.add('selected');
tr.innerHTML=`<td style="color:var(--text3)">${i+1}</td><td style="font-family:var(--sans);font-size:10px">${escH(e.text.length>50?e.text.slice(0,50)+'…':e.text)}</td><td><span class="tag">${escH(e.source||'—')}</span></td><td style="color:var(--text2)">${escH(e.category||'—')}</td><td style="color:var(--text3)">${fmtD(e.date)}</td>`;
tr.addEventListener('click',()=>selectVdb(e.id));tb.appendChild(tr)});
$('vdb-count-modal').textContent=`${d.length} / ${ST.db.length} entries`;
$('vdb-del-btn').disabled=!ST.selectedVdbId;
document.querySelectorAll('.vdb-tbl th').forEach(th=>{const a=th.querySelector('.sa');if(th.dataset.col===ST.sortCol){a.textContent=ST.sortDir===1?'▲':'▼';a.style.color='var(--cyan)'}else{a.textContent='▲';a.style.color='var(--text3)'}});
}
function selectVdb(id){
ST.selectedVdbId=id;renderVdbTbl();
const e=ST.db.find(x=>x.id===id);if(!e)return;
const b=$('vdb-detail-body');
b.innerHTML=`<div class="vd-row"><div class="vd-label">ID</div><div class="vd-value" style="font-family:var(--mono);font-size:9px;color:var(--text3)">${e.id}</div></div>
<div class="vd-row"><div class="vd-label">Text</div><div class="vd-value">${escH(e.text)}</div></div>
<div class="vd-row"><div class="vd-label">Source</div><div class="vd-value">${escH(e.source||'—')}</div></div>
<div class="vd-row"><div class="vd-label">Category</div><div class="vd-value">${escH(e.category||'—')}</div></div>
<div class="vd-row"><div class="vd-label">Date</div><div class="vd-value">${fmtD(e.date)}</div></div>
<div class="vd-row"><div class="vd-label">Embedding (${e.vec?.length||0}d)</div><div class="vd-vec" id="vd-vec-t">${vecP(e.vec||[])}</div><button class="btn btn-ghost btn-sm" onclick="window._copyVec()" style="margin-top:3px"><i class="fa-solid fa-copy"></i> Copy</button></div>
<div class="vd-actions"><button class="btn btn-ghost btn-sm" id="vd-edit-btn"><i class="fa-solid fa-pen"></i> Edit</button><button class="btn btn-red btn-sm" id="vd-del-btn2"><i class="fa-solid fa-trash"></i> Delete</button></div>`;
b.querySelector('#vd-edit-btn').addEventListener('click',()=>openVdbEdit(e));
b.querySelector('#vd-del-btn2').addEventListener('click',()=>delVdb(e.id));
}
window._copyVec=function(){const e=ST.db.find(x=>x.id===ST.selectedVdbId);if(!e)return;navigator.clipboard.writeText(JSON.stringify(e.vec)).then(()=>toast('Vector copied'))};
async function openVdbEdit(entry=null){
$('ve-title').textContent=entry?'Edit Entry':'Add Entry';
$('ve-text').value=entry?.text||'';$('ve-source').value=entry?.source||'';$('ve-category').value=entry?.category||'';
$('ve-save-btn').textContent=entry?'Update & Re-embed':'Save & Embed';
$('ve-save-btn').dataset.editId=entry?.id||'';
openModal('vdb-edit-modal');
}
async function saveVdb(){
const text=$('ve-text').value.trim();if(!text){toast('Enter text');return}
if(!ST.embedder){toast('Embedder not loaded');return}
$('ve-save-btn').disabled=true;$('ve-progress').style.display='block';$('ve-prog-txt').textContent='Computing embedding…';
let pct=0;const pt=setInterval(()=>{pct=Math.min(pct+10,90);$('ve-fill').style.width=pct+'%'},100);
try{
const vec=await embedText(text);clearInterval(pt);$('ve-fill').style.width='100%';
const eid=$('ve-save-btn').dataset.editId;
if(eid){const idx=ST.db.findIndex(e=>e.id===eid);if(idx>=0){ST.db[idx].text=text;ST.db[idx].vec=vec;ST.db[idx].source=$('ve-source').value.trim()||ST.db[idx].source;ST.db[idx].category=$('ve-category').value.trim()||ST.db[idx].category;ST.db[idx].date=new Date().toISOString();await idbPut(ST.db[idx]);toast('Updated — vector recalculated')}}
else{const e={id:Date.now().toString(36)+Math.random().toString(36).slice(2,6),text,vec,source:$('ve-source').value.trim()||'manual',category:$('ve-category').value.trim()||'general',date:new Date().toISOString()};ST.db.push(e);await idbPut(e);toast('Added to vector DB')}
renderVdbTbl();renderVdbTools();if(ST.selectedVdbId)selectVdb(ST.selectedVdbId);closeModal('vdb-edit-modal');
}catch(e){clearInterval(pt);toast('Error: '+e.message,5000)}
$('ve-progress').style.display='none';$('ve-fill').style.width='0';$('ve-save-btn').disabled=false;
}
async function delVdb(id){ST.db=ST.db.filter(e=>e.id!==id);try{await idbDel(id)}catch(e){}ST.selectedVdbId=null;renderVdbTbl();renderVdbTools();$('vdb-detail-body').innerHTML='<div class="empty"><div class="empty-icon">🗑️</div><span>Deleted</span></div>';toast('Deleted')}
/* ═══════════════════════════════════════════════════════════
VDB TOOLS LIST (compact view below tokenizer)
═══════════════════════════════════════════════════════════ */
function renderVdbTools(){
const el=$('vdb-tools-list');
$('vdb-tools-count').textContent=`${ST.db.length} entries`;
if(!ST.db.length){el.innerHTML='<div class="vdb-tools-empty"><div class="empty-icon">📭</div><span>No entries in vector database</span></div>';return}
el.innerHTML=ST.db.map((e,i)=>{
const sel=e.id===ST.selectedVdbId?' selected':'';
return`<div class="vdb-tools-item${sel}" data-id="${e.id}"><span class="vdb-tools-num">${i+1}</span><span class="vdb-tools-text" title="${escH(e.text)}">${escH(e.text.length>40?e.text.slice(0,40)+'…':e.text)}</span><span class="vdb-tools-meta">${escH(e.source||'—')} · ${escH(e.category||'—')}</span></div>`;
}).join('');
el.querySelectorAll('.vdb-tools-item').forEach(item=>{
item.addEventListener('click',()=>{
ST.selectedVdbId=item.dataset.id;
renderVdbTools();
openModal('vdb-modal');
renderVdbTbl();
selectVdb(item.dataset.id);
});
});
}
/* ═══════════════════════════════════════════════════════════
NODE EDITOR MODAL
═══════════════════════════════════════════════════════════ */
let neDrag=null,neConn=null;const nePos={};
function initNodeEditor(){
const wrap=$('ne-wrap'),nEl=$('ne-nodes'),svg=$('ne-svg');
const wW=Math.max(wrap.clientWidth-150,400),wH=Math.max(wrap.clientHeight-70,300);
const cols=4,nodeW=130,nodeH=70;
const rows=Math.ceil(NODES.length/cols);
const gapX=wW>cols*nodeW?(wW-cols*nodeW)/Math.max(cols-1,1):30;
const gapY=wH>rows*nodeH?(wH-rows*nodeH)/Math.max(rows-1,1):40;
NODES.forEach((n,i)=>{
if(!nePos[n.id]){
const col=i%cols;
const row=Math.floor(i/cols);
nePos[n.id]={x:30+col*(nodeW+gapX),y:20+row*(nodeH+gapY)};
}
});
function render(){
nEl.innerHTML='';svg.innerHTML='';
EDGES.forEach(([f,t])=>{if(!ST.nodeEnabled[f]||!ST.nodeEnabled[t])return;const fp=nePos[f],tp=nePos[t];if(!fp||!tp)return;
const x1=fp.x+120,y1=fp.y+32,x2=tp.x,y2=tp.y+32,cx=(x1+x2)/2;
const p=document.createElementNS('http://www.w3.org/2000/svg','path');
p.setAttribute('d',`M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`);
if(neConn&&((neConn.from===f&&neConn.to===t)||(neConn.from===t&&neConn.to===f)))p.classList.add('hl');
svg.appendChild(p)});
NODES.forEach(n=>{const p=nePos[n.id];if(!p)return;const d=document.createElement('div');
d.className=`ne-node${!ST.nodeEnabled[n.id]?' disabled-node':''}`;d.dataset.id=n.id;d.style.left=p.x+'px';d.style.top=p.y+'px';
d.innerHTML=`<div class="ne-port in" data-port="in" data-node="${n.id}"></div><div class="ne-port out" data-port="out" data-node="${n.id}"></div>
<div class="ne-node-head"><span class="ne-node-icon">${n.icon}</span><span class="ne-node-label" style="color:${n.color}">${n.label}</span><button class="ne-node-toggle ${ST.nodeEnabled[n.id]?'on':''}" data-toggle="${n.id}">${ST.nodeEnabled[n.id]?'ON':'OFF'}</button></div>
<div class="ne-node-info">${n.settings.Model||n.settings.Type||''}</div>`;
nEl.appendChild(d)});
}
nEl.addEventListener('mousedown',e=>{
const port=e.target.closest('.ne-port');if(port){e.preventDefault();e.stopPropagation();neConn={from:port.dataset.node,port:port.dataset.port};render();return}
const tog=e.target.closest('.ne-node-toggle');if(tog){e.preventDefault();e.stopPropagation();const nid=tog.dataset.toggle;ST.nodeEnabled[nid]=!ST.nodeEnabled[nid];updateNBDisabled();render();return}
const node=e.target.closest('.ne-node');if(node){e.preventDefault();const id=node.dataset.id;const r=wrap.getBoundingClientRect();neDrag={id,ox:e.clientX-r.left-nePos[id].x,oy:e.clientY-r.top-nePos[id].y}}
});
document.addEventListener('mousemove',e=>{if(neDrag){const r=wrap.getBoundingClientRect();nePos[neDrag.id].x=Math.max(0,e.clientX-r.left-neDrag.ox);nePos[neDrag.id].y=Math.max(0,e.clientY-r.top-neDrag.oy);render()}});
document.addEventListener('mouseup',e=>{if(neConn){const port=e.target.closest('.ne-port');if(port&&port.dataset.node!==neConn.from){const f=neConn.from,t=port.dataset.node;const idx=EDGES.findIndex(e=>(e[0]===f&&e[1]===t)||(e[0]===t&&e[1]===f));if(idx>=0)EDGES.splice(idx,1);else EDGES.push([f,t])}neConn=null;render()}neDrag=null});
nEl.addEventListener('dblclick',e=>{const n=e.target.closest('.ne-node');if(n)openNodeConfig(n.dataset.id)});
$('ne-reset-btn').addEventListener('click',()=>{
const wW=Math.max(wrap.clientWidth-150,400),wH=Math.max(wrap.clientHeight-70,300);
const cols=4,nodeW=130,nodeH=70;
const rows=Math.ceil(NODES.length/cols);
const gapX=wW>cols*nodeW?(wW-cols*nodeW)/Math.max(cols-1,1):30;
const gapY=wH>rows*nodeH?(wH-rows*nodeH)/Math.max(rows-1,1):40;
NODES.forEach((n,i)=>{
const col=i%cols;
const row=Math.floor(i/cols);
nePos[n.id]={x:30+col*(nodeW+gapX),y:20+row*(nodeH+gapY)};
});
render();
});
render();
}
function openNodeConfig(nid){
const n=NODES.find(x=>x.id===nid);if(!n)return;
$('nc-title').textContent=`${n.icon} ${n.label}`;
const b=$('nc-body');let h='<div style="display:flex;flex-direction:column;gap:10px">';
h+=`<div class="lm-row"><div class="lm-label">Enabled</div><label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="nc-en" ${ST.nodeEnabled[nid]?'checked':''} style="accent-color:var(--green)"><span style="font-size:11px">${ST.nodeEnabled[nid]?'Yes':'No'}</span></label></div>`;
Object.entries(n.settings).forEach(([k,v])=>{
if(k==='Model'&&['embed','rerank','llm','tokenizer'].includes(nid)){
const opts={embed:[['Xenova/all-MiniLM-L6-v2','all-MiniLM-L6-v2']],rerank:[['Xenova/ms-marco-MiniLM-L-6-v2','ms-marco-MiniLM-L-6-v2']],llm:[['onnx-community/Qwen3.5-0.8B-ONNX','Qwen 3.5 0.8B ONNX'],['Xenova/LaMini-Flan-T5-248M','LaMini-Flan-T5-248M']],tokenizer:[['onnx-community/Qwen3.5-0.8B-ONNX','Qwen 3.5 0.8B ONNX'],['Xenova/LaMini-Flan-T5-248M','LaMini-Flan-T5-248M']]}; h+=`<div class="lm-row"><div class="lm-label">${k}</div><select id="nc-mod-${nid}">${(opts[nid]||[]).map(([val,lab])=>`<option value="${val}" ${String(n.settings[k])===val?'selected':''}>${lab}</option>`).join('')}</select></div>`;
h+=`<div><button class="btn btn-ghost btn-sm" id="nc-rl-${nid}"><i class="fa-solid fa-rotate"></i> Load This Model</button></div>`;
}else{h+=`<div class="lm-row"><div class="lm-label">${k}</div><input value="${v}" style="background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:6px 8px;border-radius:4px;font-family:var(--mono);font-size:11px;width:100%"></div>`}
});
h+='</div>';b.innerHTML=h;openModal('node-config-modal');
b.querySelector('#nc-en')?.addEventListener('change',e=>{ST.nodeEnabled[nid]=e.target.checked;updateNBDisabled();e.target.nextElementSibling.textContent=e.target.checked?'Yes':'No'});
b.querySelector(`#nc-rl-${nid}`)?.addEventListener('click',async()=>{
const sel=b.querySelector(`#nc-mod-${nid}`);if(!sel)return;const mid=sel.value;const btn=b.querySelector(`#nc-rl-${nid}`);
btn.disabled=true;btn.innerHTML='<i class="fa-solid fa-spinner fa-spin"></i> Loading…';
try{const opts=getModelOpts();
if(nid==='embed')ST.embedder=await pipeline('feature-extraction',mid,opts);
else if(nid==='rerank')ST.reranker=await pipeline('text-classification',mid,opts);
else if(nid==='llm')ST.generator=await pipeline('text-generation',mid,opts);
else if(nid==='tokenizer')ST.tokenizer=await AutoTokenizer.from_pretrained(mid);
n.settings.Model=mid;toast(`${n.label}: loaded ${mid}`);
}catch(e){toast('Failed: '+e.message,5000)}
btn.disabled=false;btn.innerHTML='<i class="fa-solid fa-rotate"></i> Load This Model';
});
}
/* ═══════════════════════════════════════════════════════════
TOKENIZER VIS (uses chat input)
═══════════════════════════════════════════════════════════ */
const TKCOL=[
{text:'#ff8080',bg:'rgba(255,128,128,.15)',border:'rgba(255,128,128,.35)'},
{text:'#ffb84d',bg:'rgba(255,184,77,.15)',border:'rgba(255,184,77,.35)'},
{text:'#ffe066',bg:'rgba(255,224,102,.15)',border:'rgba(255,224,102,.35)'},
{text:'#7aed91',bg:'rgba(122,237,145,.15)',border:'rgba(122,237,145,.35)'},
{text:'#4ddfc0',bg:'rgba(77,223,192,.15)',border:'rgba(77,223,192,.35)'},
{text:'#56c8f5',bg:'rgba(86,200,245,.15)',border:'rgba(86,200,245,.35)'},
{text:'#748ef8',bg:'rgba(116,142,248,.15)',border:'rgba(116,142,248,.35)'},
{text:'#c484f8',bg:'rgba(196,132,248,.15)',border:'rgba(196,132,248,.35)'},
{text:'#f57cd4',bg:'rgba(245,124,212,.15)',border:'rgba(245,124,212,.35)'},
{text:'#fa8072',bg:'rgba(250,128,114,.15)',border:'rgba(250,128,114,.35)'},
{text:'#8be08b',bg:'rgba(139,224,139,.15)',border:'rgba(139,224,139,.35)'},
{text:'#f0c040',bg:'rgba(240,192,64,.15)',border:'rgba(240,192,64,.35)'},
{text:'#60d4e0',bg:'rgba(96,212,224,.15)',border:'rgba(96,212,224,.35)'},
{text:'#e89060',bg:'rgba(232,144,96,.15)',border:'rgba(232,144,96,.35)'},
];
function decodeTokenString(raw){
if(!raw)return'';
let s=raw.replace(/^Ġ/,' ').replace(/Ġ/g,' ');
s=s.replace(/^▁/,' ').replace(/▁/g,' ');
s=s.replace(/Ċ/g,'\n');
s=s.replace(/\r/g,'');
s=s.replace(/<0x([0-9A-Fa-f]{2})>/g,(_,hex)=>{const code=parseInt(hex,16);return code<128?String.fromCharCode(code):`[0x${hex}]`;});
return s;
}
function setTokStats(tokens,text){
const chars=text.length;
const words=text.trim()?text.trim().split(/\s+/).length:0;
const ratio=tokens>0&&chars>0?(chars/tokens).toFixed(2):'—';
$('tok-stat-tokens').textContent=tokens>0?tokens.toLocaleString():'—';
$('tok-stat-chars').textContent=chars>0?chars.toLocaleString():'—';
$('tok-stat-words').textContent=words>0?words.toLocaleString():'—';
$('tok-stat-ratio').textContent=ratio;
const modelName=ST.tokenizer?CFG.llmModel.split('/').pop():'no model';
$('tok-stat-model').textContent=modelName;
}
let lastTokText='';
let lastTokTokens=null;
function renderTokView(tokens){
const display=$('token-display');
const placeholder=$('token-placeholder');
if(!tokens||!tokens.length){
display.innerHTML='';
if(placeholder)placeholder.style.display='flex';
return;
}
if(placeholder)placeholder.style.display='none';
if(ST.tokView==='text')renderTokTextView(tokens);
else if(ST.tokView==='ids')renderTokIdView(tokens);
else if(ST.tokView==='list')renderTokListView(tokens);
}
function renderTokTextView(tokens){
const display=$('token-display');
const html=tokens.map((tok,i)=>{
const c=TKCOL[i%TKCOL.length];
let disp='';
if(tok.display===' ')disp='&nbsp;';
else if(tok.display==='\n')disp='↵<br>';
else if(tok.display==='\t')disp='→&nbsp;&nbsp;&nbsp;';
else disp=escH(tok.display);
const rawEsc=escH(tok.raw||'(empty)');
return`<span class="tok" style="background:${c.bg};color:${c.text};border-bottom:2px solid ${c.border}">${disp}<div class="tok-tooltip"><span class="tok-tooltip-id">#${tok.id}</span> · <span class="tok-tooltip-text">${rawEsc}</span></div></span>`;
}).join('');
display.innerHTML=`<div class="token-text-view token-view-container fade-in">${html}</div>`;
}
function renderTokIdView(tokens){
const display=$('token-display');
const html=tokens.map((tok,i)=>{
const c=TKCOL[i%TKCOL.length];
const txt=escH(tok.display.slice(0,8).replace(/\n/g,'↵').replace(/\t/g,'→'))||'…';
return`<div class="tok-id-card" style="background:${c.bg};border-color:${c.border}" title="Raw: ${escH(tok.raw)}"><div class="tok-id-top" style="color:${c.text}">${tok.id}</div><div class="tok-id-bottom">${txt}</div></div>`;
}).join('');
display.innerHTML=`<div class="token-id-view token-view-container fade-in">${html}</div>`;
}
function renderTokListView(tokens){
const display=$('token-display');
const html=tokens.map((tok,i)=>{
const c=TKCOL[i%TKCOL.length];
const txt=escH(tok.display.replace(/\n/g,'↵').replace(/\t/g,'→'))||'(empty)';
return`<div class="tok-split-row" style="background:${c.bg};border-color:${c.border}"><div class="tok-split-idx">${i}</div><div class="tok-split-text" style="color:${c.text}">${txt}</div><div class="tok-split-id">${tok.id}</div></div>`;
}).join('');
display.innerHTML=`<div class="token-split-view token-view-container fade-in">${html}</div>`;
}
let tokDebounce=null;
async function tokViz(){
if(!ST.tokenizerVizEnabled) return;
const text=$('chat-input').value;
if(text===lastTokText&&lastTokTokens){
setTokStats(lastTokTokens.length,text);
renderTokView(lastTokTokens);
return;
}
if(!text.trim()){
setTokStats(0,text);
const display=$('token-display');
display.innerHTML='';
$('token-placeholder').style.display='flex';
lastTokText=text;lastTokTokens=null;
return;
}
if(!ST.tokenizer){
setTokStats(0,text);
const display=$('token-display');
display.innerHTML='';
$('token-placeholder').style.display='flex';
lastTokText=text;lastTokTokens=null;
return;
}
try{
const encoded=await ST.tokenizer(text,{add_special_tokens:false});
const ids=Array.from(encoded.input_ids.data);
let rawTokens;
try{rawTokens=ST.tokenizer.model.convert_ids_to_tokens(ids);}
catch{rawTokens=await Promise.all(ids.map(id=>ST.tokenizer.decode([id],{skip_special_tokens:false})));}
const tokens=ids.map((id,i)=>({
id,raw:rawTokens[i]||'',display:decodeTokenString(rawTokens[i]||''),
}));
setTokStats(tokens.length,text);
renderTokView(tokens);
lastTokText=text;lastTokTokens=tokens;
}catch(e){
console.error('Tokenization error:',e);
}
}
function initTokViz(){
$('chat-input').addEventListener('input',()=>{
clearTimeout(tokDebounce);
tokDebounce=setTimeout(tokViz,50);
});
document.querySelectorAll('#tok-toggle-group .tok-toggle-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('#tok-toggle-group .tok-toggle-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
ST.tokView=btn.dataset.view;
tokViz();
});
});
}
/* ═══════════════════════════════════════════════════════════
PANEL MANAGEMENT
═══════════════════════════════════════════════════════════ */
function initPanels(){
const panels = Array.from(document.querySelectorAll('#main > .panel'));
const MIN_W = 180;
document.querySelectorAll('.resize-h').forEach(h => {
let startX, startW, startWNext, panel, nextEl;
h.addEventListener('mousedown', e => {
e.preventDefault();
panel = h.parentElement;
if(panel.classList.contains('maximized')) return;
const siblings = Array.from(document.querySelectorAll('#main > *'));
const idx = siblings.indexOf(panel);
nextEl = siblings[idx + 1];
if(!nextEl) return;
h.classList.add('active');
panel.style.willChange = 'width';
nextEl.style.willChange = 'width';
startX = e.clientX;
startW = panel.offsetWidth;
startWNext = nextEl.offsetWidth;
const onMove = e2 => {
const delta = e2.clientX - startX;
const newW = Math.max(MIN_W, startW + delta);
const newWNext = Math.max(MIN_W, startWNext - delta);
if(newW >= MIN_W && newWNext >= MIN_W) {
panel.style.flex = 'none';
panel.style.width = newW + 'px';
nextEl.style.flex = 'none';
nextEl.style.width = newWNext + 'px';
}
};
const onUp = () => {
h.classList.remove('active');
panel.style.willChange = '';
nextEl.style.willChange = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
document.querySelectorAll('.ph-btn[data-action]').forEach(b => {
b.addEventListener('click', () => {
const panel = b.closest('.panel');
const action = b.dataset.action;
const allPanels = document.querySelectorAll('#main .panel');
if(action === 'minimize'){
const isMin = panel.classList.toggle('minimized');
b.classList.toggle('active', isMin);
if(isMin){
ST.panelStates[panel.dataset.panel] = {
maximized: panel.classList.contains('maximized'),
flex: panel.style.flex,
width: panel.style.width
};
panel.classList.remove('maximized');
panel.style.flex = '';
panel.style.width = '';
} else {
const state = ST.panelStates[panel.dataset.panel];
if(state){
if(state.maximized) panel.classList.add('maximized');
panel.style.flex = state.flex || '';
panel.style.width = state.width || '';
delete ST.panelStates[panel.dataset.panel];
}
}
}
if(action === 'maximize'){
const isMax = panel.classList.toggle('maximized');
b.classList.toggle('active', isMax);
if(isMax){
ST.panelStates[panel.dataset.panel] = {
minimized: panel.classList.contains('minimized'),
flex: panel.style.flex,
width: panel.style.width
};
panel.classList.remove('minimized');
panel.style.flex = 'none';
panel.style.width = '100%';
allPanels.forEach(p => {
if(p !== panel && !p.classList.contains('minimized')){
p.classList.add('minimized');
if(!ST.panelStates[p.dataset.panel]) ST.panelStates[p.dataset.panel] = {};
ST.panelStates[p.dataset.panel].autoMinimized = true;
}
});
} else {
allPanels.forEach(p => {
const state = ST.panelStates[p.dataset.panel];
if(state && state.autoMinimized){
p.classList.remove('minimized');
delete state.autoMinimized;
}
});
const state = ST.panelStates[panel.dataset.panel];
if(state){
if(state.minimized) panel.classList.add('minimized');
panel.style.flex = state.flex || '';
panel.style.width = state.width || '';
delete ST.panelStates[panel.dataset.panel];
} else {
panel.style.flex = '';
panel.style.width = '';
}
}
}
});
});
}
/* ═══════════════════════════════════════════════════════════
CHAT INPUT AUTO-RESIZE
═══════════════════════════════════════════════════════════ */
function initAutoResize(){
const ta=$('chat-input');
function resize(){
ta.style.height='auto';
ta.style.overflowY='hidden';
const computed=getComputedStyle(ta);
const lh=parseFloat(computed.lineHeight)||(parseFloat(computed.fontSize)*1.4);
const pt=parseFloat(computed.paddingTop);
const pb=parseFloat(computed.paddingBottom);
const bt=parseFloat(computed.borderTopWidth);
const bb=parseFloat(computed.borderBottomWidth);
const maxH=lh*5+pt+pb+bt+bb;
const newH=Math.min(ta.scrollHeight,maxH);
ta.style.height=newH+'px';
ta.style.overflowY=ta.scrollHeight>maxH?'auto':'hidden';
}
ta.addEventListener('input',resize);
resize();
}
/* ═══════════════════════════════════════════════════════════
SETTINGS
═══════════════════════════════════════════════════════════ */
async function clearCache(){
if(!caches)return toast('Cache API not available');
const names=await caches.keys();
for(const n of names){if(n.includes('huggingface')||n.includes('transformers')||n.includes('onnx'))await caches.delete(n)}
toast('Model cache cleared — reload to re-download');
}
async function clearVdb(){
ST.db=[];ST.selectedVdbId=null;
if(idb){const t=idb.transaction('vectors','readwrite');t.objectStore('vectors').clear()}
renderVdbTbl();renderVdbTools();$('vdb-detail-body').innerHTML='<div class="empty"><div class="empty-icon">🗑️</div><span>Cleared</span></div>';toast('Vector database cleared');
}
/* ═══════════════════════════════════════════════════════════
TOKENIZER LAYOUT
═══════════════════════════════════════════════════════════ */
function updateTokenizerLayout(){
const toolsPanel = document.querySelector('.panel[data-panel="tools"]');
const vdbPanel = document.querySelector('.panel[data-panel="vdb"]');
if(!toolsPanel || !vdbPanel) return;
if(ST.tokenizerVizEnabled){
toolsPanel.classList.remove('hidden');
vdbPanel.classList.remove('full-height');
} else {
toolsPanel.classList.add('hidden');
vdbPanel.classList.add('full-height');
}
}
/* ═══════════════════════════════════════════════════════════
INIT
═══════════════════════════════════════════════════════════ */
async function init(){
detectGPU();
try{await openIDB();ST.db=await idbAll()}catch(e){console.warn('IndexedDB error:',e)}
$('load-hdr-btn').addEventListener('click',()=>openModal('load-modal'));
$('do-load-btn').addEventListener('click',loadAllModels);
$('send-btn').addEventListener('click',()=>{
const q=$('chat-input').value.trim();
if(q){
$('chat-input').value='';
$('chat-input').dispatchEvent(new Event('input'));
runRAG(q);
}
});
$('chat-input').addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey&&!ST.busy){
e.preventDefault();
const q=$('chat-input').value.trim();
if(q){
$('chat-input').value='';
$('chat-input').dispatchEvent(new Event('input'));
runRAG(q);
}
}
});
$('toggle-vdb').addEventListener('click',function(){ST.vdbOn=!ST.vdbOn;this.classList.toggle('on',ST.vdbOn)});
$('toggle-rigid').addEventListener('click',function(){ST.rigidMode=!ST.rigidMode;this.classList.toggle('on',ST.rigidMode);if(ST.rigidMode){ST.vdbOn=true;$('toggle-vdb').classList.add('on')}});
document.querySelectorAll('.nb-node').forEach(n=>n.addEventListener('click',()=>openNodeConfig(n.dataset.node)));
$('open-node-editor').addEventListener('click',()=>{openModal('node-modal');setTimeout(initNodeEditor,50)});
$('open-vdb-btn').addEventListener('click',()=>{openModal('vdb-modal');renderVdbTbl()});
$('vdb-search').addEventListener('input',e=>{ST.vdbFilter=e.target.value;renderVdbTbl()});
$('vdb-add-btn').addEventListener('click',()=>openVdbEdit());
$('vdb-del-btn').addEventListener('click',()=>{if(ST.selectedVdbId)delVdb(ST.selectedVdbId)});
$('ve-save-btn').addEventListener('click',saveVdb);
document.querySelectorAll('.vdb-tbl th').forEach(th=>th.addEventListener('click',()=>{const c=th.dataset.col;if(ST.sortCol===c)ST.sortDir*=-1;else{ST.sortCol=c;ST.sortDir=-1}renderVdbTbl()}));
$('dl-btn').addEventListener('click',()=>{renderDlModal();openModal('dl-modal')});
$('settings-btn').addEventListener('click',openModal.bind(null,'settings-modal'));
$('clear-cache-btn').addEventListener('click',clearCache);
$('clear-vdb-btn').addEventListener('click',clearVdb);
$('toggle-tok-viz').addEventListener('change', function(){
ST.tokenizerVizEnabled = this.checked;
$('toggle-tok-viz-label').textContent = this.checked ? 'Enabled' : 'Disabled';
updateTokenizerLayout();
});
initPanels();
initTokViz();
initAutoResize();
updateNBDisabled();
renderVdbTools();
updateTokenizerLayout();
}
init();
</script>
</body>
</html>