Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>RAG Pipeline Visualizer</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600&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 & 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 & 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,'<').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> |