RAG-Visualizer / index.html
quickgrid's picture
Update index.html
e611b8d verified
<!DOCTYPE html>
<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>