Spaces:
Running
Running
| <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);max-width:var(--sidebar-w);width:var(--sidebar-w);border-left-color:var(--border);border-right-color:var(--border)} | |
| .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;inset:0;z-index:40;border:none;min-width:0;max-width:none;width:auto} | |
| .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;width:auto;flex:none;border-left:none} | |
| #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">×</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">×</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">×</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">×</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">×</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">×</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">×</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,'&').replace(/</g,'<').replace(/>/g,'>')} | |
| 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=' '; | |
| else if(tok.display==='\n')disp='↵<br>'; | |
| else if(tok.display==='\t')disp='→ '; | |
| 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> |