RAG-Visualizer / index.html
quickgrid's picture
Update index.html
d190577 verified
raw
history blame
59 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAG Pipeline Visualizer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════════════════ */
/* TOKENS */
/* ═══════════════════════════════════════════════════════════ */
:root {
--bg: #040912;
--bg2: #07111f;
--surface: #0b1a2e;
--surface2: #102038;
--surface3: #162845;
--border: #1d3254;
--border2: #26405e;
--cyan: #00e5ff;
--cyan-dim: rgba(0,229,255,.12);
--purple: #a855f7;
--purple-dim:rgba(168,85,247,.12);
--green: #00ff88;
--green-dim:rgba(0,255,136,.10);
--amber: #ffaa00;
--amber-dim:rgba(255,170,0,.12);
--red: #ff4455;
--pink: #f472b6;
--text: #c8dff5;
--text2: #7a9ab8;
--text3: #3d5a7a;
--mono: 'JetBrains Mono', monospace;
--sans: 'DM Sans', system-ui, sans-serif;
}
/* ═══════════════════════════════════════════════════════════ */
/* RESET & BASE */
/* ═══════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
display: grid;
grid-template-rows: 44px 200px 1fr;
height: 100vh;
}
/* scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
/* ═══════════════════════════════════════════════════════════ */
/* HEADER */
/* ═══════════════════════════════════════════════════════════ */
#header {
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: relative; z-index: 50;
}
#header::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg,transparent,var(--cyan),var(--purple),transparent);
opacity: .4;
}
.header-title {
font-family: var(--mono); font-size: 13px; font-weight: 600; letter-spacing: .06em;
background: linear-gradient(90deg, var(--cyan), var(--purple));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.header-title span { -webkit-text-fill-color: var(--text3); font-weight: 300; }
.model-badges { display: flex; gap: 8px; }
.badge {
display: flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 20px; border: 1px solid var(--border2);
font-family: var(--mono); font-size: 10px; color: var(--text3);
transition: all .3s;
}
.badge.loading { border-color: var(--amber); color: var(--amber); }
.badge.ready { border-color: var(--green); color: var(--green); }
.badge.error { border-color: var(--red); color: var(--red); }
.badge-dot {
width: 5px; height: 5px; border-radius: 50%; background: currentColor;
}
.badge.loading .badge-dot { animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1}50%{opacity:.2} }
/* ═══════════════════════════════════════════════════════════ */
/* NODE FLOW EDITOR */
/* ═══════════════════════════════════════════════════════════ */
#node-editor {
position: relative;
background: var(--bg2);
border-bottom: 1px solid var(--border);
overflow: hidden;
}
#flow-canvas { display: block; }
/* Settings panel */
#node-settings {
position: absolute; right: 0; top: 0; bottom: 0; width: 240px;
background: var(--surface);
border-left: 1px solid var(--border);
padding: 14px;
overflow-y: auto;
transform: translateX(100%);
transition: transform .25s;
z-index: 10;
}
#node-settings.open { transform: translateX(0); }
.ns-title {
font-family: var(--mono); font-size: 12px; font-weight: 600;
margin-bottom: 14px; letter-spacing: .04em;
}
.ns-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ns-label {
font-family: var(--mono); font-size: 9px; text-transform: uppercase;
letter-spacing: .08em; color: var(--text3);
}
.ns-input {
background: var(--surface3); border: 1px solid var(--border);
color: var(--text); padding: 5px 8px; border-radius: 4px;
font-family: var(--mono); font-size: 11px; width: 100%;
outline: none; transition: border-color .2s;
}
.ns-input:focus { border-color: var(--cyan); }
.ns-close {
float: right; background: none; border: none; color: var(--text2);
cursor: pointer; font-size: 16px; line-height: 1;
}
.ns-close:hover { color: var(--text); }
/* ═══════════════════════════════════════════════════════════ */
/* MAIN PANELS */
/* ═══════════════════════════════════════════════════════════ */
#main {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
overflow: hidden;
min-height: 0;
}
.panel {
display: flex; flex-direction: column; overflow: hidden;
border-right: 1px solid var(--border);
min-height: 0;
}
.panel:last-child { border-right: none; }
.panel-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-hdr-title {
font-family: var(--mono); font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: .1em; color: var(--text2);
}
.panel-hdr-badge {
font-family: var(--mono); font-size: 9px; color: var(--text3);
padding: 2px 6px; border-radius: 4px; background: var(--surface2);
}
/* ═══════════════════════════════════════════════════════════ */
/* CHAT PANEL */
/* ═══════════════════════════════════════════════════════════ */
#chat-messages {
flex: 1; overflow-y: auto; padding: 10px;
display: flex; flex-direction: column; gap: 8px;
min-height: 0;
}
.msg { display: flex; flex-direction: column; gap: 3px; }
.msg.user { align-items: flex-end; }
.msg.assistant { align-items: flex-start; }
.msg-bubble {
max-width: 88%; padding: 8px 11px; border-radius: 8px;
font-size: 12px; line-height: 1.55; white-space: pre-wrap;
}
.msg.user .msg-bubble {
background: linear-gradient(135deg, var(--purple), #7c3aed);
color: #fff; border-radius: 8px 8px 2px 8px;
}
.msg.assistant .msg-bubble {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 8px 8px 8px 2px;
}
.msg-time {
font-family: var(--mono); font-size: 9px; color: var(--text3);
padding: 0 4px;
}
.ctx-chips { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
.ctx-chip {
font-family: var(--mono); font-size: 9px;
padding: 1px 6px; border-radius: 10px;
background: var(--cyan-dim); border: 1px solid var(--cyan);
color: var(--cyan);
}
/* typing dots */
.typing { display: flex; gap: 4px; align-items: center; padding: 4px 2px; }
.typing i {
width: 5px; height: 5px; border-radius: 50%;
background: var(--purple); display: block;
animation: tdot 1s infinite;
}
.typing i:nth-child(2){animation-delay:.15s}
.typing i:nth-child(3){animation-delay:.3s}
@keyframes tdot{0%,100%{transform:translateY(0);opacity:.5}50%{transform:translateY(-5px);opacity:1}}
/* chat input */
#chat-input-wrap {
flex-shrink: 0; display: flex; gap: 6px; padding: 9px 10px;
border-top: 1px solid var(--border); background: var(--surface);
}
#chat-input {
flex: 1; background: var(--surface3); border: 1px solid var(--border);
color: var(--text); padding: 7px 11px; border-radius: 6px;
font-family: var(--sans); font-size: 12px; outline: none;
transition: border-color .2s;
}
#chat-input:focus { border-color: var(--purple); }
#chat-input::placeholder { color: var(--text3); }
#chat-input:disabled { opacity: .4; cursor: not-allowed; }
/* ═══════════════════════════════════════════════════════════ */
/* BUTTONS */
/* ═══════════════════════════════════════════════════════════ */
.btn {
padding: 7px 13px; border-radius: 6px; border: none; cursor: pointer;
font-family: var(--mono); font-size: 11px; font-weight: 600;
transition: all .18s; letter-spacing: .03em;
}
.btn-cyan {
background: var(--cyan); color: var(--bg);
}
.btn-cyan:hover { filter: brightness(1.15); transform: translateY(-1px); }
.btn-cyan:active { transform: translateY(0); }
.btn-cyan:disabled { opacity: .35; cursor: not-allowed; transform: none; filter: none; }
.btn-ghost {
background: var(--surface3); color: var(--text2);
border: 1px solid var(--border);
}
.btn-ghost:hover { border-color: var(--border2); color: var(--text); }
.btn-ghost:disabled { opacity: .35; cursor: not-allowed; }
.btn-green {
background: var(--green); color: var(--bg);
}
.btn-green:hover { filter: brightness(1.1); }
.btn-green:disabled { opacity: .35; cursor: not-allowed; filter: none; }
/* ═══════════════════════════════════════════════════════════ */
/* RETRIEVAL PANEL */
/* ═══════════════════════════════════════════════════════════ */
#retrieval-panel { display: flex; flex-direction: column; min-height: 0; }
.ret-section { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.ret-section + .ret-section { border-top: 1px solid var(--border); }
.ret-section-hdr {
flex-shrink: 0; padding: 5px 10px;
font-family: var(--mono); font-size: 9px; font-weight: 600;
text-transform: uppercase; letter-spacing: .1em;
color: var(--text3); background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}
.ret-list { flex: 1; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; min-height: 0; }
.ret-item {
padding: 8px 10px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 5px;
font-size: 11px; transition: all .35s; position: relative;
cursor: default;
}
.ret-item.lit {
border-color: var(--cyan);
box-shadow: 0 0 0 1px var(--cyan-dim), inset 0 0 20px var(--cyan-dim);
background: rgba(0,229,255,.05);
animation: ret-pop .35s ease;
}
.ret-item.selected {
border-color: var(--green);
box-shadow: 0 0 0 1px var(--green-dim), inset 0 0 20px var(--green-dim);
background: rgba(0,255,136,.04);
animation: ret-pop .35s ease;
}
@keyframes ret-pop{0%{transform:scale(.96);opacity:.5}100%{transform:scale(1);opacity:1}}
.ret-rank {
position: absolute; top: -8px; left: 8px;
font-family: var(--mono); font-size: 9px; font-weight: 700;
padding: 1px 5px; border-radius: 4px;
background: var(--amber); color: var(--bg);
}
.ret-item.selected .ret-rank { background: var(--green); }
.ret-text {
color: var(--text); font-size: 11px; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
margin-bottom: 5px;
}
.ret-foot { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.ret-meta { font-family: var(--mono); font-size: 9px; color: var(--text3); }
.score-bar { flex: 1; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
.score-fill {
height: 100%; border-radius: 1px;
transition: width .6s ease;
background: linear-gradient(90deg, var(--purple), var(--cyan));
}
.ret-item.selected .score-fill {
background: linear-gradient(90deg, var(--green), var(--cyan));
}
.score-pct { font-family: var(--mono); font-size: 9px; color: var(--text2); white-space: nowrap; }
/* ═══════════════════════════════════════════════════════════ */
/* VECTOR DB PANEL */
/* ═══════════════════════════════════════════════════════════ */
#add-form {
flex-shrink: 0; padding: 9px; background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: 6px;
}
#add-form textarea {
background: var(--surface3); border: 1px solid var(--border);
color: var(--text); padding: 7px 9px; border-radius: 5px;
font-family: var(--sans); font-size: 11px; resize: none; height: 54px;
outline: none; width: 100%; line-height: 1.45; transition: border-color .2s;
}
#add-form textarea:focus { border-color: var(--green); }
#add-form textarea::placeholder { color: var(--text3); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.form-row input {
background: var(--surface3); border: 1px solid var(--border);
color: var(--text); padding: 5px 8px; border-radius: 4px;
font-family: var(--mono); font-size: 10px; outline: none; width: 100%;
transition: border-color .2s;
}
.form-row input:focus { border-color: var(--green); }
.form-row input::placeholder { color: var(--text3); }
.embed-progress { display: none; }
.embed-progress.show { display: block; }
.embed-progress-bar {
height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; margin-top: 2px;
}
.embed-progress-fill {
height: 100%; background: linear-gradient(90deg, var(--green), var(--cyan));
border-radius: 1px; width: 0; transition: width .3s;
}
.embed-progress-txt {
font-family: var(--mono); font-size: 9px; color: var(--green);
}
/* table */
#vdb-table-wrap { flex: 1; overflow: auto; min-height: 0; }
#vdb-table {
width: 100%; border-collapse: collapse;
font-family: var(--mono); font-size: 10px;
}
#vdb-table th {
padding: 5px 8px; background: var(--surface3);
border-bottom: 1px solid var(--border);
text-align: left; font-weight: 600; color: var(--text3);
text-transform: uppercase; letter-spacing: .06em;
position: sticky; top: 0; z-index: 1;
}
#vdb-table td {
padding: 5px 8px; border-bottom: 1px solid rgba(29,50,84,.4);
color: var(--text); vertical-align: top;
}
#vdb-table tr { transition: background .3s; }
#vdb-table tr:hover td { background: var(--surface2); }
#vdb-table tr.lit td { background: rgba(0,229,255,.05); }
#vdb-table tr.lit { outline: 1px solid var(--cyan); }
.vec-chip {
display: inline-block;
font-size: 9px; color: var(--cyan); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 80px;
}
.tag {
display: inline-block; padding: 1px 5px; border-radius: 3px;
font-size: 9px; font-weight: 600;
background: var(--surface3); border: 1px solid var(--border); color: var(--text3);
}
.tag.new-tag { border-color: var(--green); color: var(--green); animation: tag-glow 2s ease; }
@keyframes tag-glow{0%{box-shadow:0 0 10px var(--green)}100%{box-shadow:none}}
.tag.selected-tag { border-color: var(--cyan); color: var(--cyan); }
/* ═══════════════════════════════════════════════════════════ */
/* EMPTY STATE */
/* ═══════════════════════════════════════════════════════════ */
.empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--text3); font-size: 11px; gap: 7px; padding: 20px; text-align: center;
}
.empty-icon { font-size: 28px; opacity: .5; }
/* ═══════════════════════════════════════════════════════════ */
/* TOAST */
/* ═══════════════════════════════════════════════════════════ */
#toast {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%) translateY(60px);
padding: 8px 16px; background: var(--surface3); border: 1px solid var(--border2);
border-radius: 8px; font-family: var(--mono); font-size: 11px; color: var(--text);
transition: transform .3s; z-index: 999; pointer-events: none; white-space: nowrap;
}
#toast.show { transform: translateX(-50%) translateY(0); }
/* ═══════════════════════════════════════════════════════════ */
/* LOADING OVERLAY */
/* ═══════════════════════════════════════════════════════════ */
#loading-overlay {
position: fixed; inset: 0; background: var(--bg);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 20px; z-index: 200; transition: opacity .5s;
}
#loading-overlay.fade { opacity: 0; pointer-events: none; }
.lo-title {
font-family: var(--mono); font-size: 20px; font-weight: 700; letter-spacing: .08em;
background: linear-gradient(90deg, var(--cyan), var(--purple));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.lo-sub {
font-family: var(--sans); font-size: 12px; color: var(--text2); text-align: center;
max-width: 340px; line-height: 1.6;
}
.lo-steps { display: flex; flex-direction: column; gap: 8px; width: 320px; }
.lo-step {
display: flex; align-items: center; gap: 10px;
font-family: var(--mono); font-size: 11px; color: var(--text3);
transition: color .3s;
}
.lo-step.active { color: var(--cyan); }
.lo-step.done { color: var(--green); }
.lo-step-icon { font-size: 14px; width: 20px; text-align: center; }
.lo-bar-wrap { width: 320px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
.lo-bar-fill {
height: 100%; background: linear-gradient(90deg, var(--cyan), var(--purple));
border-radius: 1px; width: 0%; transition: width .5s;
}
</style>
</head>
<body>
<!-- LOADING OVERLAY -->
<div id="loading-overlay">
<div class="lo-title">⬡ RAG PIPELINE VISUALIZER</div>
<div class="lo-sub">Loading AI models into your browser. Models are cached after first download.</div>
<div class="lo-steps">
<div class="lo-step" id="lo-embed">
<span class="lo-step-icon">🔢</span>
<span>Embedding model — all-MiniLM-L6-v2 (22 MB)</span>
</div>
<div class="lo-step" id="lo-rerank">
<span class="lo-step-icon">🔄</span>
<span>Reranker — ms-marco-MiniLM-L-6 (~80 MB)</span>
</div>
<div class="lo-step" id="lo-llm">
<span class="lo-step-icon">🤖</span>
<span>LLM — LaMini-Flan-T5-248M (~900 MB)</span>
</div>
</div>
<div class="lo-bar-wrap"><div class="lo-bar-fill" id="lo-bar"></div></div>
<div class="lo-sub" id="lo-status">Initializing…</div>
</div>
<!-- HEADER -->
<div id="header">
<div class="header-title">⬡ RAG<span>/</span>VISUALIZER <span>· in-browser · powered by transformers.js</span></div>
<div class="model-badges">
<div class="badge" id="bd-embed"><div class="badge-dot"></div> Embed</div>
<div class="badge" id="bd-rerank"><div class="badge-dot"></div> Rerank</div>
<div class="badge" id="bd-llm"><div class="badge-dot"></div> LLM</div>
</div>
</div>
<!-- NODE FLOW EDITOR -->
<div id="node-editor">
<canvas id="flow-canvas"></canvas>
<div id="node-settings">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div class="ns-title" id="ns-title">Node Settings</div>
<button class="ns-close" onclick="closeSettings()"></button>
</div>
<div id="ns-body"></div>
</div>
</div>
<!-- MAIN PANELS -->
<div id="main">
<!-- CHAT -->
<div class="panel" id="chat-panel">
<div class="panel-hdr">
<span class="panel-hdr-title">💬 Chat</span>
<span class="panel-hdr-badge" id="chat-status">idle</span>
</div>
<div id="chat-messages">
<div class="empty" id="chat-empty">
<div class="empty-icon">🤖</div>
<span>Ask anything. Every step of the<br>RAG pipeline will be visualized live.</span>
</div>
</div>
<div id="chat-input-wrap">
<input id="chat-input" placeholder="Ask a question…" disabled />
<button class="btn btn-cyan" id="send-btn" disabled>Send</button>
</div>
</div>
<!-- RETRIEVAL + RERANKING -->
<div class="panel" id="retrieval-panel">
<div class="panel-hdr">
<span class="panel-hdr-title">🔍 Retrieval &amp; Reranking</span>
<span class="panel-hdr-badge" id="ret-status">idle</span>
</div>
<div class="ret-section">
<div class="ret-section-hdr">
<span>Retrieved (Top-K)</span>
<span id="ret-k-badge" style="color:var(--cyan);font-size:9px"></span>
</div>
<div class="ret-list" id="ret-list">
<div class="empty" id="ret-empty">
<div class="empty-icon">📚</div>
<span>Retrieved chunks appear here</span>
</div>
</div>
</div>
<div class="ret-section">
<div class="ret-section-hdr">
<span>After Reranking</span>
<span id="rerank-k-badge" style="color:var(--green);font-size:9px"></span>
</div>
<div class="ret-list" id="rerank-list">
<div class="empty" id="rerank-empty">
<div class="empty-icon">🔄</div>
<span>Reranked context appears here</span>
</div>
</div>
</div>
</div>
<!-- VECTOR DB -->
<div class="panel" id="vectordb-panel">
<div class="panel-hdr">
<span class="panel-hdr-title">🗄️ Vector DB (LanceDB-style)</span>
<span class="panel-hdr-badge" id="db-count">0 vectors</span>
</div>
<div id="add-form">
<textarea id="add-text" placeholder="Type or paste text to embed and store in the vector DB…"></textarea>
<div class="form-row">
<input id="add-source" placeholder="Source (e.g. doc1.pdf)" />
<input id="add-category" placeholder="Category (e.g. science)" />
</div>
<div class="embed-progress" id="embed-prog">
<div class="embed-progress-txt" id="embed-prog-txt">Embedding…</div>
<div class="embed-progress-bar"><div class="embed-progress-fill" id="embed-prog-fill"></div></div>
</div>
<button class="btn btn-green" id="add-btn" style="width:100%" disabled>+ Embed &amp; Add to Vector DB</button>
</div>
<div id="vdb-table-wrap">
<table id="vdb-table">
<thead>
<tr>
<th>#</th>
<th style="min-width:110px">Text Preview</th>
<th>Vector Preview</th>
<th>Source</th>
<th>Category</th>
<th>Date Added</th>
</tr>
</thead>
<tbody id="vdb-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- TOAST -->
<div id="toast"></div>
<!-- ═══════════════════════════════════════════════════════════
SCRIPT
═══════════════════════════════════════════════════════════ -->
<script type="module">
import { pipeline, env }
from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';
env.allowLocalModels = false;
env.useBrowserCache = true; // cache models in browser
/* ─────────────────────────────────────────────────────────── */
/* CONFIG */
/* ─────────────────────────────────────────────────────────── */
const CFG = {
embedModel: 'Xenova/all-MiniLM-L6-v2',
rerankModel: 'Xenova/ms-marco-MiniLM-L-6-v2',
llmModel: 'Xenova/LaMini-Flan-T5-248M',
topK: 5,
topKRerank: 3,
maxTokens: 180,
};
/* ─────────────────────────────────────────────────────────── */
/* STATE */
/* ─────────────────────────────────────────────────────────── */
const ST = {
embedder: null, reranker: null, generator: null,
db: [], // {id,text,vec,source,category,date}
busy: false,
};
/* ─────────────────────────────────────────────────────────── */
/* IndexedDB persistence (LanceDB-style in-browser) */
/* ─────────────────────────────────────────────────────────── */
let idb = null;
async function openIDB() {
return new Promise((res, rej) => {
const req = indexedDB.open('rag-viz-db', 1);
req.onupgradeneeded = e => {
e.target.result.createObjectStore('vectors', { keyPath: 'id' });
};
req.onsuccess = e => { idb = e.target.result; res(); };
req.onerror = e => rej(e);
});
}
async function idbGetAll() {
return new Promise((res, rej) => {
const tx = idb.transaction('vectors','readonly');
const req = tx.objectStore('vectors').getAll();
req.onsuccess = e => res(e.target.result || []);
req.onerror = e => rej(e);
});
}
async function idbPut(entry) {
return new Promise((res, rej) => {
const tx = idb.transaction('vectors','readwrite');
tx.objectStore('vectors').put(entry);
tx.oncomplete = res; tx.onerror = rej;
});
}
/* ─────────────────────────────────────────────────────────── */
/* VECTOR MATH */
/* ─────────────────────────────────────────────────────────── */
function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i];
}
return dot / (Math.sqrt(na)*Math.sqrt(nb) + 1e-9);
}
function vecSearch(queryVec, topK) {
const scored = ST.db.map(e => ({
...e, score: cosine(queryVec, e.vec)
}));
scored.sort((a,b) => b.score - a.score);
return scored.slice(0, topK);
}
/* ─────────────────────────────────────────────────────────── */
/* TOAST */
/* ─────────────────────────────────────────────────────────── */
let toastTimer;
function toast(msg, dur = 3200) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), dur);
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const $ = id => document.getElementById(id);
/* ─────────────────────────────────────────────────────────── */
/* BADGE helpers */
/* ─────────────────────────────────────────────────────────── */
function badge(id, state) {
const el = $(`bd-${id}`);
el.className = `badge ${state}`;
}
function loStep(id, state) {
const el = $(`lo-${id}`);
if (state === 'active') { el.className = 'lo-step active'; el.querySelector('.lo-step-icon').textContent = '⏳'; }
if (state === 'done') { el.className = 'lo-step done'; el.querySelector('.lo-step-icon').textContent = '✅'; }
if (state === 'error') { el.className = 'lo-step'; el.querySelector('.lo-step-icon').textContent = '⚠️'; }
}
function loBar(pct) { $('lo-bar').style.width = pct + '%'; }
/* ─────────────────────────────────────────────────────────── */
/* MODEL LOADING */
/* ─────────────────────────────────────────────────────────── */
async function loadModels() {
$('lo-status').textContent = 'Loading embedding model…';
loStep('embed','active'); loBar(5); badge('embed','loading');
try {
ST.embedder = await pipeline('feature-extraction', CFG.embedModel, {
progress_callback: p => {
if (p.status === 'progress') {
const pct = 5 + (p.progress || 0) * .2;
loBar(Math.min(25, pct));
}
}
});
badge('embed','ready'); loStep('embed','done');
toast('✅ Embedding model ready');
} catch(e) {
badge('embed','error'); loStep('embed','error');
console.error(e); toast('⚠️ Embedding model failed: '+e.message, 5000);
}
loBar(25); $('lo-status').textContent = 'Loading reranking model…';
loStep('rerank','active'); badge('rerank','loading');
try {
ST.reranker = await pipeline('text-classification', CFG.rerankModel, {
progress_callback: p => {
if (p.status === 'progress') loBar(25 + (p.progress||0)*.2);
}
});
badge('rerank','ready'); loStep('rerank','done');
toast('✅ Reranking model ready');
} catch(e) {
badge('rerank','error'); loStep('rerank','error');
ST.reranker = null;
console.warn('Reranker unavailable, fallback to cosine:', e.message);
toast('⚠️ Reranker unavailable — cosine fallback active', 5000);
}
loBar(45); $('lo-status').textContent = 'Loading LLM (large — may take a minute)…';
loStep('llm','active'); badge('llm','loading');
try {
ST.generator = await pipeline('text2text-generation', CFG.llmModel, {
progress_callback: p => {
if (p.status === 'progress') loBar(45 + (p.progress||0)*.5);
}
});
badge('llm','ready'); loStep('llm','done');
toast('✅ LLM ready!');
} catch(e) {
badge('llm','error'); loStep('llm','error');
ST.generator = null;
console.warn('LLM unavailable:', e.message);
toast('⚠️ LLM not loaded — answers will be template-based', 5000);
}
loBar(100);
$('lo-status').textContent = ST.embedder ? 'Ready! All models loaded.' : '⚠️ Some models failed.';
await sleep(800);
$('loading-overlay').classList.add('fade');
setTimeout(() => { $('loading-overlay').style.display='none'; }, 500);
if (ST.embedder) {
$('chat-input').disabled = false;
$('send-btn').disabled = false;
$('add-btn').disabled = false;
// Load sample data if DB empty
if (ST.db.length === 0) await insertSamples();
}
FE.setAllIdle();
}
/* ─────────────────────────────────────────────────────────── */
/* EMBEDDING & RERANKING & GENERATION */
/* ─────────────────────────────────────────────────────────── */
async function embed(text) {
const out = await ST.embedder(text, { pooling: 'mean', normalize: true });
return Array.from(out.data);
}
async function rerank(query, results) {
const topK = CFG.topKRerank;
if (!ST.reranker) {
return [...results].sort((a,b)=>b.score-a.score).slice(0,topK)
.map(r=>({...r, rerankScore: r.score}));
}
try {
const pairs = results.map(r => [query, r.text]);
const scores = await ST.reranker(pairs, { topk: 1 });
const withScores = results.map((r, i) => ({
...r,
rerankScore: Array.isArray(scores[i]) ? scores[i][0].score : (scores[i]?.score ?? r.score)
}));
withScores.sort((a,b) => b.rerankScore - a.rerankScore);
return withScores.slice(0, topK);
} catch(e) {
console.warn('Rerank error, cosine fallback:', e.message);
return [...results].sort((a,b)=>b.score-a.score).slice(0,topK)
.map(r=>({...r, rerankScore: r.score}));
}
}
async function generate(query, context) {
const ctxTxt = context.map((c,i) => `[${i+1}] ${c.text}`).join('\n');
const prompt = `Answer the question using the context.\n\nContext:\n${ctxTxt}\n\nQuestion: ${query}\nAnswer:`;
if (!ST.generator) {
return `Context summary:\n${context.slice(0,2).map(c=>'• '+c.text.slice(0,100)).join('\n')}`;
}
try {
const out = await ST.generator(prompt, { max_new_tokens: CFG.maxTokens, do_sample: false });
return (out[0]?.generated_text || '').trim() || 'No answer generated.';
} catch(e) {
console.error('Generate error:', e);
return context[0]?.text || 'No context found.';
}
}
/* ─────────────────────────────────────────────────────────── */
/* VECTOR DB UI */
/* ─────────────────────────────────────────────────────────── */
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
function vecPreview(v) {
return `[${v.slice(0,3).map(x=>x.toFixed(2)).join(', ')},…]`;
}
function addTableRow(entry, isNew=false) {
const tbody = $('vdb-tbody');
const rowNum = tbody.children.length + 1;
const tr = document.createElement('tr');
tr.dataset.id = entry.id;
const textPrev = entry.text.length > 40 ? entry.text.slice(0,40)+'…' : entry.text;
tr.innerHTML = `
<td style="color:var(--text3)">${rowNum}</td>
<td title="${entry.text}" style="font-family:var(--sans);font-size:11px">${textPrev}</td>
<td><span class="vec-chip">${vecPreview(entry.vec)}</span></td>
<td><span class="tag ${isNew?'new-tag':''}">${entry.source||'—'}</span></td>
<td style="color:var(--text2)">${entry.category||'—'}</td>
<td style="color:var(--text3)">${fmtDate(entry.date)}</td>
`;
tbody.appendChild(tr);
if (isNew) tr.scrollIntoView({behavior:'smooth',block:'nearest'});
$('db-count').textContent = `${tbody.children.length} vectors`;
}
function rebuildTable() {
$('vdb-tbody').innerHTML = '';
ST.db.forEach(e => addTableRow(e, false));
$('db-count').textContent = `${ST.db.length} vectors`;
}
function highlightRows(ids) {
document.querySelectorAll('#vdb-tbody tr').forEach(tr => {
tr.classList.toggle('lit', ids.includes(tr.dataset.id));
});
}
function clearHighlightRows() {
document.querySelectorAll('#vdb-tbody tr').forEach(tr => tr.classList.remove('lit'));
}
/* ─────────────────────────────────────────────────────────── */
/* ADD ENTRY */
/* ─────────────────────────────────────────────────────────── */
async function addEntry() {
const text = $('add-text').value.trim();
if (!text) { toast('Enter some text first'); return; }
if (!ST.embedder) { toast('Embedding model not ready'); return; }
$('add-btn').disabled = true;
$('add-btn').textContent = 'Embedding…';
$('embed-prog').classList.add('show');
$('embed-prog-txt').textContent = 'Computing embedding…';
// Animate embed node active
FE.setStatus('embed', 'active');
// Fake progress
let pct = 0;
const progTimer = setInterval(() => {
pct = Math.min(pct + 8, 90);
$('embed-prog-fill').style.width = pct + '%';
}, 120);
try {
const vec = await embed(text);
clearInterval(progTimer);
$('embed-prog-fill').style.width = '100%';
FE.setStatus('embed', 'done');
FE.setStatus('search', 'active');
const entry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2,6),
text, vec,
source: $('add-source').value.trim() || 'manual',
category: $('add-category').value.trim() || 'general',
date: new Date().toISOString(),
};
ST.db.push(entry);
await idbPut(entry);
addTableRow(entry, true);
// Animate edge
await FE.animEdgePromise('embed','search');
FE.setStatus('search','done');
setTimeout(() => FE.setAllIdle(), 800);
toast(`✅ Stored "${text.slice(0,35)}…" in vector DB`);
$('add-text').value = '';
} catch(e) {
clearInterval(progTimer);
toast('❌ Error: '+e.message, 5000);
console.error(e);
FE.setAllIdle();
}
$('embed-prog').classList.remove('show');
$('embed-prog-fill').style.width = '0';
$('add-btn').disabled = false;
$('add-btn').textContent = '+ Embed & Add to Vector DB';
}
/* ─────────────────────────────────────────────────────────── */
/* RETRIEVAL UI */
/* ─────────────────────────────────────────────────────────── */
function renderRetList(items, listId, isReranked=false) {
const el = $(listId);
el.innerHTML = '';
if (!items || items.length === 0) {
el.innerHTML = `<div class="empty"><div class="empty-icon">${isReranked?'🔄':'📚'}</div><span>No results</span></div>`;
return;
}
items.forEach((item, i) => {
const div = document.createElement('div');
div.className = `ret-item${isReranked?' selected':''}`;
div.dataset.id = item.id;
const score = isReranked ? (item.rerankScore ?? item.score) : item.score;
const scorePct = Math.min(100, Math.abs(score) * 100);
div.innerHTML = `
<div class="ret-rank">#${i+1}</div>
<div class="ret-text">${item.text}</div>
<div class="ret-foot">
<span class="ret-meta">${item.source||'?'} · ${item.category||'?'}</span>
<div class="score-bar"><div class="score-fill" style="width:0%"></div></div>
<span class="score-pct">${(score*100).toFixed(1)}%</span>
</div>
`;
setTimeout(() => {
div.classList.add(isReranked ? 'selected' : 'lit');
div.querySelector('.score-fill').style.width = scorePct + '%';
}, i * 90);
el.appendChild(div);
});
}
/* ─────────────────────────────────────────────────────────── */
/* CHAT UI */
/* ─────────────────────────────────────────────────────────── */
function chatMsg(role, text, ctxItems=[]) {
const c = $('chat-messages');
$('chat-empty')?.remove();
const div = document.createElement('div');
div.className = `msg ${role}`;
let chips = '';
if (ctxItems.length > 0) {
chips = `<div class="ctx-chips">${ctxItems.map(c=>`<span class="ctx-chip">📄 ${c.source||'chunk'}</span>`).join('')}</div>`;
}
div.innerHTML = `
<div class="msg-bubble">${text.replace(/</g,'&lt;').replace(/\n/g,'<br>')}</div>
${chips}
<div class="msg-time">${new Date().toLocaleTimeString()}</div>
`;
c.appendChild(div);
c.scrollTop = c.scrollHeight;
return div;
}
let typingEl = null;
function showTyping() {
const c = $('chat-messages');
typingEl = document.createElement('div');
typingEl.className = 'msg assistant';
typingEl.id = '__typing';
typingEl.innerHTML = `<div class="msg-bubble"><div class="typing"><i></i><i></i><i></i></div></div>`;
c.appendChild(typingEl);
c.scrollTop = c.scrollHeight;
}
function removeTyping() { $('__typing')?.remove(); typingEl = null; }
/* ─────────────────────────────────────────────────────────── */
/* RAG PIPELINE */
/* ─────────────────────────────────────────────────────────── */
async function runRAG(query) {
if (ST.busy) return;
if (!ST.embedder) { toast('Embedding model not loaded yet'); return; }
ST.busy = true;
$('send-btn').disabled = true;
$('chat-status').textContent = 'processing…';
$('ret-status').textContent = 'searching…';
chatMsg('user', query);
showTyping();
// Clear retrieval panels
$('ret-list').innerHTML = `<div class="empty"><div class="empty-icon">⏳</div><span>Searching…</span></div>`;
$('rerank-list').innerHTML = `<div class="empty"><div class="empty-icon">⏳</div><span>Waiting…</span></div>`;
clearHighlightRows();
FE.setAllIdle();
try {
/* ── 1. Query node ── */
FE.setStatus('query','active');
await sleep(200);
FE.setStatus('query','done');
await FE.animEdgePromise('query','embed');
/* ── 2. Embed query ── */
FE.setStatus('embed','active');
$('chat-status').textContent = 'embedding query…';
const qVec = await embed(query);
FE.setStatus('embed','done');
await FE.animEdgePromise('embed','search');
/* ── 3. Vector search ── */
FE.setStatus('search','active');
$('chat-status').textContent = 'vector search…';
await sleep(150);
const retrieved = vecSearch(qVec, CFG.topK);
FE.setStatus('search','done');
await FE.animEdgePromise('search','topk');
/* ── 4. Top-K ── */
FE.setStatus('topk','active');
$('ret-k-badge').textContent = `k=${retrieved.length}`;
renderRetList(retrieved,'ret-list',false);
highlightRows(retrieved.map(r=>r.id));
await sleep(400);
FE.setStatus('topk','done');
await FE.animEdgePromise('topk','rerank');
/* ── 5. Rerank ── */
FE.setStatus('rerank','active');
$('ret-status').textContent = 'reranking…';
$('chat-status').textContent = 'reranking…';
const reranked = await rerank(query, retrieved);
$('rerank-k-badge').textContent = `k=${reranked.length}`;
renderRetList(reranked,'rerank-list',true);
FE.setStatus('rerank','done');
await FE.animEdgePromise('rerank','llm');
/* ── 6. LLM ── */
FE.setStatus('llm','active');
$('chat-status').textContent = 'generating…';
const context = reranked.length > 0 ? reranked : retrieved.slice(0, CFG.topKRerank);
const answer = await generate(query, context);
FE.setStatus('llm','done');
await FE.animEdgePromise('llm','answer');
/* ── 7. Answer ── */
FE.setStatus('answer','active');
removeTyping();
chatMsg('assistant', answer, context);
FE.setStatus('answer','done');
} catch(e) {
removeTyping();
chatMsg('assistant', '❌ Pipeline error: '+e.message);
console.error(e);
FE.setAllIdle();
}
$('send-btn').disabled = false;
$('chat-status').textContent = 'idle';
$('ret-status').textContent = 'idle';
ST.busy = false;
clearHighlightRows();
}
/* ─────────────────────────────────────────────────────────── */
/* SAMPLE DATA */
/* ─────────────────────────────────────────────────────────── */
async function insertSamples() {
toast('Adding sample documents…', 5000);
const samples = [
{ text:'The Eiffel Tower in Paris was built in 1889 by Gustave Eiffel for the World Fair. It stands 330 metres tall.', source:'wiki.txt', category:'history' },
{ text:'Machine learning is a branch of AI that enables systems to learn from data without being explicitly programmed.', source:'ml-intro.md', category:'tech' },
{ text:'The Amazon River carries more water than any other river on Earth and drains into the Atlantic Ocean.', source:'geo.txt', category:'geography' },
{ text:'Python is a high-level interpreted language prized for readability, used widely in data science and web development.', source:'prog.md', category:'tech' },
{ text:'The speed of light in a vacuum is exactly 299,792,458 metres per second, a fundamental constant of nature.', source:'physics.txt', category:'science' },
{ text:'Transformers are a neural network architecture introduced in "Attention is All You Need" (2017). Self-attention is core.', source:'dl-paper.txt', category:'AI' },
{ text:'Retrieval-Augmented Generation (RAG) combines a retriever to find relevant passages with a language model to generate answers.', source:'rag-overview.md', category:'AI' },
];
for (const s of samples) {
FE.setStatus('embed','active');
const vec = await embed(s.text);
FE.setStatus('embed','done');
const entry = {
id: Date.now().toString(36)+Math.random().toString(36).slice(2,5),
text: s.text, vec, source: s.source, category: s.category,
date: new Date().toISOString()
};
ST.db.push(entry);
await idbPut(entry);
addTableRow(entry, true);
await sleep(80);
}
FE.setAllIdle();
toast('✅ Sample documents loaded. Try asking a question!', 4000);
}
/* ─────────────────────────────────────────────────────────── */
/* NODE FLOW EDITOR */
/* ─────────────────────────────────────────────────────────── */
class FlowEditor {
constructor() {
this.canvas = $('flow-canvas');
this.ctx = this.canvas.getContext('2d');
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.W = 0; this.H = 0;
this.selectedId = null;
this.particles = [];
this.nodes = [
{ id:'query', label:'Query', icon:'📝', color:'#a855f7',
settings:{ Type:'User Input', Description:'Raw text question from user' }},
{ id:'embed', label:'Embed Query', icon:'🔢', color:'#00e5ff',
settings:{ Model:CFG.embedModel, Dims:384, Pooling:'mean', Normalize:true }},
{ id:'search', label:'Vector Search', icon:'🗄️', color:'#ffaa00',
settings:{ Metric:'Cosine', 'Top-K':CFG.topK, Index:'Flat (in-memory)' }},
{ id:'topk', label:'Top-K Select', icon:'📊', color:'#f472b6',
settings:{ K:CFG.topK, Threshold:'0.0', Dedup:'false' }},
{ id:'rerank', label:'Rerank', icon:'🔄', color:'#00ff88',
settings:{ Model:CFG.rerankModel, Type:'Cross-Encoder', 'Keep':CFG.topKRerank }},
{ id:'llm', label:'LLM', icon:'🤖', color:'#fb923c',
settings:{ Model:CFG.llmModel, MaxTokens:CFG.maxTokens, Sample:false }},
{ id:'answer', label:'Answer', icon:'💬', color:'#34d399',
settings:{ Format:'Text', Grounded:true, Sources:'Top reranked chunks' }},
];
this.edges = [
['query','embed'],['embed','search'],['search','topk'],
['topk','rerank'],['rerank','llm'],['llm','answer']
];
this.statuses = {};
this.nodes.forEach(n => this.statuses[n.id] = 'idle');
this.resize();
window.addEventListener('resize', () => this.resize());
this.canvas.addEventListener('click', e => this.onClick(e));
this.raf();
}
resize() {
const wrap = this.canvas.parentElement;
const rect = wrap.getBoundingClientRect();
this.W = rect.width; this.H = rect.height;
this.canvas.width = this.W * this.dpr;
this.canvas.height = this.H * this.dpr;
this.canvas.style.cssText = `width:${this.W}px;height:${this.H}px;`;
this.ctx.scale(this.dpr, this.dpr);
this.layout();
}
layout() {
const n = this.nodes.length;
const nW = 110, nH = 68;
const pad = 28;
const gap = (this.W - pad*2 - n*nW) / (n - 1);
this.nodes.forEach((node, i) => {
node.x = pad + i*(nW+gap);
node.y = (this.H - nH) / 2;
node.w = nW; node.h = nH;
});
}
draw() {
const { ctx, W, H } = this;
ctx.clearRect(0, 0, W, H);
// Grid
ctx.save();
ctx.strokeStyle = 'rgba(29,50,84,0.35)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
ctx.restore();
// Edges
this.edges.forEach(([fId,tId]) => {
const fn = this.nodeById(fId), tn = this.nodeById(tId);
const fx = fn.x+fn.w, fy = fn.y+fn.h/2;
const tx = tn.x, ty = tn.y+tn.h/2;
const cx = (fx+tx)/2;
ctx.beginPath();
ctx.moveTo(fx,fy);
ctx.bezierCurveTo(cx,fy,cx,ty,tx,ty);
ctx.strokeStyle = 'rgba(29,50,84,0.9)';
ctx.lineWidth = 2;
ctx.stroke();
});
// Particles
this.particles.forEach(p => {
const fn = this.nodeById(p.from), tn = this.nodeById(p.to);
const fx = fn.x+fn.w, fy = fn.y+fn.h/2;
const tx = tn.x, ty = tn.y+tn.h/2;
const cx = (fx+tx)/2;
const pt = this.bezPt(fx,fy,cx,fy,cx,ty,tx,ty,p.t);
const grd = ctx.createRadialGradient(pt.x,pt.y,0,pt.x,pt.y,10);
grd.addColorStop(0, fn.color+'cc');
grd.addColorStop(1, 'transparent');
ctx.beginPath(); ctx.arc(pt.x,pt.y,10,0,Math.PI*2);
ctx.fillStyle = grd; ctx.fill();
ctx.beginPath(); ctx.arc(pt.x,pt.y,3,0,Math.PI*2);
ctx.fillStyle = fn.color; ctx.fill();
});
// Nodes
this.nodes.forEach(n => this.drawNode(n));
}
drawNode(n) {
const { ctx } = this;
const { x, y, w, h, color } = n;
const st = this.statuses[n.id];
const sel = this.selectedId === n.id;
// Glow
if (st === 'active') { ctx.shadowColor = color; ctx.shadowBlur = 18; }
else if (sel) { ctx.shadowColor = color; ctx.shadowBlur = 10; }
// BG
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.fillStyle = st==='active' ? color+'1a' : st==='done' ? '#001f14' : '#0b1a2e';
ctx.fill();
ctx.shadowBlur = 0;
// Border
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.strokeStyle = st==='idle' ? '#1d3254' :
st==='active'? color :
st==='done' ? '#00ff88' : color;
ctx.lineWidth = sel ? 2.5 : (st==='active' ? 2 : 1.5);
ctx.stroke();
// Status dot
const dotColor = { idle:'#1d3254', loading:CFG.amber, active:color, done:'#00ff88' }[st] || '#1d3254';
ctx.beginPath(); ctx.arc(x+w-9, y+9, 4, 0, Math.PI*2);
ctx.fillStyle = dotColor; ctx.fill();
if (st==='active') {
ctx.beginPath(); ctx.arc(x+w-9, y+9, 7, 0, Math.PI*2);
ctx.strokeStyle = dotColor+'55'; ctx.lineWidth=2; ctx.stroke();
}
// Icon
ctx.font = '18px serif';
ctx.textAlign = 'center';
ctx.fillText(n.icon, x+w/2, y+h/2-3);
// Label
ctx.font = `500 10px 'JetBrains Mono', monospace`;
ctx.fillStyle = st==='active' ? color : '#3d5a7a';
ctx.fillText(n.label, x+w/2, y+h-11);
// Mini status
if (st !== 'idle') {
const lbl = { loading:'loading…', active:'processing', done:'done ✓' }[st] || '';
ctx.font = `8px 'JetBrains Mono', monospace`;
ctx.fillStyle = dotColor;
ctx.fillText(lbl, x+w/2, y+h-2);
}
}
bezPt(x0,y0,cx1,cy1,cx2,cy2,x1,y1,t) {
const m=1-t;
return {
x: m*m*m*x0+3*m*m*t*cx1+3*m*t*t*cx2+t*t*t*x1,
y: m*m*m*y0+3*m*m*t*cy1+3*m*t*t*cy2+t*t*t*y1
};
}
onClick(e) {
const r = this.canvas.getBoundingClientRect();
const mx = e.clientX-r.left, my = e.clientY-r.top;
const hit = this.nodes.find(n => mx>=n.x&&mx<=n.x+n.w&&my>=n.y&&my<=n.y+n.h);
if (hit) {
this.selectedId = hit.id;
this.openSettings(hit);
} else {
this.selectedId = null;
$('node-settings').classList.remove('open');
}
}
openSettings(node) {
const panel = $('node-settings');
$('ns-title').textContent = `${node.icon} ${node.label}`;
$('ns-title').style.color = node.color;
$('ns-body').innerHTML = Object.entries(node.settings).map(([k,v]) => `
<div class="ns-row">
<div class="ns-label">${k}</div>
<input class="ns-input" value="${v}" data-node="${node.id}" data-key="${k}"
onchange="window.FE.onSettingChange(this)">
</div>
`).join('');
panel.classList.add('open');
}
onSettingChange(input) {
const nodeId = input.dataset.node, key = input.dataset.key, val = input.value;
const node = this.nodeById(nodeId);
if (node) node.settings[key] = val;
// Apply live
if (nodeId==='search' && key==='Top-K') CFG.topK = parseInt(val)||5;
if (nodeId==='rerank' && key==='Keep') CFG.topKRerank = parseInt(val)||3;
if (nodeId==='llm' && key==='MaxTokens') CFG.maxTokens = parseInt(val)||180;
}
setStatus(id, st) { this.statuses[id] = st; }
setAllIdle() {
this.nodes.forEach(n => this.statuses[n.id] = 'idle');
this.particles = [];
}
animEdgePromise(from, to) {
return new Promise(resolve => {
const p = { from, to, t: 0, speed: 0.03, done: resolve };
this.particles.push(p);
});
}
nodeById(id) { return this.nodes.find(n=>n.id===id); }
raf() {
// Advance particles
this.particles = this.particles.filter(p => {
p.t += p.speed;
if (p.t >= 1) { p.done?.(); return false; }
return true;
});
this.draw();
requestAnimationFrame(() => this.raf());
}
}
/* ─────────────────────────────────────────────────────────── */
/* EXPOSE & WIRE UP */
/* ─────────────────────────────────────────────────────────── */
window.FE = null;
function closeSettings() {
$('node-settings').classList.remove('open');
if (window.FE) window.FE.selectedId = null;
}
window.closeSettings = closeSettings;
/* ─────────────────────────────────────────────────────────── */
/* MAIN INIT */
/* ─────────────────────────────────────────────────────────── */
async function main() {
// Node editor
window.FE = new FlowEditor();
// IndexedDB
try {
await openIDB();
const saved = await idbGetAll();
ST.db = saved;
rebuildTable();
} catch(e) {
console.warn('IndexedDB unavailable:', e.message);
}
// Send button
$('send-btn').addEventListener('click', () => {
const q = $('chat-input').value.trim();
if (q) { $('chat-input').value=''; runRAG(q); }
});
$('chat-input').addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && !ST.busy) {
const q = $('chat-input').value.trim();
if (q) { $('chat-input').value=''; runRAG(q); }
}
});
// Add entry
$('add-btn').addEventListener('click', addEntry);
// Load models
await loadModels();
}
main();
</script>
</body>
</html>