Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>RAG Visualizer</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{--bg:#09090b;--bg2:#18181b;--bg3:#27272a;--fg:#fafaf9;--muted:#a1a1aa;--accent:#f59e0b;--accent2:#d97706;--border:#3f3f46;--card:#1c1c1f;--success:#22c55e;--error:#ef4444;--info:#06b6d4;--node-input:#06b6d4;--node-embed:#f59e0b;--node-search:#22c55e;--node-rerank:#f97316;--node-llm:#ef4444;--node-output:#14b8a6} | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:'Space Grotesk',sans-serif;background:var(--bg);color:var(--fg);height:100vh;overflow:hidden;display:flex;flex-direction:column} | |
| .mono{font-family:'IBM Plex Mono',monospace} | |
| header{height:54px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0;z-index:50} | |
| header h1{font-size:16px;font-weight:700;letter-spacing:-.5px;white-space:nowrap} | |
| header h1 span{color:var(--accent)} | |
| .hdr-btn{height:32px;padding:0 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--fg);font-size:12px;font-family:inherit;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .2s} | |
| .hdr-btn:hover{border-color:var(--accent);color:var(--accent)} | |
| .hdr-btn.primary{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600} | |
| .hdr-btn.primary:hover{background:var(--accent2)} | |
| .hdr-btn.danger{border-color:var(--error);color:var(--error)} | |
| .hdr-btn.danger:hover{background:var(--error);color:#fff} | |
| .spacer{flex:1} | |
| main{flex:1;display:flex;overflow:hidden} | |
| /* Node Panel */ | |
| #nodePanel{width:260px;min-width:260px;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 12px;display:flex;flex-direction:column;align-items:center;gap:0;position:relative} | |
| .node-col{display:flex;flex-direction:column;align-items:center;width:100%;position:relative} | |
| .node-box{width:220px;background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden;cursor:pointer;transition:all .3s;position:relative} | |
| .node-box:hover{transform:translateY(-1px);border-color:var(--muted)} | |
| .node-box.active{border-color:var(--accent);box-shadow:0 0 20px rgba(245,158,11,.15)} | |
| .node-box.processing{animation:nodePulse 1s ease-in-out infinite} | |
| .node-box.done{border-color:var(--success)} | |
| .node-header{height:32px;display:flex;align-items:center;padding:0 10px;gap:8px;font-size:12px;font-weight:600} | |
| .node-header i{font-size:11px} | |
| .node-body{padding:8px 10px;font-size:10px;color:var(--muted);min-height:28px;display:flex;align-items:center;justify-content:space-between} | |
| .node-status{width:8px;height:8px;border-radius:50%;background:var(--bg3);border:1px solid var(--border);flex-shrink:0} | |
| .node-status.idle{background:var(--bg3)} | |
| .node-status.loading{background:var(--accent);animation:blink 1s infinite} | |
| .node-status.active{background:var(--success);box-shadow:0 0 8px var(--success)} | |
| .node-status.error{background:var(--error)} | |
| .node-connector{width:2px;height:28px;background:var(--border);position:relative} | |
| .node-connector::after{content:'';position:absolute;top:0;left:50%;transform:translateX(-50%);width:6px;height:6px;border-radius:50%;background:var(--border)} | |
| .node-connector.active{background:var(--accent)} | |
| .node-connector.active::after{background:var(--accent);box-shadow:0 0 6px var(--accent)} | |
| @keyframes nodePulse{0%,100%{box-shadow:0 0 10px rgba(245,158,11,.1)}50%{box-shadow:0 0 25px rgba(245,158,11,.3)}} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}} | |
| /* Center Panel */ | |
| #centerPanel{flex:1;display:flex;flex-direction:column;overflow:hidden;border-right:1px solid var(--border)} | |
| #contextViz{max-height:180px;border-bottom:1px solid var(--border);overflow-y:auto;padding:12px 16px;background:var(--bg2);display:none} | |
| #contextViz.show{display:block} | |
| .ctx-title{font-size:11px;font-weight:600;color:var(--accent);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px} | |
| .ctx-item{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 12px;margin-bottom:6px;font-size:12px;position:relative;overflow:hidden;transition:all .3s} | |
| .ctx-item .score{position:absolute;top:8px;right:8px;font-size:10px;font-weight:600;padding:2px 8px;border-radius:4px} | |
| .ctx-item .score.high{background:rgba(34,197,94,.15);color:var(--success)} | |
| .ctx-item .score.med{background:rgba(245,158,11,.15);color:var(--accent)} | |
| .ctx-item .score.low{background:rgba(239,68,68,.15);color:var(--error)} | |
| .ctx-item .text{color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;padding-right:60px} | |
| .ctx-rerank{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px;color:var(--muted)} | |
| .ctx-rerank .arrow{color:var(--node-rerank)} | |
| .ctx-rerank .new-score{color:var(--node-rerank);font-weight:600} | |
| /* Chat */ | |
| #chatArea{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px} | |
| .chat-msg{max-width:85%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.6;word-break:break-word} | |
| .chat-msg.user{align-self:flex-end;background:var(--accent);color:#000;border-bottom-right-radius:4px;font-weight:500} | |
| .chat-msg.assistant{align-self:flex-start;background:var(--card);border:1px solid var(--border);border-bottom-left-radius:4px} | |
| .chat-msg.system{align-self:center;background:rgba(6,182,212,.1);border:1px solid rgba(6,182,212,.2);color:var(--info);font-size:11px;text-align:center;max-width:100%} | |
| .chat-msg pre{background:var(--bg);padding:8px;border-radius:6px;margin:6px 0;overflow-x:auto;font-size:11px} | |
| #chatInput{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;background:var(--bg2)} | |
| #chatInput textarea{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--fg);font-family:inherit;font-size:13px;resize:none;height:42px;max-height:120px;outline:none;transition:border-color .2s} | |
| #chatInput textarea:focus{border-color:var(--accent)} | |
| #chatInput button{width:42px;height:42px;border-radius:8px;border:none;background:var(--accent);color:#000;font-size:16px;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center} | |
| #chatInput button:hover{background:var(--accent2)} | |
| #chatInput button:disabled{opacity:.4;cursor:not-allowed} | |
| /* Right Panel - Vector DB */ | |
| #vectorPanel{width:420px;min-width:360px;display:flex;flex-direction:column;overflow:hidden;background:var(--bg2)} | |
| .vp-toolbar{padding:10px 12px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-wrap:wrap;align-items:center} | |
| .vp-search{flex:1;min-width:120px;height:30px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:0 10px;color:var(--fg);font-family:inherit;font-size:12px;outline:none;transition:border-color .2s} | |
| .vp-search:focus{border-color:var(--accent)} | |
| .vp-btn{height:30px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;display:flex;align-items:center;gap:5px;transition:all .2s;white-space:nowrap} | |
| .vp-btn:hover{border-color:var(--accent);color:var(--accent)} | |
| .vp-btn.on{background:rgba(34,197,94,.15);border-color:var(--success);color:var(--success)} | |
| .vp-btn.off{background:rgba(239,68,68,.1);border-color:var(--error);color:var(--error)} | |
| .vp-btn.accent{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600} | |
| .vp-btn.accent:hover{background:var(--accent2)} | |
| /* Add Entry Form */ | |
| #addForm{padding:12px;border-bottom:1px solid var(--border);display:none;background:var(--card)} | |
| #addForm.show{display:block} | |
| #addForm textarea{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--fg);font-family:inherit;font-size:12px;resize:vertical;outline:none;margin-bottom:8px} | |
| #addForm textarea:focus{border-color:var(--accent)} | |
| #addForm label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:4px} | |
| /* Table */ | |
| #tableWrap{flex:1;overflow:auto;padding:0} | |
| #vectorTable{width:100%;border-collapse:collapse;font-size:11px} | |
| #vectorTable thead{position:sticky;top:0;z-index:5} | |
| #vectorTable th{background:var(--bg3);padding:8px 10px;text-align:left;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);cursor:pointer;user-select:none;white-space:nowrap;border-bottom:1px solid var(--border);transition:color .2s} | |
| #vectorTable th:hover{color:var(--accent)} | |
| #vectorTable th .sort-icon{margin-left:4px;font-size:9px;opacity:.5} | |
| #vectorTable th.sorted .sort-icon{opacity:1;color:var(--accent)} | |
| #vectorTable td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| #vectorTable tr{cursor:pointer;transition:background .15s} | |
| #vectorTable tbody tr:hover{background:rgba(245,158,11,.05)} | |
| #vectorTable tbody tr.highlight{animation:rowHighlight 1.5s ease-out} | |
| #vectorTable tbody tr.rerank-up{animation:rowUp .5s ease-out} | |
| #vectorTable tbody tr.rerank-down{animation:rowDown .5s ease-out} | |
| @keyframes rowHighlight{0%{background:rgba(245,158,11,.3)}100%{background:transparent}} | |
| @keyframes rowUp{0%{background:rgba(34,197,94,.3);transform:translateX(-4px)}100%{background:transparent;transform:translateX(0)}} | |
| @keyframes rowDown{0%{background:rgba(239,68,68,.2);transform:translateX(4px)}100%{background:transparent;transform:translateX(0)}} | |
| .vec-preview{color:var(--muted);font-size:10px} | |
| .meta-preview{color:var(--info);font-size:10px} | |
| .del-btn{background:none;border:none;color:var(--error);cursor:pointer;font-size:12px;opacity:.6;transition:opacity .2s;padding:2px 4px} | |
| .del-btn:hover{opacity:1} | |
| /* Settings Sidebar */ | |
| #settingsSidebar{position:fixed;top:0;right:0;width:320px;height:100vh;background:var(--bg2);border-left:1px solid var(--border);z-index:100;transform:translateX(100%);transition:transform .3s ease;display:flex;flex-direction:column;overflow:hidden} | |
| #settingsSidebar.open{transform:translateX(0)} | |
| #settingsOverlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99;opacity:0;pointer-events:none;transition:opacity .3s} | |
| #settingsOverlay.open{opacity:1;pointer-events:all} | |
| .ss-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between} | |
| .ss-header h3{font-size:14px;font-weight:600} | |
| .ss-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;transition:color .2s} | |
| .ss-close:hover{color:var(--fg)} | |
| .ss-body{flex:1;overflow-y:auto;padding:16px} | |
| .ss-field{margin-bottom:16px} | |
| .ss-field label{display:block;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;font-weight:600} | |
| .ss-field input,.ss-field textarea,.ss-field select{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--fg);font-family:'IBM Plex Mono',monospace;font-size:12px;outline:none;transition:border-color .2s} | |
| .ss-field input:focus,.ss-field textarea:focus{border-color:var(--accent)} | |
| .ss-field .hint{font-size:10px;color:var(--muted);margin-top:4px} | |
| .ss-load-btn{width:100%;height:36px;border-radius:6px;border:none;background:var(--accent);color:#000;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px} | |
| .ss-load-btn:hover{background:var(--accent2)} | |
| .ss-load-btn:disabled{opacity:.5;cursor:not-allowed} | |
| /* Modal */ | |
| .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)} | |
| .modal-overlay.show{display:flex} | |
| .modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;width:600px;max-width:90vw;max-height:85vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.5)} | |
| .modal-head{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between} | |
| .modal-head h3{font-size:15px;font-weight:600} | |
| .modal-body{flex:1;overflow-y:auto;padding:20px} | |
| .modal-body .field{margin-bottom:14px} | |
| .modal-body .field label{display:block;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;font-weight:600} | |
| .modal-body .field textarea{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--fg);font-family:inherit;font-size:12px;resize:vertical;outline:none;min-height:60px} | |
| .modal-body .field textarea:focus{border-color:var(--accent)} | |
| .modal-body .field .vec-display{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted);max-height:120px;overflow:auto;word-break:break-all;line-height:1.6} | |
| .modal-foot{padding:12px 20px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end} | |
| /* Download Button */ | |
| #dlBtn{width:36px;height:36px;border-radius:50%;border:2px solid var(--border);background:var(--bg3);cursor:pointer;position:relative;display:flex;align-items:center;justify-content:center;transition:all .3s} | |
| #dlBtn:hover{border-color:var(--accent)} | |
| #dlBtn svg{width:20px;height:20px;transform:rotate(-90deg)} | |
| #dlBtn .track{fill:none;stroke:var(--border);stroke-width:2.5} | |
| #dlBtn .progress{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linecap:round;stroke-dasharray:56.5;stroke-dashoffset:56.5;transition:stroke-dashoffset .3s} | |
| #dlBtn .icon{position:absolute;font-size:12px;color:var(--muted);transition:color .3s} | |
| #dlBtn.downloading .icon{color:var(--accent);animation:spin 2s linear infinite} | |
| #dlPopup{position:absolute;top:46px;right:0;width:280px;background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:12px;font-size:11px;z-index:60;display:none;box-shadow:0 8px 30px rgba(0,0,0,.4)} | |
| #dlPopup.show{display:block} | |
| #dlPopup .dl-title{font-weight:600;margin-bottom:8px;color:var(--accent)} | |
| #dlPopup .dl-file{padding:4px 0;color:var(--muted);display:flex;justify-content:space-between} | |
| #dlPopup .dl-file .pct{color:var(--fg)} | |
| #dlPopup .dl-bar{height:3px;background:var(--bg);border-radius:2px;margin-top:2px;margin-bottom:6px;overflow:hidden} | |
| #dlPopup .dl-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;width:0} | |
| @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} | |
| /* Toast */ | |
| #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(80px);background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 20px;font-size:12px;z-index:300;transition:transform .4s ease;box-shadow:0 8px 30px rgba(0,0,0,.4);display:flex;align-items:center;gap:8px} | |
| #toast.show{transform:translateX(-50%) translateY(0)} | |
| #toast.success{border-color:var(--success)} | |
| #toast.error{border-color:var(--error)} | |
| #toast.info{border-color:var(--info)} | |
| /* Empty state */ | |
| .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:13px;gap:8px} | |
| .empty-state i{font-size:32px;opacity:.3} | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar{width:6px;height:6px} | |
| ::-webkit-scrollbar-track{background:transparent} | |
| ::-webkit-scrollbar-thumb{background:var(--bg3);border-radius:3px} | |
| ::-webkit-scrollbar-thumb:hover{background:var(--border)} | |
| /* Background ambiance */ | |
| body::before{content:'';position:fixed;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at 20% 50%,rgba(245,158,11,.03) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(6,182,212,.02) 0%,transparent 50%);pointer-events:none;z-index:0} | |
| body>*{position:relative;z-index:1} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header> | |
| <h1><span>RAG</span> Visualizer</h1> | |
| <button class="hdr-btn danger" id="clearCacheBtn"><i class="fas fa-trash-can"></i> Clear Cache</button> | |
| <button class="hdr-btn primary" id="loadAllBtn"><i class="fas fa-download"></i> Load Models</button> | |
| <div style="position:relative"> | |
| <button id="dlBtn" title="Download progress"> | |
| <svg viewBox="0 0 24 24"><circle class="track" cx="12" cy="12" r="9"/><circle class="progress" id="dlProgress" cx="12" cy="12" r="9"/></svg> | |
| <span class="icon"><i class="fas fa-arrow-down"></i></span> | |
| </button> | |
| <div id="dlPopup"> | |
| <div class="dl-title">Download Progress</div> | |
| <div id="dlFiles"><div class="empty-state" style="height:auto;padding:8px;font-size:11px"><i class="fas fa-check"></i> No active downloads</div></div> | |
| </div> | |
| </div> | |
| <div class="spacer"></div> | |
| <a href="https://quickgrid.github.io/" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;transition:color .2s" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--muted)'">Made by Asif Ahmed</a> | |
| </header> | |
| <!-- Main Layout --> | |
| <main> | |
| <!-- Left: Node Flow Editor --> | |
| <aside id="nodePanel"> | |
| <div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;font-weight:600;margin-bottom:14px;width:100%;text-align:center">Pipeline Flow</div> | |
| <div class="node-col" id="nodeFlow"></div> | |
| </aside> | |
| <!-- Center: Chat + Context --> | |
| <section id="centerPanel"> | |
| <div id="contextViz"></div> | |
| <div id="chatArea"><div class="empty-state"><i class="fas fa-comments"></i>Load models and start chatting</div></div> | |
| <div id="chatInput"> | |
| <textarea id="chatText" placeholder="Ask a question..." rows="1"></textarea> | |
| <button id="sendBtn" disabled><i class="fas fa-paper-plane"></i></button> | |
| </div> | |
| </section> | |
| <!-- Right: Vector DB --> | |
| <section id="vectorPanel"> | |
| <div class="vp-toolbar"> | |
| <input type="text" class="vp-search" id="tableSearch" placeholder="Search entries..."> | |
| <button class="vp-btn accent" id="addEntryBtn"><i class="fas fa-plus"></i> Add Entry</button> | |
| <button class="vp-btn on" id="toggleVdbBtn"><i class="fas fa-database"></i> VDB: ON</button> | |
| </div> | |
| <div id="addForm"> | |
| <label>Text Content</label> | |
| <textarea id="newText" rows="3" placeholder="Enter text to embed and store..."></textarea> | |
| <label>Metadata (JSON)</label> | |
| <textarea id="newMeta" rows="2" placeholder='{"source": "doc1", "page": 1}'></textarea> | |
| <div style="display:flex;gap:8px;margin-top:8px"> | |
| <button class="vp-btn accent" id="submitEntryBtn" style="flex:1;justify-content:center;height:34px"><i class="fas fa-check"></i> Store Entry</button> | |
| <button class="vp-btn" id="cancelEntryBtn" style="height:34px"><i class="fas fa-times"></i></button> | |
| </div> | |
| </div> | |
| <div id="tableWrap"> | |
| <table id="vectorTable"> | |
| <thead><tr> | |
| <th data-col="id">ID <span class="sort-icon"><i class="fas fa-sort"></i></span></th> | |
| <th data-col="text">Text <span class="sort-icon"><i class="fas fa-sort"></i></span></th> | |
| <th data-col="metadata">Metadata <span class="sort-icon"><i class="fas fa-sort"></i></span></th> | |
| <th data-col="vector">Vector <span class="sort-icon"><i class="fas fa-sort"></i></span></th> | |
| <th data-col="date">Date <span class="sort-icon"><i class="fas fa-sort"></i></span></th> | |
| <th style="width:30px"></th> | |
| </tr></thead> | |
| <tbody id="tableBody"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Settings Sidebar --> | |
| <div id="settingsOverlay"></div> | |
| <aside id="settingsSidebar"> | |
| <div class="ss-header"> | |
| <h3 id="ssTitle">Node Settings</h3> | |
| <button class="ss-close" id="ssClose"><i class="fas fa-times"></i></button> | |
| </div> | |
| <div class="ss-body" id="ssBody"></div> | |
| </aside> | |
| <!-- Entry Detail Modal --> | |
| <div class="modal-overlay" id="entryModal"> | |
| <div class="modal"> | |
| <div class="modal-head"><h3 id="modalTitle">Entry Detail</h3><button class="ss-close" id="modalClose"><i class="fas fa-times"></i></button></div> | |
| <div class="modal-body" id="modalBody"></div> | |
| <div class="modal-foot" id="modalFoot"></div> | |
| </div> | |
| </div> | |
| <!-- Toast --> | |
| <div id="toast"></div> | |
| <script type="module"> | |
| // ============================================= | |
| // State | |
| // ============================================= | |
| const STATE = { | |
| models: { llm: null, embedder: null, reranker: null }, | |
| modelIds: { | |
| llm: 'onnx-community/Qwen3.5-0.8B-ONNX', | |
| embedder: 'Xenova/all-MiniLM-L6-v2', | |
| reranker: 'Xenova/bge-reranker-base' | |
| }, | |
| modelLoading: { llm: false, embedder: false, reranker: false }, | |
| entries: [], | |
| nextId: 1, | |
| chatMessages: [], | |
| topKResults: [], | |
| rerankedResults: [], | |
| activeNode: null, | |
| isVDBEnabled: true, | |
| sortCol: 'id', | |
| sortDir: 'desc', | |
| searchQuery: '', | |
| settings: { topK: 5, similarityThreshold: 0.3, maxTokens: 256, temperature: 0.6, rerankTopK: 3 }, | |
| downloadProgress: { files: [], totalProgress: 0, active: false }, | |
| isProcessing: false | |
| }; | |
| // ============================================= | |
| // Node definitions | |
| // ============================================= | |
| const NODES = [ | |
| { id: 'input', label: 'Query Input', icon: 'fa-keyboard', color: 'var(--node-input)', desc: 'User question is received and prepared for embedding.' }, | |
| { id: 'embedding', label: 'Embedding', icon: 'fa-vector-square', color: 'var(--node-embed)', desc: 'Converts text into a high-dimensional vector using a transformer model.' }, | |
| { id: 'search', label: 'Vector Search', icon: 'fa-magnifying-glass', color: 'var(--node-search)', desc: 'Searches the vector database for the most similar entries using cosine similarity.' }, | |
| { id: 'rerank', label: 'Reranking', icon: 'fa-arrow-up-wide-short', color: 'var(--node-rerank)', desc: 'Re-scores and re-orders the top-k results using a cross-encoder or heuristic for better relevance.' }, | |
| { id: 'llm', label: 'LLM Generation', icon: 'fa-brain', color: 'var(--node-llm)', desc: 'Generates a response using the language model with retrieved context.' }, | |
| { id: 'output', label: 'Response', icon: 'fa-comment-dots', color: 'var(--node-output)', desc: 'Final answer is displayed in the chat window.' } | |
| ]; | |
| // ============================================= | |
| // Lightweight LanceDB-compatible Vector Store | |
| // ============================================= | |
| class LanceDBTable { | |
| constructor(name) { this.name = name; this.data = []; } | |
| add(items) { this.data.push(...items); } | |
| search(vector) { | |
| return { | |
| limit: (k) => ({ | |
| where: (fn) => { | |
| let filtered = this.data.filter(fn); | |
| return { toArray: () => this._rank(filtered, vector, k) }; | |
| }, | |
| toArray: () => this._rank(this.data, vector, k) | |
| }) | |
| }; | |
| } | |
| _rank(data, vector, k) { | |
| const scored = data.map(item => ({ | |
| ...item, | |
| _score: cosineSim(vector, item.vector) | |
| })); | |
| scored.sort((a, b) => b._score - a._score); | |
| return scored.slice(0, k); | |
| } | |
| update(id, updates) { | |
| const idx = this.data.findIndex(d => d.id === id); | |
| if (idx >= 0) Object.assign(this.data[idx], updates, { date: new Date().toISOString() }); | |
| } | |
| delete(id) { this.data = this.data.filter(d => d.id !== id); } | |
| toList() { return [...this.data]; } | |
| count() { return this.data.length; } | |
| } | |
| class LanceDB { | |
| constructor() { this.tables = {}; } | |
| async connect() { return this; } | |
| async createTable(name, data) { | |
| this.tables[name] = new LanceDBTable(name); | |
| if (data && data.length) this.tables[name].add(data); | |
| return this.tables[name]; | |
| } | |
| async openTable(name) { | |
| if (!this.tables[name]) this.tables[name] = new LanceDBTable(name); | |
| return this.tables[name]; | |
| } | |
| } | |
| const db = new LanceDB(); | |
| let vTable = null; | |
| 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]; } | |
| const denom = Math.sqrt(na) * Math.sqrt(nb); | |
| return denom === 0 ? 0 : dot / denom; | |
| } | |
| // ============================================= | |
| // Transformers.js import (lazy) | |
| // ============================================= | |
| let T = null; | |
| async function getTransformers() { | |
| if (T) return T; | |
| try { | |
| T = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0'); | |
| } catch { | |
| T = await import('https://cdn.jsdelivr.net/npm/@xenova/transformers@latest'); | |
| } | |
| T.env.allowLocalModels = false; | |
| T.env.backends.onnx.wasm.proxy = false; | |
| // FORCE DISCRETE GPU (NVIDIA) OVER INTEGRATED GPU (AMD) | |
| if (navigator.gpu) { | |
| const _origRequestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu); | |
| navigator.gpu.requestAdapter = function(opts = {}) { | |
| console.log('[GPU] Requesting adapter with powerPreference: high-performance'); | |
| return _origRequestAdapter({ ...opts, powerPreference: 'high-performance' }); | |
| }; | |
| } | |
| return T; | |
| } | |
| // ============================================= | |
| // Download Progress Tracking | |
| // ============================================= | |
| function handleProgress(progress) { | |
| const dp = STATE.downloadProgress; | |
| if (progress.status === 'initiate') { | |
| dp.files.push({ file: progress.file, progress: 0, loaded: 0, total: 0, status: 'downloading' }); | |
| dp.active = true; | |
| updateDlBtn(); | |
| } else if (progress.status === 'progress') { | |
| const f = dp.files.find(f => f.file === progress.file); | |
| if (f) { f.loaded = progress.loaded; f.total = progress.total; f.progress = progress.total ? (progress.loaded / progress.total) : 0; } | |
| const totalP = dp.files.reduce((s, f) => s + f.progress, 0) / Math.max(dp.files.length, 1); | |
| dp.totalProgress = totalP; | |
| updateDlBtn(); | |
| updateDlPopup(); | |
| } else if (progress.status === 'done') { | |
| const f = dp.files.find(f => f.file === progress.file); | |
| if (f) { f.progress = 1; f.status = 'done'; } | |
| const allDone = dp.files.every(f => f.status === 'done'); | |
| if (allDone) { dp.active = false; setTimeout(() => { dp.files = []; updateDlPopup(); }, 2000); } | |
| updateDlBtn(); | |
| updateDlPopup(); | |
| } | |
| } | |
| function updateDlBtn() { | |
| const btn = document.getElementById('dlBtn'); | |
| const circle = document.getElementById('dlProgress'); | |
| const p = STATE.downloadProgress.totalProgress; | |
| const offset = 56.5 * (1 - p); | |
| circle.style.strokeDashoffset = offset; | |
| if (STATE.downloadProgress.active) btn.classList.add('downloading'); | |
| else btn.classList.remove('downloading'); | |
| } | |
| function updateDlPopup() { | |
| const container = document.getElementById('dlFiles'); | |
| const dp = STATE.downloadProgress; | |
| if (!dp.files.length) { | |
| container.innerHTML = '<div style="padding:8px;font-size:11px;color:var(--muted);text-align:center"><i class="fas fa-check" style="margin-right:4px"></i>No active downloads</div>'; | |
| return; | |
| } | |
| container.innerHTML = dp.files.map(f => { | |
| const pct = Math.round(f.progress * 100); | |
| const shortName = f.file.split('/').pop(); | |
| return `<div class="dl-file"><span>${shortName}</span><span class="pct">${pct}%</span></div><div class="dl-bar"><div class="dl-bar-fill" style="width:${pct}%"></div></div>`; | |
| }).join(''); | |
| } | |
| // ============================================= | |
| // Model Loading | |
| // ============================================= | |
| async function loadModel(type, modelId) { | |
| if (STATE.modelLoading[type]) return; | |
| STATE.modelLoading[type] = true; | |
| setNodeStatus(type === 'llm' ? 'llm' : type === 'embedder' ? 'embedding' : 'rerank', 'loading'); | |
| try { | |
| const tf = await getTransformers(); | |
| if (type === 'llm') { | |
| if (!navigator.gpu) { | |
| throw new Error('WebGPU not available in this browser. q4f16 quantization REQUIRES WebGPU (it uses GatherBlockQuantized kernels that do not exist on CPU). Use Chrome 113+ or switch dtype to "q4" for CPU fallback.'); | |
| } | |
| tf.env.backends.onnx.wasm.proxy = false; | |
| STATE.models.llm = await tf.pipeline('text-generation', modelId, { | |
| progress_callback: handleProgress, | |
| dtype: 'q4f16', | |
| device: 'webgpu' | |
| }); | |
| } else if (type === 'embedder') { | |
| STATE.models.embedder = await tf.pipeline('feature-extraction', modelId, { progress_callback: handleProgress, device: 'webgpu' }); | |
| } else if (type === 'reranker') { | |
| try { | |
| STATE.models.reranker = await tf.pipeline('text-classification', modelId, { progress_callback: handleProgress, device: 'webgpu' }); | |
| } catch { | |
| STATE.models.reranker = 'heuristic'; | |
| toast('Reranker model unavailable, using heuristic fallback', 'info'); | |
| } | |
| } | |
| STATE.modelIds[type] = modelId; | |
| setNodeStatus(type === 'llm' ? 'llm' : type === 'embedder' ? 'embedding' : 'rerank', 'active'); | |
| toast(`${type.charAt(0).toUpperCase() + type.slice(1)} model loaded: ${modelId}`, 'success'); | |
| updateSendBtn(); | |
| } catch (e) { | |
| console.error(e); | |
| setNodeStatus(type === 'llm' ? 'llm' : type === 'embedder' ? 'embedding' : 'rerank', 'error'); | |
| toast(`Failed to load ${type} model: ${e.message}`, 'error'); | |
| } | |
| STATE.modelLoading[type] = false; | |
| } | |
| async function loadAllModels() { | |
| await loadModel('embedder', STATE.modelIds.embedder); | |
| await seedVectorDB(); // ADD THIS LINE | |
| await loadModel('llm', STATE.modelIds.llm); | |
| await loadModel('reranker', STATE.modelIds.reranker); | |
| document.getElementById('sendBtn').disabled = !STATE.models.llm; | |
| } | |
| async function clearCache() { | |
| try { | |
| if (window.caches) { const keys = await caches.keys(); for (const k of keys) await caches.delete(k); } | |
| const dbs = await indexedDB.databases ? await indexedDB.databases() : []; | |
| for (const d of dbs) { if (d.name && (d.name.includes('transformers') || d.name.includes('huggingface'))) indexedDB.deleteDatabase(d.name); } | |
| toast('Model cache cleared', 'success'); | |
| } catch { toast('Cache clear attempted', 'info'); } | |
| } | |
| // ============================================= | |
| // Embedding & Search | |
| // ============================================= | |
| async function embedText(text) { | |
| if (!STATE.models.embedder) throw new Error('Embedding model not loaded'); | |
| const output = await STATE.models.embedder(text, { pooling: 'mean', normalize: true }); | |
| return Array.from(output[0].data || output.data); | |
| } | |
| async function searchVectors(query, topK) { | |
| const queryVec = await embedText(query); | |
| if (!vTable) return []; | |
| const results = await vTable.search(queryVec).limit(topK).toArray(); | |
| return results.map(r => ({ ...r, _score: r._score || 0 })); | |
| } | |
| // ============================================= | |
| // Reranking | |
| // ============================================= | |
| async function rerankResults(query, results) { | |
| if (!results.length) return []; | |
| if (STATE.models.reranker === 'heuristic') { | |
| return heuristicRerank(query, results); | |
| } | |
| if (STATE.models.reranker && typeof STATE.models.reranker === 'function') { | |
| try { | |
| const scored = []; | |
| for (const r of results) { | |
| const out = await STATE.models.reranker(`${query} [SEP] ${r.text}`, { top_k: 1 }); | |
| scored.push({ ...r, _rerankScore: out[0]?.score || 0 }); | |
| } | |
| scored.sort((a, b) => b._rerankScore - a._rerankScore); | |
| return scored; | |
| } catch { return heuristicRerank(query, results); } | |
| } | |
| return heuristicRerank(query, results); | |
| } | |
| function heuristicRerank(query, results) { | |
| const qTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2); | |
| const scored = results.map(r => { | |
| const rTerms = r.text.toLowerCase().split(/\s+/); | |
| let overlap = 0; | |
| for (const qt of qTerms) { if (rTerms.some(rt => rt.includes(qt) || qt.includes(rt))) overlap++; } | |
| const termScore = qTerms.length > 0 ? overlap / qTerms.length : 0; | |
| const combinedScore = 0.6 * r._score + 0.4 * termScore; | |
| return { ...r, _rerankScore: combinedScore }; | |
| }); | |
| scored.sort((a, b) => b._rerankScore - a._rerankScore); | |
| return scored; | |
| } | |
| // ============================================= | |
| // LLM Generation | |
| // ============================================= | |
| function formatQwenPrompt(messages) { | |
| let p = ''; | |
| for (const m of messages) { | |
| p += `<|im_start|>${m.role}\n${m.content}<|im_end|>\n`; | |
| } | |
| p += '<|im_start|>assistant\n'; | |
| return p; | |
| } | |
| async function generateResponse(userMsg, context) { | |
| if (!STATE.models.llm) throw new Error('LLM not loaded'); | |
| const sysMsg = context | |
| ? `You are a helpful assistant. Answer the user's question using ONLY the following context. If the context doesn't contain the answer, say so clearly.\n\nContext:\n${context}` | |
| : 'You are a helpful assistant. Answer the user\'s question using your own knowledge.'; | |
| const messages = [ | |
| { role: 'system', content: sysMsg }, | |
| { role: 'user', content: userMsg } | |
| ]; | |
| // Try chat format first (v3), fall back to string prompt (v2) | |
| try { | |
| const result = await STATE.models.llm(messages, { | |
| max_new_tokens: STATE.settings.maxTokens, | |
| temperature: STATE.settings.temperature, | |
| do_sample: false, | |
| return_full_text: false | |
| }); | |
| const text = result[0]?.generated_text || ''; | |
| return text.replace(/<\|im_end\|>/g, '').trim(); | |
| } catch { | |
| const prompt = formatQwenPrompt(messages); | |
| const result = await STATE.models.llm(prompt, { | |
| max_new_tokens: STATE.settings.maxTokens, | |
| temperature: STATE.settings.temperature, | |
| do_sample: false, | |
| return_full_text: false | |
| }); | |
| const text = result[0]?.generated_text || ''; | |
| return text.replace(/<\|im_end\|>/g, '').trim(); | |
| } | |
| } | |
| // ============================================= | |
| // RAG Pipeline | |
| // ============================================= | |
| async function runRAG(query) { | |
| if (STATE.isProcessing) return; | |
| STATE.isProcessing = true; | |
| document.getElementById('sendBtn').disabled = true; | |
| // Add user message | |
| addChatMsg('user', query); | |
| try { | |
| // Step 1: Input | |
| setNodeActive('input'); | |
| await delay(300); | |
| setNodeDone('input'); | |
| // Step 2: Embedding | |
| setNodeActive('embedding'); | |
| const queryVec = await embedText(query); | |
| setNodeDone('embedding'); | |
| // Step 3: Vector Search (if VDB enabled and has data) | |
| let searchResults = []; | |
| if (STATE.isVDBEnabled && vTable && vTable.count() > 0) { | |
| setNodeActive('search'); | |
| searchResults = await vTable.search(queryVec).limit(STATE.settings.topK).toArray(); | |
| searchResults = searchResults.filter(r => r._score >= STATE.settings.similarityThreshold); | |
| setNodeDone('search'); | |
| highlightTableRows(searchResults.map(r => r.id)); | |
| } else { | |
| setNodeStatus('search', STATE.isVDBEnabled ? 'idle' : 'idle'); | |
| } | |
| STATE.topKResults = searchResults; | |
| renderContextViz(searchResults, null); | |
| // Step 4: Reranking | |
| let reranked = []; | |
| if (searchResults.length > 0) { | |
| setNodeActive('rerank'); | |
| reranked = await rerankResults(query, searchResults); | |
| setNodeDone('rerank'); | |
| STATE.rerankedResults = reranked; | |
| renderContextViz(searchResults, reranked); | |
| showRerankAnimation(searchResults, reranked); | |
| } else { | |
| setNodeStatus('rerank', 'idle'); | |
| STATE.rerankedResults = []; | |
| } | |
| // Step 5: LLM | |
| setNodeActive('llm'); | |
| const contextText = reranked.length > 0 | |
| ? reranked.map((r, i) => `[${i + 1}] ${r.text}`).join('\n\n') | |
| : null; | |
| const response = await generateResponse(query, contextText); | |
| setNodeDone('llm'); | |
| // Step 6: Output | |
| setNodeActive('output'); | |
| addChatMsg('assistant', response); | |
| await delay(200); | |
| setNodeDone('output'); | |
| if (!STATE.isVDBEnabled || searchResults.length === 0) { | |
| addChatMsg('system', STATE.isVDBEnabled ? 'No relevant context found in vector DB — answered from model knowledge' : 'Vector DB is OFF — answered from model knowledge'); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| addChatMsg('system', `Error: ${e.message}`); | |
| NODES.forEach(n => setNodeStatus(n.id, 'error')); | |
| } | |
| STATE.isProcessing = false; | |
| updateSendBtn(); | |
| // Reset nodes after a delay | |
| setTimeout(() => NODES.forEach(n => { | |
| if (n.id !== 'embedding' && n.id !== 'llm') setNodeStatus(n.id, STATE.models[n.id === 'embedding' ? 'embedder' : n.id === 'llm' ? 'llm' : 'reranker'] ? 'active' : 'idle'); | |
| else setNodeStatus(n.id, STATE.models[n.id === 'embedding' ? 'embedder' : 'llm'] ? 'active' : 'idle'); | |
| }), 2000); | |
| } | |
| function delay(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| // ============================================= | |
| // Chat UI | |
| // ============================================= | |
| function addChatMsg(role, text) { | |
| STATE.chatMessages.push({ role, text, time: new Date() }); | |
| renderChat(); | |
| } | |
| function renderChat() { | |
| const area = document.getElementById('chatArea'); | |
| if (!STATE.chatMessages.length) { | |
| area.innerHTML = '<div class="empty-state"><i class="fas fa-comments"></i>Load models and start chatting</div>'; | |
| return; | |
| } | |
| area.innerHTML = STATE.chatMessages.map(m => { | |
| if (m.role === 'system') return `<div class="chat-msg system">${escHtml(m.text)}</div>`; | |
| const content = formatMsgText(m.text); | |
| return `<div class="chat-msg ${m.role}">${content}</div>`; | |
| }).join(''); | |
| area.scrollTop = area.scrollHeight; | |
| } | |
| function formatMsgText(text) { | |
| return escHtml(text).replace(/\n/g, '<br>').replace(/`([^`]+)`/g, '<code style="background:var(--bg);padding:1px 5px;border-radius:3px;font-size:11px">$1</code>'); | |
| } | |
| function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } | |
| // ============================================= | |
| // Context Visualization | |
| // ============================================= | |
| function renderContextViz(searchResults, reranked) { | |
| const el = document.getElementById('contextViz'); | |
| if (!searchResults || !searchResults.length) { el.classList.remove('show'); return; } | |
| el.classList.add('show'); | |
| let html = '<div class="ctx-title"><i class="fas fa-layer-group" style="margin-right:6px"></i>Retrieved Context</div>'; | |
| const items = reranked || searchResults; | |
| items.forEach((r, i) => { | |
| const score = r._rerankScore !== undefined ? r._rerankScore : r._score; | |
| const origScore = r._score; | |
| const cls = score > 0.7 ? 'high' : score > 0.4 ? 'med' : 'low'; | |
| let rerankInfo = ''; | |
| if (reranked && r._rerankScore !== undefined) { | |
| const diff = r._rerankScore - r._score; | |
| const arrow = diff >= 0 ? '<i class="fas fa-arrow-up arrow"></i>' : '<i class="fas fa-arrow-down arrow"></i>'; | |
| rerankInfo = `<div class="ctx-rerank">${arrow} <span class="mono">${origScore.toFixed(3)}</span> → <span class="new-score mono">${r._rerankScore.toFixed(3)}</span></div>`; | |
| } | |
| html += `<div class="ctx-item"><span class="score ${cls} mono">${score.toFixed(3)}</span><div class="text">${escHtml(r.text)}</div>${rerankInfo}</div>`; | |
| }); | |
| el.innerHTML = html; | |
| } | |
| // ============================================= | |
| // Table | |
| // ============================================= | |
| function renderTable() { | |
| const tbody = document.getElementById('tableBody'); | |
| let data = [...STATE.entries]; | |
| // Filter | |
| if (STATE.searchQuery) { | |
| const q = STATE.searchQuery.toLowerCase(); | |
| data = data.filter(e => | |
| e.text.toLowerCase().includes(q) || | |
| JSON.stringify(e.metadata).toLowerCase().includes(q) || | |
| String(e.id).includes(q) || | |
| e.date.includes(q) | |
| ); | |
| } | |
| // Sort | |
| data.sort((a, b) => { | |
| let va = a[STATE.sortCol], vb = b[STATE.sortCol]; | |
| if (STATE.sortCol === 'id') { va = Number(va); vb = Number(vb); } | |
| if (typeof va === 'string') va = va.toLowerCase(); | |
| if (typeof vb === 'string') vb = vb.toLowerCase(); | |
| if (va < vb) return STATE.sortDir === 'asc' ? -1 : 1; | |
| if (va > vb) return STATE.sortDir === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| if (!data.length) { | |
| tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--muted)"><i class="fas fa-database" style="font-size:24px;display:block;margin-bottom:8px;opacity:.3"></i>No entries yet. Click "Add Entry" to start.</td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = data.map(e => { | |
| const vecStr = e.vector ? `[${e.vector.slice(0, 3).map(v => v.toFixed(2)).join(', ')}, ...]` : 'N/A'; | |
| const metaStr = e.metadata ? JSON.stringify(e.metadata).slice(0, 30) + '...' : '{}'; | |
| const dateStr = e.date ? new Date(e.date).toLocaleString() : ''; | |
| return `<tr data-id="${e.id}"> | |
| <td class="mono" style="color:var(--muted);width:40px">${e.id}</td> | |
| <td title="${escHtml(e.text)}">${escHtml(e.text.slice(0, 60))}${e.text.length > 60 ? '...' : ''}</td> | |
| <td class="meta-preview" title="${escHtml(JSON.stringify(e.metadata))}">${escHtml(metaStr)}</td> | |
| <td class="vec-preview mono">${vecStr}</td> | |
| <td style="color:var(--muted);font-size:10px;white-space:nowrap">${dateStr}</td> | |
| <td><button class="del-btn" data-del="${e.id}" title="Delete"><i class="fas fa-trash"></i></button></td> | |
| </tr>`; | |
| }).join(''); | |
| // Update sort indicators | |
| document.querySelectorAll('#vectorTable th').forEach(th => { | |
| th.classList.remove('sorted'); | |
| const icon = th.querySelector('.sort-icon i'); | |
| if (icon) icon.className = 'fas fa-sort'; | |
| if (th.dataset.col === STATE.sortCol) { | |
| th.classList.add('sorted'); | |
| if (icon) icon.className = STATE.sortDir === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down'; | |
| } | |
| }); | |
| } | |
| function highlightTableRows(ids) { | |
| document.querySelectorAll('#vectorTable tbody tr').forEach(tr => { | |
| tr.classList.remove('highlight', 'rerank-up', 'rerank-down'); | |
| const id = Number(tr.dataset.id); | |
| if (ids.includes(id)) { | |
| tr.classList.add('highlight'); | |
| tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }); | |
| } | |
| function showRerankAnimation(original, reranked) { | |
| const origOrder = original.map(r => r.id); | |
| const newOrder = reranked.map(r => r.id); | |
| document.querySelectorAll('#vectorTable tbody tr').forEach(tr => { | |
| const id = Number(tr.dataset.id); | |
| const origIdx = origOrder.indexOf(id); | |
| const newIdx = newOrder.indexOf(id); | |
| if (origIdx >= 0 && newIdx >= 0 && origIdx !== newIdx) { | |
| tr.classList.remove('highlight'); | |
| tr.classList.add(newIdx < origIdx ? 'rerank-up' : 'rerank-down'); | |
| } | |
| }); | |
| } | |
| // ============================================= | |
| // Entry CRUD | |
| // ============================================= | |
| async function addEntry(text, metadata) { | |
| if (!STATE.models.embedder) { toast('Load embedding model first', 'error'); return; } | |
| const vector = await embedText(text); | |
| const entry = { id: STATE.nextId++, text, metadata: metadata || {}, vector, date: new Date().toISOString() }; | |
| STATE.entries.push(entry); | |
| if (vTable) vTable.add([{ ...entry }]); | |
| renderTable(); | |
| toast(`Entry #${entry.id} stored (${vector.length}d vector)`, 'success'); | |
| // Highlight new row | |
| setTimeout(() => { | |
| const tr = document.querySelector(`#vectorTable tr[data-id="${entry.id}"]`); | |
| if (tr) { tr.classList.add('highlight'); tr.scrollIntoView({ behavior: 'smooth' }); } | |
| }, 100); | |
| } | |
| async function updateEntry(id, newText, newMeta) { | |
| const entry = STATE.entries.find(e => e.id === id); | |
| if (!entry) return; | |
| entry.text = newText; | |
| entry.metadata = newMeta; | |
| entry.date = new Date().toISOString(); | |
| if (STATE.models.embedder) { | |
| entry.vector = await embedText(newText); | |
| toast(`Entry #${id} updated with new vector`, 'success'); | |
| } else { | |
| toast(`Entry #${id} updated (vector unchanged - embedder not loaded)`, 'info'); | |
| } | |
| // Update in vTable | |
| if (vTable) { vTable.delete(id); vTable.add([{ ...entry }]); } | |
| renderTable(); | |
| } | |
| function deleteEntry(id) { | |
| STATE.entries = STATE.entries.filter(e => e.id !== id); | |
| if (vTable) vTable.delete(id); | |
| renderTable(); | |
| toast(`Entry #${id} deleted`, 'info'); | |
| } | |
| // ============================================= | |
| // Entry Modal | |
| // ============================================= | |
| function openEntryModal(id) { | |
| const entry = STATE.entries.find(e => e.id === id); | |
| if (!entry) return; | |
| const modal = document.getElementById('entryModal'); | |
| document.getElementById('modalTitle').textContent = `Entry #${entry.id}`; | |
| document.getElementById('modalBody').innerHTML = ` | |
| <div class="field"> | |
| <label>Text Content</label> | |
| <textarea id="modalText" rows="5">${escHtml(entry.text)}</textarea> | |
| </div> | |
| <div class="field"> | |
| <label>Metadata (JSON)</label> | |
| <textarea id="modalMeta" rows="3">${escHtml(JSON.stringify(entry.metadata, null, 2))}</textarea> | |
| </div> | |
| <div class="field"> | |
| <label>Vector Embedding (${entry.vector ? entry.vector.length : 0} dimensions)</label> | |
| <div class="vec-display mono" id="modalVec">${entry.vector ? entry.vector.map(v => v.toFixed(6)).join(', ') : 'N/A'}</div> | |
| </div> | |
| <div class="field"> | |
| <label>Created / Updated</label> | |
| <div style="font-size:12px;color:var(--muted)">${entry.date ? new Date(entry.date).toLocaleString() : 'N/A'}</div> | |
| </div> | |
| `; | |
| document.getElementById('modalFoot').innerHTML = ` | |
| <button class="vp-btn" id="modalCopyVec"><i class="fas fa-copy"></i> Copy Vector</button> | |
| <button class="vp-btn danger" id="modalDelete" style="border-color:var(--error);color:var(--error)"><i class="fas fa-trash"></i> Delete</button> | |
| <button class="vp-btn" id="modalCancel">Cancel</button> | |
| <button class="vp-btn accent" id="modalSave"><i class="fas fa-check"></i> Save Changes</button> | |
| `; | |
| modal.classList.add('show'); | |
| document.getElementById('modalCopyVec').onclick = () => { | |
| if (entry.vector) { | |
| navigator.clipboard.writeText(JSON.stringify(entry.vector)); | |
| toast('Vector copied to clipboard', 'success'); | |
| } | |
| }; | |
| document.getElementById('modalDelete').onclick = () => { | |
| deleteEntry(id); | |
| modal.classList.remove('show'); | |
| }; | |
| document.getElementById('modalCancel').onclick = () => modal.classList.remove('show'); | |
| document.getElementById('modalSave').onclick = async () => { | |
| const newText = document.getElementById('modalText').value.trim(); | |
| let newMeta = {}; | |
| try { newMeta = JSON.parse(document.getElementById('modalMeta').value); } | |
| catch { toast('Invalid JSON in metadata', 'error'); return; } | |
| if (!newText) { toast('Text cannot be empty', 'error'); return; } | |
| await updateEntry(id, newText, newMeta); | |
| modal.classList.remove('show'); | |
| }; | |
| } | |
| // ============================================= | |
| // Node Flow Editor | |
| // ============================================= | |
| function renderNodeFlow() { | |
| const container = document.getElementById('nodeFlow'); | |
| let html = ''; | |
| NODES.forEach((node, i) => { | |
| const status = 'idle'; | |
| html += `<div class="node-box" data-node="${node.id}" id="node-${node.id}"> | |
| <div class="node-header" style="background:${node.color}22;color:${node.color}"> | |
| <i class="fas ${node.icon}"></i> ${node.label} | |
| </div> | |
| <div class="node-body"> | |
| <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${node.desc.slice(0, 50)}...</span> | |
| <div class="node-status idle" id="status-${node.id}"></div> | |
| </div> | |
| </div>`; | |
| if (i < NODES.length - 1) { | |
| html += `<div class="node-connector" id="conn-${node.id}"></div>`; | |
| } | |
| }); | |
| container.innerHTML = html; | |
| // Click handlers | |
| container.querySelectorAll('.node-box').forEach(el => { | |
| el.addEventListener('click', () => openSettings(el.dataset.node)); | |
| }); | |
| } | |
| function setNodeStatus(nodeId, status) { | |
| const el = document.getElementById(`status-${nodeId}`); | |
| if (!el) return; | |
| el.className = `node-status ${status}`; | |
| const box = document.getElementById(`node-${nodeId}`); | |
| if (box) { box.classList.remove('active', 'processing', 'done'); if (status === 'active') box.classList.add('active'); } | |
| } | |
| function setNodeActive(nodeId) { | |
| const box = document.getElementById(`node-${nodeId}`); | |
| if (box) { box.classList.remove('done', 'active'); box.classList.add('processing'); } | |
| const conn = document.getElementById(`conn-${nodeId}`); | |
| if (conn) conn.classList.add('active'); | |
| setNodeStatus(nodeId, 'loading'); | |
| } | |
| function setNodeDone(nodeId) { | |
| const box = document.getElementById(`node-${nodeId}`); | |
| if (box) { box.classList.remove('processing'); box.classList.add('done'); } | |
| setNodeStatus(nodeId, 'active'); | |
| setTimeout(() => { if (box) box.classList.remove('done'); }, 1500); | |
| } | |
| // ============================================= | |
| // Settings Sidebar | |
| // ============================================= | |
| function openSettings(nodeId) { | |
| STATE.activeNode = nodeId; | |
| const node = NODES.find(n => n.id === nodeId); | |
| if (!node) return; | |
| document.getElementById('ssTitle').textContent = node.label + ' Settings'; | |
| const body = document.getElementById('ssBody'); | |
| let html = `<p style="font-size:12px;color:var(--muted);margin-bottom:16px;line-height:1.5">${node.desc}</p>`; | |
| const modelType = nodeId === 'embedding' ? 'embedder' : nodeId === 'llm' ? 'llm' : nodeId === 'rerank' ? 'reranker' : null; | |
| if (modelType) { | |
| const currentId = STATE.modelIds[modelType]; | |
| const isLoaded = STATE.models[modelType] != null; | |
| html += `<div class="ss-field"> | |
| <label>Model ID</label> | |
| <input type="text" id="ssModelId" value="${currentId}" placeholder="e.g. Xenova/all-MiniLM-L6-v2"> | |
| <div class="hint">Enter a HuggingFace model ID compatible with transformers.js</div> | |
| </div> | |
| <button class="ss-load-btn" id="ssLoadBtn" ${STATE.modelLoading[modelType] ? 'disabled' : ''}> | |
| <i class="fas ${STATE.modelLoading[modelType] ? 'fa-spinner fa-spin' : 'fa-download'}"></i> | |
| ${isLoaded ? 'Reload Model' : 'Load Model'} | |
| </button> | |
| <div style="margin-top:8px;font-size:11px;color:${isLoaded ? 'var(--success)' : 'var(--muted)'}"> | |
| <i class="fas ${isLoaded ? 'fa-check-circle' : 'fa-circle-xmark'}"></i> | |
| ${isLoaded ? 'Model loaded' : 'Model not loaded'} | |
| </div>`; | |
| } | |
| // Node-specific settings | |
| if (nodeId === 'search') { | |
| html += `<div class="ss-field" style="margin-top:16px"><label>Top K Results</label><input type="number" id="ssTopK" value="${STATE.settings.topK}" min="1" max="50"></div> | |
| <div class="ss-field"><label>Similarity Threshold</label><input type="number" id="ssThreshold" value="${STATE.settings.similarityThreshold}" min="0" max="1" step="0.05"></div>`; | |
| } | |
| if (nodeId === 'rerank') { | |
| html += `<div class="ss-field" style="margin-top:16px"><label>Top K After Rerank</label><input type="number" id="ssRerankK" value="${STATE.settings.rerankTopK}" min="1" max="20"></div>`; | |
| } | |
| if (nodeId === 'llm') { | |
| html += `<div class="ss-field" style="margin-top:16px"><label>Max Tokens</label><input type="number" id="ssMaxTokens" value="${STATE.settings.maxTokens}" min="16" max="2048"></div> | |
| <div class="ss-field"><label>Temperature</label><input type="number" id="ssTemp" value="${STATE.settings.temperature}" min="0" max="2" step="0.1"></div>`; | |
| } | |
| body.innerHTML = html; | |
| document.getElementById('settingsSidebar').classList.add('open'); | |
| document.getElementById('settingsOverlay').classList.add('open'); | |
| // Bind events | |
| if (modelType) { | |
| document.getElementById('ssLoadBtn').addEventListener('click', async () => { | |
| const newId = document.getElementById('ssModelId').value.trim(); | |
| if (!newId) return; | |
| await loadModel(modelType, newId); | |
| openSettings(nodeId); // Refresh | |
| }); | |
| } | |
| if (nodeId === 'search') { | |
| document.getElementById('ssTopK')?.addEventListener('change', e => { STATE.settings.topK = parseInt(e.target.value) || 5; }); | |
| document.getElementById('ssThreshold')?.addEventListener('change', e => { STATE.settings.similarityThreshold = parseFloat(e.target.value) || 0.3; }); | |
| } | |
| if (nodeId === 'rerank') { | |
| document.getElementById('ssRerankK')?.addEventListener('change', e => { STATE.settings.rerankTopK = parseInt(e.target.value) || 3; }); | |
| } | |
| if (nodeId === 'llm') { | |
| document.getElementById('ssMaxTokens')?.addEventListener('change', e => { STATE.settings.maxTokens = parseInt(e.target.value) || 256; }); | |
| document.getElementById('ssTemp')?.addEventListener('change', e => { STATE.settings.temperature = parseFloat(e.target.value) || 0.6; }); | |
| } | |
| } | |
| function closeSettings() { | |
| document.getElementById('settingsSidebar').classList.remove('open'); | |
| document.getElementById('settingsOverlay').classList.remove('open'); | |
| STATE.activeNode = null; | |
| } | |
| // ============================================= | |
| // Toast | |
| // ============================================= | |
| function toast(msg, type = 'info') { | |
| const el = document.getElementById('toast'); | |
| el.className = `show ${type}`; | |
| const icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'; | |
| const color = type === 'success' ? 'var(--success)' : type === 'error' ? 'var(--error)' : 'var(--info)'; | |
| el.innerHTML = `<i class="fas ${icon}" style="color:${color}"></i> ${msg}`; | |
| clearTimeout(el._t); | |
| el._t = setTimeout(() => el.classList.remove('show'), 3500); | |
| } | |
| // ============================================= | |
| // Utilities | |
| // ============================================= | |
| function updateSendBtn() { | |
| document.getElementById('sendBtn').disabled = !STATE.models.llm || STATE.isProcessing; | |
| } | |
| // ============================================= | |
| // Event Listeners | |
| // ============================================= | |
| function initEvents() { | |
| // Load all models | |
| document.getElementById('loadAllBtn').addEventListener('click', loadAllModels); | |
| // Clear cache | |
| document.getElementById('clearCacheBtn').addEventListener('click', clearCache); | |
| // Download button popup | |
| document.getElementById('dlBtn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| document.getElementById('dlPopup').classList.toggle('show'); | |
| }); | |
| document.addEventListener('click', () => document.getElementById('dlPopup').classList.remove('show')); | |
| // Send message | |
| document.getElementById('sendBtn').addEventListener('click', () => { | |
| const text = document.getElementById('chatText').value.trim(); | |
| if (!text) return; | |
| document.getElementById('chatText').value = ''; | |
| document.getElementById('chatText').style.height = '42px'; | |
| runRAG(text); | |
| }); | |
| // Enter to send | |
| document.getElementById('chatText').addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); document.getElementById('sendBtn').click(); } | |
| }); | |
| // Auto-resize textarea | |
| document.getElementById('chatText').addEventListener('input', function() { | |
| this.style.height = '42px'; | |
| this.style.height = Math.min(this.scrollHeight, 120) + 'px'; | |
| }); | |
| // Table search | |
| document.getElementById('tableSearch').addEventListener('input', e => { | |
| STATE.searchQuery = e.target.value; | |
| renderTable(); | |
| }); | |
| // Table sort | |
| document.querySelectorAll('#vectorTable th[data-col]').forEach(th => { | |
| th.addEventListener('click', () => { | |
| const col = th.dataset.col; | |
| if (STATE.sortCol === col) STATE.sortDir = STATE.sortDir === 'asc' ? 'desc' : 'asc'; | |
| else { STATE.sortCol = col; STATE.sortDir = 'asc'; } | |
| renderTable(); | |
| }); | |
| }); | |
| // Table row click (open modal) | |
| document.getElementById('tableBody').addEventListener('click', e => { | |
| const delBtn = e.target.closest('[data-del]'); | |
| if (delBtn) { | |
| e.stopPropagation(); | |
| deleteEntry(Number(delBtn.dataset.del)); | |
| return; | |
| } | |
| const tr = e.target.closest('tr[data-id]'); | |
| if (tr) openEntryModal(Number(tr.dataset.id)); | |
| }); | |
| // Add entry form | |
| document.getElementById('addEntryBtn').addEventListener('click', () => { | |
| const form = document.getElementById('addForm'); | |
| form.classList.toggle('show'); | |
| if (form.classList.contains('show')) document.getElementById('newText').focus(); | |
| }); | |
| document.getElementById('cancelEntryBtn').addEventListener('click', () => { | |
| document.getElementById('addForm').classList.remove('show'); | |
| document.getElementById('newText').value = ''; | |
| document.getElementById('newMeta').value = ''; | |
| }); | |
| document.getElementById('submitEntryBtn').addEventListener('click', async () => { | |
| const text = document.getElementById('newText').value.trim(); | |
| if (!text) { toast('Please enter text content', 'error'); return; } | |
| let meta = {}; | |
| const metaStr = document.getElementById('newMeta').value.trim(); | |
| if (metaStr) { | |
| try { meta = JSON.parse(metaStr); } | |
| catch { toast('Invalid JSON in metadata field', 'error'); return; } | |
| } | |
| await addEntry(text, meta); | |
| document.getElementById('newText').value = ''; | |
| document.getElementById('newMeta').value = ''; | |
| document.getElementById('addForm').classList.remove('show'); | |
| }); | |
| // VDB toggle | |
| document.getElementById('toggleVdbBtn').addEventListener('click', () => { | |
| STATE.isVDBEnabled = !STATE.isVDBEnabled; | |
| const btn = document.getElementById('toggleVdbBtn'); | |
| btn.className = STATE.isVDBEnabled ? 'vp-btn on' : 'vp-btn off'; | |
| btn.innerHTML = `<i class="fas fa-database"></i> VDB: ${STATE.isVDBEnabled ? 'ON' : 'OFF'}`; | |
| toast(`Vector DB ${STATE.isVDBEnabled ? 'enabled' : 'disabled'} — ${STATE.isVDBEnabled ? 'chat will use retrieved context' : 'chat will use model knowledge only'}`, 'info'); | |
| }); | |
| // Settings sidebar close | |
| document.getElementById('ssClose').addEventListener('click', closeSettings); | |
| document.getElementById('settingsOverlay').addEventListener('click', closeSettings); | |
| // Modal close | |
| document.getElementById('modalClose').addEventListener('click', () => document.getElementById('entryModal').classList.remove('show')); | |
| document.getElementById('entryModal').addEventListener('click', e => { | |
| if (e.target === document.getElementById('entryModal')) document.getElementById('entryModal').classList.remove('show'); | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'Escape') { | |
| closeSettings(); | |
| document.getElementById('entryModal').classList.remove('show'); | |
| } | |
| }); | |
| } | |
| async function seedVectorDB() { | |
| const seeds = [ | |
| { text: "Transformers.js is a library that enables running machine learning models directly in the browser using ONNX Runtime. It supports text generation, image classification, audio processing, and more without requiring a server.", metadata: { source: "docs", topic: "transformers.js" } }, | |
| { text: "Vector databases store data as high-dimensional numerical vectors. They enable fast similarity search by comparing vector distances using metrics like cosine similarity, euclidean distance, or dot product.", metadata: { source: "docs", topic: "vector-db" } }, | |
| { text: "RAG (Retrieval-Augmented Generation) combines information retrieval with language model generation. It first searches a knowledge base for relevant passages, then feeds those passages as context to an LLM to produce grounded answers.", metadata: { source: "docs", topic: "rag" } }, | |
| { text: "LanceDB is an open-source vector database designed for fast similarity search. It uses the Lance columnar format for efficient storage and retrieval of vector embeddings, supporting ANN indexes and full-text search.", metadata: { source: "docs", topic: "lancedb" } }, | |
| { text: "Embedding models convert text into fixed-size numerical vectors that capture semantic meaning. Popular models include all-MiniLM-L6-v2 (384 dimensions), BGE embeddings, and E5 models. Higher dimensions generally capture more nuance.", metadata: { source: "docs", topic: "embeddings" } }, | |
| { text: "Reranking improves search quality by re-scoring initially retrieved results with a more expensive cross-encoder model. Cross-encoders jointly process the query and each document, producing more accurate relevance scores than bi-encoder similarity.", metadata: { source: "docs", topic: "reranking" } }, | |
| { text: "Quantization reduces model size by lowering numerical precision. Q4 uses 4-bit integers, Q4F16 uses 4-bit weights with 16-bit activations for WebGPU. Q4 runs on CPU/WASM, Q4F16 requires WebGPU due to specialized GPU-only operators.", metadata: { source: "docs", topic: "quantization" } }, | |
| { text: "WebGPU is a modern browser API for general-purpose GPU computation. It replaces WebGL for compute workloads and is required for running quantized models like Q4F16 in transformers.js. Chrome 113+ supports WebGPU natively.", metadata: { source: "docs", topic: "webgpu" } } | |
| ]; | |
| for (const s of seeds) { | |
| const vector = await embedText(s.text); | |
| const entry = { id: STATE.nextId++, text: s.text, metadata: s.metadata, vector, date: new Date().toISOString() }; | |
| STATE.entries.push(entry); | |
| vTable.add([{ ...entry }]); | |
| } | |
| renderTable(); | |
| } | |
| // ============================================= | |
| // Init | |
| // ============================================= | |
| async function init() { | |
| renderNodeFlow(); | |
| renderTable(); | |
| renderChat(); | |
| initEvents(); | |
| vTable = await db.openTable('rag_vectors'); | |
| updateSendBtn(); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |