1// static/js/researchSynapse.js 2// 3// Live SVG visualization of a deep-research run: central query node with 4// sub-question branches and source leaves that pop in as rounds progress. 5// Driven imperatively by chat.js when SSE research_progress events arrive. 6 7const SVG_NS = 'http://www.w3.org/2000/svg'; 8 9const PHASE_LABEL = { 10 probing: 'verifying model', 11 planning: 'planning strategy', 12 searching: 'searching', 13 reading: 'reading sources', 14 analyzing: 'analyzing findings', 15 writing: 'writing report', 16 error: 'error', 17 done: 'complete', 18}; 19 20function rand(a, b) { return Math.random() * (b - a) + a; } 21function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } 22 23export default function createResearchSynapse(container, opts = {}) { 24 const W = 520, H = 220; 25 const cx = W / 2, cy = H / 2; 26 27 const wrap = document.createElement('div'); 28 wrap.className = 'research-synapse' + (opts.compact ? ' research-synapse-compact' : ''); 29 wrap.innerHTML = ` 30 <div class="rs-stage"> 31 <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet"> 32 <g class="rs-edges"></g> 33 <g class="rs-nodes"></g> 34 <circle class="rs-pulse" cx="${cx}" cy="${cy}" r="6"></circle> 35 </svg> 36 </div> 37 <div class="rs-meta"> 38 <span class="rs-status">startingâ¦</span> 39 <span class="rs-sep">·</span> 40 <span class="rs-round">round <b>0</b></span> 41 <span class="rs-sep">·</span> 42 <span class="rs-sources"><b>0</b> sources</span> 43 <span class="rs-sep">·</span> 44 <span class="rs-timer">00:00</span> 45 </div> 46 `; 47 container.appendChild(wrap); 48 49 const svg = wrap.querySelector('svg'); 50 const edgesG = wrap.querySelector('.rs-edges'); 51 const nodesG = wrap.querySelector('.rs-nodes'); 52 const statusE = wrap.querySelector('.rs-status'); 53 const roundE = wrap.querySelector('.rs-round b'); 54 const srcE = wrap.querySelector('.rs-sources b'); 55 const timerE = wrap.querySelector('.rs-timer'); 56 57 // ââ root (query) âââââââââââââââââââââââââââââââââââââââââââââââ 58 const root = document.createElementNS(SVG_NS, 'circle'); 59 root.setAttribute('cx', cx); root.setAttribute('cy', cy); 60 root.setAttribute('r', 11); 61 root.setAttribute('class', 'rs-node rs-node-root'); 62 nodesG.appendChild(root); 63 const rootLabel = document.createElementNS(SVG_NS, 'text'); 64 rootLabel.setAttribute('x', cx); 65 rootLabel.setAttribute('y', cy + 28); 66 rootLabel.setAttribute('text-anchor', 'middle'); 67 rootLabel.setAttribute('class', 'rs-label'); 68 rootLabel.textContent = _trunc(opts.query || 'query', 28); 69 nodesG.appendChild(rootLabel); 70 71 const subs = []; // { x, y, count } 72 let sourceCount = 0; 73 let lastRound = 0; 74 let completed = false; 75 76 // ââ timer ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 77 const startedAt = opts.startedAt || Date.now(); 78 let timerInterval = setInterval(() => { 79 const elapsed = Math.floor((Date.now() - startedAt) / 1000); 80 timerE.textContent = 81 String(Math.floor(elapsed / 60)).padStart(2, '0') + ':' + 82 String(elapsed % 60).padStart(2, '0'); 83 }, 1000); 84 85 // ââ helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââ 86 function _trunc(s, n) { 87 if (!s) return ''; 88 s = String(s).replace(/\s+/g, ' ').trim(); 89 return s.length > n ? s.slice(0, n - 1) + 'â¦' : s; 90 } 91 92 function _addSub(label) { 93 if (subs.length >= 10) return; // cap visual clutter 94 // Spread subs around a circle; reserve slight offset so first sub doesn't 95 // sit directly above the root label. 96 const slot = subs.length; 97 const totalSlots = Math.max(6, subs.length + 1); 98 const angle = (slot / totalSlots) * Math.PI * 2 - Math.PI / 2; 99 const r = 78; 100 const x = cx + Math.cos(angle) * r; 101 const y = cy + Math.sin(angle) * r; 102 103 const edge = document.createElementNS(SVG_NS, 'line'); 104 edge.setAttribute('x1', cx); edge.setAttribute('y1', cy); 105 edge.setAttribute('x2', x); edge.setAttribute('y2', y); 106 edge.setAttribute('class', 'rs-edge rs-edge-firing'); 107 edgesG.appendChild(edge); 108 setTimeout(() => edge.classList.remove('rs-edge-firing'), 1100); 109
110 const n = document.createElementNS(SVG_NS, 'circle'); 111 n.setAttribute('cx', x); n.setAttribute('cy', y); n.setAttribute('r', 7); 112 n.setAttribute('class', 'rs-node rs-node-sub rs-node-new'); 113 nodesG.appendChild(n); 114 115 if (label) { 116 const t = document.createElementNS(SVG_NS, 'text'); 117 // Position label outside the circle on the same angle 118 const lx = cx + Math.cos(angle) * (r + 14); 119 const ly = cy + Math.sin(angle) * (r + 14); 120 t.setAttribute('x', lx); t.setAttribute('y', ly + 3); 121 t.setAttribute('text-anchor', Math.cos(angle) > 0.15 ? 'start' : 122 Math.cos(angle) < -0.15 ? 'end' : 'middle'); 123 t.setAttribute('class', 'rs-label rs-label-sub'); 124 t.textContent = _trunc(label, 14); 125 nodesG.appendChild(t); 126 } 127 128 subs.push({ x, y, count: 0 }); 129 } 130 131 function _addLeaf() { 132 if (!subs.length) _addSub(''); 133 // Always attach the new source to the CURRENT round's sub (i.e. the 134 // most-recently-added one). That gives a clean per-round attribution 135 // â 10 sources across 3 rounds ends up as 10/10/10 across the three 136 // sub-nodes, not a random scatter. 137 const sub = subs[subs.length - 1]; 138 sub.count++; 139 // Lay leaves out in concentric arcs around the sub: 6 per ring fanned 140 // across ~140°, then a second ring further out for the next 6, etc. 141 // Keeps things readable past 10+ leaves per sub. 142 const baseAngle = Math.atan2(sub.y - cy, sub.x - cx); 143 const idx = sub.count - 1; 144 const perRing = 6; 145 const ring = Math.floor(idx / perRing); 146 const slot = idx % perRing; 147 const arcSpan = 2.4; 148 const angle = baseAngle + (slot - (perRing - 1) / 2) * (arcSpan / perRing) + rand(-0.05, 0.05); 149 const r = 26 + ring * 14 + rand(-1.5, 1.5); 150 const lx = sub.x + Math.cos(angle) * r; 151 const ly = sub.y + Math.sin(angle) * r; 152 153 const edge = document.createElementNS(SVG_NS, 'line'); 154 edge.setAttribute('x1', sub.x); edge.setAttribute('y1', sub.y); 155 edge.setAttribute('x2', lx); edge.setAttribute('y2', ly); 156 edge.setAttribute('class', 'rs-edge rs-edge-firing'); 157 edgesG.appendChild(edge); 158 setTimeout(() => edge.classList.remove('rs-edge-firing'), 1100); 159 160 const leaf = document.createElementNS(SVG_NS, 'circle'); 161 leaf.setAttribute('cx', lx); leaf.setAttribute('cy', ly); 162 leaf.setAttribute('r', 4); 163 leaf.setAttribute('class', 'rs-node rs-node-leaf rs-node-new'); 164 nodesG.appendChild(leaf); 165 } 166 167 // ââ public API âââââââââââââââââââââââââââââââââââââââââââââââââ 168 return { 169 element: wrap, 170 171 /** Reflect a phase change in the status text + side effects. */ 172 setPhase(phase, extra = {}) { 173 if (completed) return; 174 const label = PHASE_LABEL[phase] || phase || ''; 175 let txt = label; 176 if (phase === 'searching' && extra.queries) txt += ` · ${extra.queries} queries`; 177 else if (phase === 'reading' && extra.title) txt = `reading: ${_trunc(extra.title, 32)}`; 178 else if (phase === 'analyzing' && extra.total_findings) txt += ` · ${extra.total_findings} findings`; 179 statusE.textContent = txt; 180 // Visual cue per phase 181 if (phase === 'error') wrap.classList.add('rs-error'); 182 }, 183 184 /** Bump the round counter â adds a sub-question node when round grows. */ 185 setRound(round, opts = {}) { 186 if (completed) return; 187 if (typeof round !== 'number' || round < 1) return; 188 if (round > lastRound) { 189 // Add one sub-question node per new round we see 190 for (let i = lastRound; i < round && subs.length < 10; i++) { 191 _addSub(opts.label || `R${i + 1}`); 192 } 193 lastRound = round; 194 roundE.textContent = round; 195 } 196 }, 197 198 /** Update the total source count â adds leaf nodes for any new sources. */ 199 setSourceCount(total) { 200 if (completed) return; 201 if (typeof total !== 'number' || total <= sourceCount) return; 202 const delta = Math.min(total - sourceCount, 6); // animate at most 6 at a time 203 for (let i = 0; i < delta; i++) { 204 // Stagger leaves slightly so they don't all pop on the same frame 205 setTimeout(_addLeaf, i * 110); 206 } 207 sourceCount = total; 208 srcE.textContent = total; 209 }, 210 211 /** Mark the run as done â freezes the pulse and tints the graph green. */ 212 complete() { 213 if (completed) return; 214 completed = true; 215 wrap.classList.add('rs-complete'); 216 statusE.textContent = 'complete'; 217 if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 218 }, 219 220 destroy() { 221 if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 222 if (wrap.parentNode) wrap.parentNode.removeChild(wrap); 223 }, 224 }; 225}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.