1/** 2 * Deep Research side panel â open/close, form, job rendering, library. 3 */ 4import * as jobs from './jobs.js'; 5import themeModule from '../theme.js'; 6import createResearchSynapse from '../researchSynapse.js'; 7import spinnerModule from '../spinner.js'; 8import { sortModelIds } from '../modelSort.js'; 9 10// jobId -> { synapse, status } â survives across _renderJobs() rebuilds so 11// the SVG keeps its accumulated nodes/edges between progress events. 12const _jobSynapses = new Map(); 13// Which foldable job sections ('active' / 'past') the user has collapsed â kept 14// across re-renders so the panel doesn't re-expand on every job-state change. 15const _collapsedSections = new Set(); 16 17// Persisted preference to minimize (hide) the per-job synapse "tree" visual. 18// Stored globally so it survives the frequent _renderJobs() card rebuilds and 19// applies to every running job. 20const _SYNAPSE_MIN_KEY = 'research.synapseMinimized'; 21let _synapseMinimized = (() => { try { return localStorage.getItem(_SYNAPSE_MIN_KEY) === '1'; } catch { return false; } })(); 22const _vizCollapseIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>'; 23const _vizExpandIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; 24function _toggleSynapseMinimized() { 25 _synapseMinimized = !_synapseMinimized; 26 try { localStorage.setItem(_SYNAPSE_MIN_KEY, _synapseMinimized ? '1' : '0'); } catch {} 27 // Apply live to all rendered cards without forcing a full rebuild. 28 document.querySelectorAll('.research-job-synapse-host') 29 .forEach(h => h.classList.toggle('synapse-collapsed', _synapseMinimized)); 30 document.querySelectorAll('.research-synapse-toggle').forEach(b => { 31 b.classList.toggle('active', _synapseMinimized); 32 b.title = _synapseMinimized ? 'Show visualization' : 'Minimize visualization'; 33 b.innerHTML = _synapseMinimized ? _vizExpandIcon : _vizCollapseIcon; 34 }); 35} 36 37let _open = false; 38let _onDocKeydown = null; 39let _apiBase = ''; 40let _endpoints = []; 41let _expandedJobId = null; 42let _markdownModule = null; 43let _sessionModule = null; 44let _settingsCollapsed = false; 45const _SETTINGS_KEY = 'odysseus-research-settings'; 46const _COLLAPSE_KEY = 'odysseus-research-settings-collapsed'; 47 48try { _settingsCollapsed = localStorage.getItem(_COLLAPSE_KEY) === '1'; } catch {} 49 50function _saveSettingsToStorage() { 51 try { 52 const activeCat = document.querySelector('.research-cat.active'); 53 localStorage.setItem(_SETTINGS_KEY, JSON.stringify({ 54 max_rounds: document.getElementById('research-rounds')?.value || '0', 55 search_provider: document.getElementById('research-search-provider')?.value || '', 56 endpoint_id: document.getElementById('research-endpoint')?.value || '', 57 model: document.getElementById('research-model')?.value || '', 58 category: activeCat?.dataset.cat || '', 59 })); 60 } catch {} 61} 62 63function _loadSettingsFromStorage() { 64 try { 65 const raw = localStorage.getItem(_SETTINGS_KEY); 66 return raw ? JSON.parse(raw) : null; 67 } catch { return null; } 68} 69 70function _showBadge() { 71 const btn = document.getElementById('tool-research-btn'); 72 if (!btn || btn.querySelector('.research-badge')) return; 73 const dot = document.createElement('span'); 74 dot.className = 'research-badge'; 75 btn.appendChild(dot); 76} 77 78function _clearBadge() { 79 const dot = document.querySelector('#tool-research-btn .research-badge'); 80 if (dot) dot.remove(); 81} 82 83// Live sidebar/rail feedback â mirrors the cookbook pattern. While 84// research jobs are running, the rail button pulses; errors flag red; 85// nothing running clears it. Panel-independent so it works with the 86// modal closed. Called from _renderJobs on every job-state change. 87function _syncResearchRail() { 88 let running = 0, errored = 0, runningJob = null; 89 try { 90 for (const j of jobs.getJobs()) { 91 if (j.status === 'running' || j.status === 'queued') { 92 running++; 93 if (j.status === 'running' && !runningJob) runningJob = j; 94 } else if (j.status === 'error') errored++; 95 } 96 } catch { return; } 97 const railBtn = document.getElementById('rail-research'); 98 const toolBtn = document.getElementById('tool-research-btn'); 99 const active = running > 0 || errored > 0; 100 // Shared flag so sessions.js:_updateRailNotifs (which lights the same 101 // rail button for INLINE research mode) ORs with us instead of 102 // clobbering â otherwise a session re-render would clear our dot. 103 window._researchJobsActive = active; 104 if (railBtn) { 105 railBtn.classList.remove('rail-notify', 'rail-notify-success', 'rail-notify-error', 'research-notif-active'); 106 if (active) { 107 railBtn.classList.add('rail-notify', errored ? 'rail-notify-error' : 'rail-notify-success', 'research-notif-active'); 108 } 109 } 110 if (toolBtn) { 111 toolBtn.classList.toggle('research-notif-active', active); 112 toolBtn.style.opacity = active ? '1' : ''; 113 // Sidebar feedback while running â a small pulsing dot + round text, 114 // same style as Cookbook's running indicator (no glow). 115 let wrap = toolBtn.querySelector('.research-sb-running'); 116 if (running > 0) { 117 if (!wrap) { 118 wrap = document.createElement('span'); 119 wrap.className = 'research-sb-running'; 120 wrap.innerHTML = '<span class="research-sb-status"></span><span class="research-sb-dot"></span>'; 121 toolBtn.appendChild(wrap); 122 } 123 const round = runningJob && runningJob.progress && runningJob.progress.round; 124 // Just the round as "R1", "R2", ⦠(empty until the first roun
124d lands). 125 // Only update when we actually have a round â don't blank it out on 126 // progress ticks that lack one, or it flickers on/off between rounds. 127 if (round) wrap.querySelector('.research-sb-status').textContent = `R${round}`; 128 } else if (wrap) { 129 wrap.remove(); 130 } 131 } 132 // Orbiting edge animation: faster when a job is running, slower while idle 133 // (ambient). The rAF loop in _ensureOrbit drives --research-orbit-angle on 134 // the pane element â CSS-only @property animation silently no-op'd in some 135 // browsers, so JS drives it for universal compatibility. 136 _orbitSpeedDegPerSec = running > 0 ? 60 : 22; // 6s/rev vs ~16s/rev 137 _ensureOrbit(); 138 if (window._syncRailDynamic) window._syncRailDynamic(); 139} 140 141// ââ Orbit-angle rAF driver âââââââââââââââââââââââââââââââââââââ 142// Universally-supported alternative to a CSS @property angle animation. 143// Walks --research-orbit-angle on the #research-pane element every frame 144// while the panel is open. Stops itself when the pane is gone. 145let _orbitRAF = null; 146let _orbitAngle = 0; 147let _orbitLastTs = 0; 148let _orbitSpeedDegPerSec = 22; // idle ambient default 149function _ensureOrbit() { 150 if (_orbitRAF) return; 151 _orbitLastTs = 0; 152 const tick = (ts) => { 153 const pane = document.getElementById('research-pane'); 154 if (!pane) { _orbitRAF = null; return; } // panel closed â stop loop 155 if (_orbitLastTs) { 156 const dt = (ts - _orbitLastTs) / 1000; 157 _orbitAngle = (_orbitAngle + _orbitSpeedDegPerSec * dt) % 360; 158 pane.style.setProperty('--research-orbit-angle', _orbitAngle.toFixed(2) + 'deg'); 159 } 160 _orbitLastTs = ts; 161 _orbitRAF = requestAnimationFrame(tick); 162 }; 163 _orbitRAF = requestAnimationFrame(tick); 164} 165 166/** Fetch the count of saved research items and populate the header chip. */ 167async function _updateResearchCount() { 168 const el = document.getElementById('research-stats'); 169 if (!el) return; 170 try { 171 const res = await fetch('/api/research/library?limit=1', { credentials: 'same-origin' }); 172 if (!res.ok) return; 173 const data = await res.json(); 174 const n = data.total || 0; 175 el.textContent = n + (n === 1 ? ' research' : ' research'); 176 } catch {} 177} 178 179const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>'; 180const _closeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; 181const _playIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>'; 182const _cancelIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; 183const _trashIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
184const _externalIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>'; 185const _copyIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>'; 186const _retryIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>'; 187const _chevronIcon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; 188const _editIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'; 189const _chatIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'; 190 191export function init(apiBase, markdownMod, sessionMod) { 192 _apiBase = apiBase; 193 _markdownModule = markdownMod; 194 _sessionModule = sessionMod; 195 jobs.init(apiBase); 196 jobs.setRenderCallback(_renderJobs); 197 jobs.onComplete(() => { if (!_open) _showBadge(); }); 198} 199 200export function isOpen() { return _open; } 201export function toggle() { 202 if (_open) { 203 // If minimized, restore instead of closing 204 const overlay = document.getElementById('research-overlay'); 205 if (overlay && overlay.style.display === 'none') { 206 overlay.style.display = ''; 207 const btn = document.getElementById('tool-research-btn'); 208 if (btn) btn.classList.remove('minimized'); 209 return; 210 } 211 closePanel(); 212 } else { 213 openPanel(); 214 } 215} 216 217export function openPanel(focusJobId) { 218 if (_open) { 219 const overlay = document.getElementById('research-overlay'); 220 if (overlay && overlay.style.display === 'none') { 221 overlay.style.display = ''; 222 const btn = document.getElementById('tool-research-btn'); 223 if (btn) btn.classList.remove('minimized'); 224 } 225 document.body.classList.add('research-panel-view'); 226 if (focusJobId) _focusJob(focusJobId); 227 return; 228 } 229 _open = true; 230 231 const container = document.getElementById('chat-container'); 232 if (!container) return; 233 234 document.body.classList.add('research-panel-view'); 235 const btn = document.getElementById('tool-research-btn'); 236 if (btn) btn.classList.add('active'); 237 238 const overlay = document.createElement('div'); 239 overlay.id = 'research-overlay'; 240 overlay.className = 'modal research-overlay'; 241 242 // Match doclib/gallery/calendar modal sizing exactly so research feels like 243 // the rest of the modal family (centered, ~640px, 85vh). 244 const pane = document.createElement('div'); 245 pane.id = 'research-pane'; 246 pane.className = 'modal-content doclib-modal-content research-pane'; 247 // Mobile: full-screen so the content has room and the jobs list can scroll 248 // inside it. Desktop: centered ~640px / 85vh modal like the rest. 249 pane.style.cssText = (window.innerWidth <= 768) 250 ? 'width:100vw;max-width:100vw;height:90dvh;max-height:90dvh;border-radius:14px 14px 0 0;background:var(--bg);' 251 : 'width:min(640px, 92vw);max-height:85vh;background:var(--bg);'; 252 pane.innerHTML = _buildPanelHTML(); 253 254 overlay.appendChild(pane); 255 document.body.appendChild(overlay); 256 257 overlay.addEventListener('click', (e) => { 258 if (e.target === overlay) closePanel(); 259 }); 260 261 // Document-level ESC handler â overlay-only listener never fired because 262 // overlay isn't focused. Tracked in module scope so closePanel can detach. 263 _onDocKeydown = (e) => { 264 if (e.key === 'Escape' && _open) { 265 e.preventDefault(); 266 closePanel(); 267 } 268 }; 269 document.addEventListener('keydown', _onDocKeydown); 270 271 // Make the pane draggable by its header â same pattern as Library/Calendar. 272 const paneHeader = pane.querySelector('.research-pane-header'); 273 if (themeModule && themeModule.makeDraggable && paneHeader) { 274 themeModule.makeDraggable(pane, paneHeader); 275 } 276 277 _wireEvents(pane); 278 _loadEndpoints().then(_restoreSavedSettings); 279 _clearBadge(); 280 _updateResearchCount(); 281 282 if ('Notification' in window && Notification.permission === 'default') { 283 try { Notification.requestPermission(); } catch {} 284 } 285 286 if (focusJobId) _focusJob(focusJobId); 287} 288 289// Scroll to + highlight a research job card by session id. Used by the 290// chat anchor-link delegate ([Topic](#research-<session_id>)). 291function _focusJob(jobId) { 292 if (!jobId) return;
293 // jobs may still be loading from /api/research/active â retry a few times. 294 let tries = 0; 295 const tryFocus = () => { 296 const card = document.querySelector(`[data-job-id="${jobId}"]`); 297 if (card) { 298 card.scrollIntoView({ behavior: 'smooth', block: 'center' }); 299 card.classList.add('research-card-flash'); 300 setTimeout(() => card.classList.remove('research-card-flash'), 2000); 301 return; 302 } 303 if (tries++ < 8) setTimeout(tryFocus, 400); 304 }; 305 setTimeout(tryFocus, 200); 306} 307 308export function closePanel() { 309 if (!_open) return; 310 _open = false; 311 312 if (_onDocKeydown) { 313 document.removeEventListener('keydown', _onDocKeydown); 314 _onDocKeydown = null; 315 } 316 317 document.body.classList.remove('research-panel-view'); 318 const btn = document.getElementById('tool-research-btn'); 319 if (btn) btn.classList.remove('active'); 320 321 const overlay = document.getElementById('research-overlay'); 322 if (overlay) overlay.remove(); 323} 324 325function _buildPanelHTML() { 326 const searchProviders = ['', 'searxng', 'duckduckgo', 'tavily', 'brave', 'google', 'serper']; 327 const providerOpts = searchProviders.map(p => 328 `<option value="${p}">${p || 'Default'}</option>` 329 ).join(''); 330 331 let roundOpts = '<option value="0" selected>Auto</option>'; 332 for (let i = 1; i <= 20; i++) { 333 roundOpts += `<option value="${i}">${i}</option>`; 334 } 335 336 const settingsHidden = _settingsCollapsed ? ' style="display:none"' : ''; 337 const chevronCls = _settingsCollapsed ? ' collapsed' : ''; 338 339 return ` 340 <div class="modal-header research-pane-header"> 341 <h4><span style="position:relative;top:-1px;left:6px;display:inline-flex;vertical-align:middle;">${_searchIcon}</span><span style="margin-left:6px;">Deep Research</span></h4> 342 <div class="research-pane-header-actions"> 343 <button id="research-panel-minimize" class="modal-minimize-btn" type="button" title="Minimize"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="18" x2="19" y2="18"/></svg></button> 344 <button id="research-panel-close" class="close-btn" title="Close">✖</button> 345 </div> 346 </div> 347 <div class="modal-body research-pane-body" data-no-swipe-dismiss> 348 <div class="research-new-job"> 349 <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"> 350 <h2 style="margin:0;padding:0;line-height:1;">Research <span id="research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2> 351 </div> 352 <p class="memory-desc doclib-desc" style="margin-top:6px;display:flex;align-items:center;gap:6px;"> 353 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.8;"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h4v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg> 354 <span>Multi-step web research with an LLM-in-the-loop agent</span> 355 </p> 356 <div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div> 357 <textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy â every island, monster, and detour, and why each one cost him" rows="4"></textarea> 358 <div class="research-category-row" id="research-category-row"> 359 <button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button> 360 <button class="research-cat" data-cat="product">Product</button> 361 <button class="research-cat" data-cat="comparison">Compare</button> 362 <button class="research-cat" data-cat="howto">How-to</button> 363 <button class="research-cat" data-cat="factcheck">Fact-check</button> 364 </div> 365 <button id="research-settings-toggle" class="research-settings-toggle${chevronCls}"> 366 Settings<span class="research-settings-chevron">${_chevronIcon}</span> 367 </button> 368 <div id="research-settings-body" class="research-settings-row"${settingsHidden}> 369 <label class="research-setting">
370 <span class="research-setting-label">Rounds</span> 371 <select id="research-rounds">${roundOpts}</select> 372 </label> 373 <label class="research-setting"> 374 <span class="research-setting-label">Search engine</span> 375 <select id="research-search-provider">${providerOpts}</select> 376 </label> 377 <label class="research-setting"> 378 <span class="research-setting-label">Endpoint</span> 379 <select id="research-endpoint"><option value="">Default</option></select> 380 </label> 381 <label class="research-setting"> 382 <span class="research-setting-label">Model</span> 383 <select id="research-model"><option value="">Default</option></select> 384 </label> 385 </div> 386 <div class="research-controls-row"> 387 <button id="research-add-btn" class="research-add-btn"><span class="research-add-plus">+</span> Queue</button> 388 <button id="research-start-btn" class="research-start-btn">${_playIcon} Start</button> 389 </div> 390 </div> 391 <div id="research-jobs-list" class="research-jobs-list" data-no-swipe-dismiss></div> 392 </div> 393 `; 394} 395 396/** Fade/slide a card out, then run the removal â matches cookbook's smooth exit. */ 397function _animateOutThenRemove(el, removeFn) { 398 if (!el || !el.style) { removeFn(); return; } 399 el.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; 400 el.style.opacity = '0'; 401 el.style.transform = 'translateX(-10px)'; 402 setTimeout(removeFn, 320); 403} 404 405/** Dismiss the mobile keyboard by stealing focus into a throwaway readonly 406 * input (blur() alone is often ignored on Firefox mobile). */ 407function _dismissKeyboard(input) { 408 try { 409 if (input) input.blur(); 410 const tmp = document.createElement('input'); 411 tmp.setAttribute('readonly', 'readonly'); 412 tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;'; 413 document.body.appendChild(tmp); 414 tmp.focus(); 415 setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 60); 416 } catch {} 417} 418 419/** Reset the category selector back to "Auto" (called after each start). */ 420function _resetCategoryToAuto() { 421 document.querySelectorAll('.research-cat').forEach(b => 422 b.classList.toggle('active', (b.dataset.cat || '') === '')); 423} 424 425function _wireEvents(pane) { 426 pane.querySelector('#research-panel-close').addEventListener('click', closePanel); 427 pane.querySelector('#research-panel-minimize')?.addEventListener('click', () => { 428 const overlay = document.getElementById('research-overlay'); 429 if (overlay) overlay.style.display = 'none'; 430 const btn = document.getElementById('tool-research-btn'); 431 if (btn) btn.classList.add('minimized'); 432 }); 433 pane.querySelector('#research-start-btn').addEventListener('click', _handleStart); 434 pane.querySelector('#research-add-btn').addEventListener('click', _handleAdd); 435 436 pane.querySelectorAll('.research-cat').forEach(btn => { 437 btn.addEventListener('click', () => { 438 pane.querySelectorAll('.research-cat').forEach(b => b.classList.remove('active')); 439 btn.classList.add('active'); 440 }); 441 }); 442 443 pane.querySelector('#research-settings-toggle').addEventListener('click', () => { 444 const body = document.getElementById('research-settings-body'); 445 const btn = document.getElementById('research-settings-toggle'); 446 if (!body || !btn) return; 447 _settingsCollapsed = !_settingsCollapsed; 448 body.style.display = _settingsCollapsed ? 'none' : ''; 449 btn.classList.toggle('collapsed', _settingsCollapsed); 450 try { localStorage.setItem('odysseus-research-settings-collapsed', _settingsCollapsed ? '1' : '0'); } catch {} 451 }); 452 453 const queryInput = pane.querySelector('#research-query'); 454 queryInput.addEventListener('keydown', (e) => { 455 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { 456 e.preventDefault(); 457 _handleStart(); 458 } 459 }); 460 461 const endpointSelect = pane.querySelector('#research-endpoint'); 462 endpointSelect.addEventListener('change', () => _populateModels(endpointSelect.value)); 463 464 _renderJobs(); 465} 466 467function _readSettings() { 468 const activeCat = document.querySelector('.research-cat.active'); 469 const category = activeCat?.dataset.cat || undefined; 470 const settings = { 471 max_rounds: parseInt(document.getElementById('research-rounds')?.value || '0', 10), 472 search_provider: document.getElementById('research-search-provider')?.value || undefined,
473 endpoint_id: document.getElementById('research-endpoint')?.value || undefined, 474 model: document.getElementById('research-model')?.value || undefined, 475 category: category || undefined, 476 }; 477 const epSel = document.getElementById('research-endpoint'); 478 if (epSel && epSel.value) { 479 const opt = epSel.options[epSel.selectedIndex]; 480 settings._endpointName = opt?.textContent || ''; 481 } 482 const modelSel = document.getElementById('research-model'); 483 if (modelSel && modelSel.value) settings._modelName = modelSel.value; 484 Object.keys(settings).forEach(k => { if (!settings[k]) delete settings[k]; }); 485 return settings; 486} 487 488function _handleAdd() { 489 const queryEl = document.getElementById('research-query'); 490 const query = (queryEl?.value || '').trim(); 491 if (!query) { queryEl?.focus(); return; } 492 _saveSettingsToStorage(); 493 jobs.addToQueue(query, _readSettings()); 494 queryEl.value = ''; 495 queryEl.focus(); 496} 497 498// Move a job's data back into the compose form so user can edit and re-queue 499function _editJob(job) { 500 const queryEl = document.getElementById('research-query'); 501 if (queryEl) { 502 queryEl.value = job.query || ''; 503 queryEl.focus(); 504 queryEl.setSelectionRange(queryEl.value.length, queryEl.value.length); 505 } 506 // Restore category 507 const cat = job.category || ''; 508 document.querySelectorAll('.research-cat').forEach(b => { 509 b.classList.toggle('active', b.dataset.cat === cat); 510 }); 511 // Restore settings 512 const s = job.settings || {}; 513 const roundsEl = document.getElementById('research-rounds'); 514 if (roundsEl && s.max_rounds) roundsEl.value = s.max_rounds; 515 const spEl = document.getElementById('research-search-provider'); 516 if (spEl && s.search_provider) spEl.value = s.search_provider; 517 const epEl = document.getElementById('research-endpoint'); 518 if (epEl && s.endpoint_id) epEl.value = s.endpoint_id; 519 const mEl = document.getElementById('research-model'); 520 if (mEl && s.model) mEl.value = s.model; 521 // Remove the old job so clicking Start/Queue makes a fresh one 522 jobs.removeJob(job.id); 523 // Scroll the form into view 524 queryEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 525} 526 527async function _handleStart() { 528 const queryEl = document.getElementById('research-query'); 529 const startBtn = document.getElementById('research-start-btn'); 530 const query = (queryEl?.value || '').trim(); 531 532 // "Start All" mode: more than one job queued â let the user pick parallel 533 // vs sequential before launching. Queue any freshly-typed query first so 534 // it joins the batch, then open the picker anchored to this button. 535 const queuedCount = jobs.getJobs().filter(j => j.status === 'queued').length; 536 if (queuedCount > 1) { 537 if (query) { _saveSettingsToStorage(); jobs.addToQueue(query, _readSettings()); queryEl.value = ''; } 538 _resetCategoryToAuto(); 539 if (window.innerWidth <= 768) _dismissKeyboard(queryEl); 540 const total = jobs.getJobs().filter(j => j.status === 'queued').length; 541 _promptParallelOrSequential(total, startBtn); 542 return; 543 } 544 545 // Visual + spinner feedback while the launch request is in flight 546 const _setBusy = (busy) => { 547 if (!startBtn) return; 548 if (busy) { 549 startBtn.disabled = true; 550 startBtn.dataset._origHTML = startBtn.dataset._origHTML || startBtn.innerHTML; 551 startBtn.innerHTML = ''; 552 try { 553 const _wp = spinnerModule.createWhirlpool(14); 554 _wp.element.style.cssText += ';vertical-align:middle;margin-right:5px;position:relative;top:-1px;'; 555 startBtn.appendChild(_wp.element); 556 } catch {} 557 startBtn.appendChild(document.createTextNode('Starting')); 558 startBtn.classList.add('research-start-busy'); 559 } else { 560 startBtn.disabled = false; 561 startBtn.classList.remove('research-start-busy'); 562 if (startBtn.dataset._origHTML) { 563 startBtn.innerHTML = startBtn.dataset._origHTML; 564 } 565 } 566 }; 567 568 // Show busy briefly for click feedback. Don't await the full launch â 569 // the per-job card immediately shows "Starting..." progress, and the 570 // backend POST can take a while. 571 _setBusy(true); 572 setTimeout(() => _setBusy(false), 1500); 573 574 const _mobile = window.innerWidth <= 768; 575 if (!query) { 576 jobs.startAllQueued(); 577 _resetCategoryToAuto(); 578 if (_mobile) _dismissKeyboard(queryEl); 579 return; 580 } 581 _saveSettingsToStorage(); 582 const settings = _readSettings(); 583 queryEl.value = ''; 584 // Mobile: drop the keyboard after sending; desktop: keep focus for fast follow-ups. 585 if (_mobile) _dismissKeyboard(queryEl); else queryEl.focus(); 586 _resetCategoryToAuto(); 587 jobs.startJob(query, settings).catch((e) => {
588 if (typeof uiModule !== 'undefined' && uiModule?.showError) uiModule.showError('Failed to start research'); 589 queryEl.value = query; // restore so user can retry 590 }); 591} 592 593function _restoreSavedSettings() { 594 const saved = _loadSettingsFromStorage(); 595 if (!saved) return; 596 if (saved.category !== undefined) { 597 document.querySelectorAll('.research-cat').forEach(b => { 598 b.classList.toggle('active', b.dataset.cat === saved.category); 599 }); 600 } 601 // Rounds intentionally defaults to "Auto" on every open â don't restore. 602 // Users can pick a specific cap each time if needed. 603 const search = document.getElementById('research-search-provider'); 604 if (search && saved.search_provider !== undefined) search.value = saved.search_provider; 605 const ep = document.getElementById('research-endpoint'); 606 if (ep && saved.endpoint_id) { 607 ep.value = saved.endpoint_id; 608 _populateModels(saved.endpoint_id); 609 if (saved.model) { 610 setTimeout(() => { 611 const model = document.getElementById('research-model'); 612 if (model) model.value = saved.model; 613 }, 50); 614 } 615 } 616} 617 618async function _loadEndpoints() { 619 try { 620 const res = await fetch(`${_apiBase}/api/model-endpoints`, { credentials: 'same-origin' }); 621 if (!res.ok) return; 622 _endpoints = await res.json(); 623 const sel = document.getElementById('research-endpoint'); 624 if (!sel) return; 625 _endpoints.filter(e => e.is_enabled && e.model_type === 'llm').forEach(ep => { 626 const opt = document.createElement('option'); 627 opt.value = ep.id; 628 opt.textContent = ep.name || ep.base_url; 629 sel.appendChild(opt); 630 }); 631 } catch {} 632} 633 634function _populateModels(endpointId) { 635 const sel = document.getElementById('research-model'); 636 if (!sel) return; 637 sel.innerHTML = '<option value="">Default</option>'; 638 if (!endpointId) return; 639 const ep = _endpoints.find(e => e.id === endpointId); 640 if (!ep || !ep.models) return; 641 sortModelIds(ep.models).forEach(m => { 642 const opt = document.createElement('option'); 643 opt.value = m; 644 opt.textContent = m; 645 sel.appendChild(opt); 646 }); 647} 648 649// ââ Job rendering ââ 650 651function _renderJobs() { 652 // Keep the rail/sidebar indicator in sync on every job-state change, 653 // even when the panel is closed (no container yet). 654 _syncResearchRail(); 655 const container = document.getElementById('research-jobs-list'); 656 if (!container) return; 657 658 const allJobs = jobs.getJobs(); 659 if (!allJobs.length) { 660 // No empty-state text in the body â the query box above is the call to 661 // action. But still surface the "All past research found in Library, 662 // Research" hint under the main title, since the Past section won't 663 // render to host it (this is exactly the case the dynamic hint targets). 664 container.innerHTML = ''; 665 const noPastHint = document.getElementById('research-no-past-hint'); 666 if (noPastHint) { 667 noPastHint.style.display = ''; 668 if (!noPastHint.dataset._wired) { 669 noPastHint.dataset._wired = '1'; 670 noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => { 671 e.stopPropagation(); 672 closePanel(); 673 if (window.documentModule && window.documentModule.openLibrary) { 674 window.documentModule.openLibrary({ tab: 'research' }); 675 } 676 }); 677 } 678 } 679 return; 680 } 681 682 container.innerHTML = ''; 683 684 const active = allJobs.filter(j => j.status === 'queued' || j.status === 'running' || j.status === 'error' || j.status === 'cancelled'); 685 const past = allJobs.filter(j => j.status === 'done' && j._fromLibrary); 686 const recentDone = allJobs.filter(j => j.status === 'done' && !j._fromLibrary).reverse(); 687 688 // Keep the header "(N research)" chip in sync with the Past-section count. 689 // _updateResearchCount fetches the library total only, which under-counts 690 // when there's a session-completed job not yet persisted to the library. 691 const statsEl = document.getElementById('research-stats'); 692 if (statsEl) { 693 const n = recentDone.length + past.length; 694 statsEl.textContent = n + ' research'; 695 } 696 697 // The main Start button doubles as "Start All (N)" when more than one job 698 // is queued â clicking it then opens the parallel/sequential picker. No 699 // separate queue-bar button (that was the redundant second button). 700 const queued = active.filter(j => j.status === 'queued'); 701 const startBtn = document.getElementById('research-start-btn'); 702 if (startBtn && !startBtn.classList.contains('research-start-busy')) { 703 startBtn.innerHTML = queued.length > 1 704 ? `${_playIcon} Start All (${queued.length})` 705 : `${_playIcon} Start`; 706 startBtn.dataset._origHTML = startBtn.innerHTML; 707 } 708 709 // Dynamic Past hint: when the Past section won't render (no past items), 710 // surface the "All past research found in Library, Research" line under 711 // the main Research title instead, so the link is always discoverable. 712 const noPastHint = document.getElementById('research-no-past-hint'); 713 if (noPastHint) { 714 const hasPast = past.length + recentDone.length > 0; 715 noPastHint.style.display = hasPast ? 'none' : ''; 716 if (!hasPast && !noPastHint.dataset._wired) { 717 noPastHint.dataset._wired = '1'; 718 noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => { 719 e.stopPropagation(); 720 closePanel(); 721 if (window.documentModule && window.documentModule.openLibrary) { 722 window.documentModule.openLibrary({ tab: 'research' }); 723 } 724 }); 725 } 726 } 727 728 // Clean up synapses for jobs that finished or disappeared. complete() 729 // marks the SVG green for ~800ms before destroy removes it. 730 const liveIds = new Set(allJobs.filter(j => j.status === 'running').map(j => j.id)); 731 for (const [jobId, entry] of _jobSynapses) { 732 if (liveIds.has(jobId)) continue; 733 try { entry.synapse.complete(); } catch {} 734 setTimeout(() => { try { entry.synapse.destroy(); } catch {} }, 800); 735 _jobSynapses.delete(jobId); 736 } 737 738 // Group into foldable sections: "Active" (in-progress) and "Past research" 739 // (everything done â this session + library). Each has a clickable title
740 // that collapses its body. Collapsed state persists across re-renders via 741 // the module-level _collapsedSections set. 742 const _addSection = (key, title, arr) => { 743 if (!arr.length) return; 744 const collapsed = _collapsedSections.has(key); 745 const sec = document.createElement('div'); 746 sec.className = 'research-section' + (collapsed ? ' collapsed' : ''); 747 const header = document.createElement('div'); 748 header.className = 'research-section-header'; 749 // Status dot on the right (visible even when folded): 750 // ⢠Active = pulsing accent glow (work in progress) 751 // ⢠any failed/cancelled job in Active = solid red 752 // ⢠Past (done) = solid green (success) 753 let dotColor, dotPulse = false; 754 if (key === 'active') { 755 const failed = arr.some(j => j.status === 'error' || j.status === 'cancelled'); 756 if (failed) { dotColor = '#f44336'; } 757 else { dotColor = 'var(--accent, var(--red))'; dotPulse = true; } 758 } else { 759 dotColor = 'var(--color-success)'; 760 } 761 // Both sections carry a "Clear all" button in the header (cookbook-running 762 // section style); it clears all research and must not toggle the fold. 763 const clearAllHtml = '<button class="research-section-clear" title="Clear all research">' + _cancelIcon + ' Clear all</button>'; 764 header.innerHTML = 765 '<span class="research-section-title">' + title + '</span>' 766 + '<span class="research-section-count memory-count">' + arr.length + ' research</span>' 767 + '<span class="research-section-right">' 768 + clearAllHtml 769 + '<span class="research-section-dot' + (dotPulse ? ' pulsing' : '') + '" style="background:' + dotColor + ';"></span>' 770 + '<svg class="research-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg>' 771 + '</span>'; 772 header.addEventListener('click', () => { 773 const nowCollapsed = sec.classList.toggle('collapsed'); 774 if (nowCollapsed) _collapsedSections.add(key); else _collapsedSections.delete(key); 775 }); 776 header.querySelector('.research-section-clear')?.addEventListener('click', (e) => { 777 e.stopPropagation(); 778 // Gracefully fade + collapse the whole section block(s) out, then clear. 779 container.querySelectorAll('.research-section').forEach(s => { 780 s.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; 781 s.style.opacity = '0'; 782 s.style.transform = 'translateX(-10px)'; 783 }); 784 setTimeout(() => jobs.clearAll(), 320); 785 }); 786 const body = document.createElement('div'); 787 body.className = 'research-section-body'; 788 // Hint inside the "Past research" header (second line, styled like the main 789 // Research description) â past research is kept in the Library's Research tab. 790 if (key === 'past') { 791 const hint = document.createElement('div'); 792 hint.className = 'memory-desc doclib-desc research-library-hint'; 793 hint.innerHTML = 'All past research found in <button type="button" class="research-library-link">Library, Research</button>'; 794 hint.querySelector('.research-library-link').addEventListener('click', (e) => { 795 e.stopPropagation(); 796 // Close the research panel first so the Library opens ABOVE it on mobile 797 // (otherwise it stacks under the full-screen panel). 798 closePanel(); 799 if (window.documentModule && window.documentModule.openLibrary) { 800 window.documentModule.openLibrary({ tab: 'research' }); 801 } 802 }); 803 header.appendChild(hint); 804 } 805 arr.forEach(j => body.appendChild(_buildJobCard(j))); 806 sec.appendChild(header); 807 sec.appendChild(body); 808 container.appendChild(sec); 809 }; 810 811 // ("Clear all" lives inside the Past research section header â see _addSection.) 812 813 _addSection('active', 'Active', active); 814 _addSection('past', 'Past research', recentDone.concat(past)); 815} 816 817/** Pick parallel vs sequential as a small popover anchored to the 818 * Start-All button. Drops down by default; flips to drop-up if there 819 * isn't enough room below the button. Outside-click / Esc dismiss. */
820function _promptParallelOrSequential(count, anchorBtn) { 821 // Strip any prior instance so a second click closes-then-reopens cleanly. 822 const existing = document.getElementById('research-run-mode-popover'); 823 if (existing) { existing.remove(); return; } 824 if (!anchorBtn) return; 825 826 const rect = anchorBtn.getBoundingClientRect(); 827 const pop = document.createElement('div'); 828 pop.id = 'research-run-mode-popover'; 829 pop.className = 'research-run-mode-popover'; 830 // Same parallel / sequential glyphs the model-comparison picker uses. 831 const ICON_PARALLEL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>'; 832 const ICON_SEQUENTIAL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>'; 833 pop.innerHTML = 834 '<button class="research-run-mode-row" data-mode="parallel">' + ICON_PARALLEL + '<span class="rrm-title">Parallel</span></button>' 835 + '<button class="research-run-mode-row" data-mode="sequential">' + ICON_SEQUENTIAL + '<span class="rrm-title">Sequential</span></button>'; 836 document.body.appendChild(pop); 837 838 // Position: prefer dropping down from the button's bottom-right corner. 839 // If there isn't enough room below the viewport, flip to drop-up above. 840 const popHeight = pop.offsetHeight; 841 const margin = 6; 842 const spaceBelow = window.innerHeight - rect.bottom; 843 const goUp = spaceBelow < popHeight + margin && rect.top > popHeight + margin; 844 const top = goUp ? (rect.top - popHeight - margin) : (rect.bottom + margin); 845 // Right-align to the button so the menu doesn't extend off-screen on the right 846 const right = Math.max(8, window.innerWidth - rect.right); 847 pop.style.top = `${Math.round(top)}px`; 848 pop.style.right = `${Math.round(right)}px`; 849 pop.classList.add(goUp ? 'rrm-up' : 'rrm-down'); 850 851 const close = () => { 852 pop.remove(); 853 document.removeEventListener('click', onDocClick, true); 854 document.removeEventListener('keydown', onKey, true); 855 }; 856 const onDocClick = (e) => { 857 if (pop.contains(e.target) || e.target === anchorBtn) return; 858 close(); 859 }; 860 const onKey = (e) => { 861 if (e.key === 'Escape') { e.preventDefault(); close(); } 862 }; 863 setTimeout(() => { 864 document.addEventListener('click', onDocClick, true); 865 document.addEventListener('keydown', onKey, true); 866 }, 0); 867 868 pop.querySelectorAll('.research-run-mode-row').forEach(b => { 869 b.addEventListener('click', () => { 870 const mode = b.dataset.mode; 871 close(); 872 if (mode === 'parallel') jobs.startAllQueued(); 873 else jobs.startAllQueuedSequential(); 874 }); 875 }); 876} 877 878function _buildJobCard(job) { 879 const card = document.createElement('div'); 880 card.className = `research-job-card ${job.status}${job._fromLibrary ? ' from-library' : ''}`; 881 card.dataset.jobId = job.id; 882 if (job.category) card.dataset.category = job.category; 883 884 const elapsed = jobs.formatElapsed(job.elapsed || 0); 885 const isExpanded = _expandedJobId === job.id;
886 const modelTag = (job.modelName || job.settings?._modelName) 887 ? `<span class="research-job-model">${_esc(job.modelName || job.settings._modelName)}</span>` : ''; 888 889 if (job.status === 'queued') { 890 const rounds = job.settings?.max_rounds; 891 const roundsLabel = !rounds ? 'Auto rounds' : `${rounds} rounds`; 892 const epName = job.settings?._endpointName || ''; 893 const mName = job.settings?._modelName || ''; 894 const meta = [mName, epName, roundsLabel].filter(Boolean).join(' -- '); 895 card.innerHTML = ` 896 <div class="research-job-header"> 897 <span class="research-job-query">${_esc(job.query)}</span>${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""} 898 </div> 899 <div class="research-job-queued-meta">${_esc(meta)}</div> 900 <div class="research-job-actions"> 901 <button class="research-job-action" data-action="start" title="Start">${_playIcon} Start</button> 902 <button class="research-job-action" data-action="edit" title="Edit query">${_editIcon} Edit</button> 903 <button class="research-job-action research-job-action-dim" data-action="remove" title="Remove">${_cancelIcon}</button> 904 </div> 905 `; 906 card.querySelector('[data-action="start"]').addEventListener('click', (e) => { 907 e.stopPropagation(); jobs.startQueued(job.id); 908 }); 909 card.querySelector('[data-action="edit"]').addEventListener('click', (e) => { 910 e.stopPropagation(); _editJob(job); 911 }); 912 card.querySelector('[data-action="remove"]').addEventListener('click', (e) => { 913 e.stopPropagation(); jobs.removeJob(job.id); 914 }); 915 916 } else if (job.status === 'running') { 917 // Auto mode (max_rounds=0/undefined) â show round number without total, 918 // and base the progress bar on a heuristic cap of 8 rounds. 919 const userMaxR = job.settings?.max_rounds || 0; 920 const phaseMaxR = userMaxR || 0; // 0 = formatPhase shows "Round X" without total 921 const phase = jobs.formatPhase(job.progress, phaseMaxR); 922 const round = job.progress?.round || 0; 923 const barCap = userMaxR || 8; 924 const pct = Math.min(100, Math.round((round / barCap) * 100)); 925 card.innerHTML = ` 926 <div class="research-job-header"> 927 <span class="research-job-query">${_esc(job.query)}</span>
927${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""} 928 ${modelTag} 929 <span class="research-job-time">${elapsed}</span> 930 <button class="research-synapse-toggle${_synapseMinimized ? ' active' : ''}" title="${_synapseMinimized ? 'Show visualization' : 'Minimize visualization'}">${_synapseMinimized ? _vizExpandIcon : _vizCollapseIcon}</button> 931 <button class="research-job-cancel" title="Cancel research">${_cancelIcon}</button> 932 </div> 933 <div class="research-job-phase">${phase}</div> 934 <div class="research-job-synapse-host${_synapseMinimized ? ' synapse-collapsed' : ''}" data-synapse-host="${job.id}"></div> 935 <div class="research-progress-bar"><div class="research-progress-fill" style="width:${pct}%"></div></div> 936 `; 937 card.querySelector('.research-job-cancel').addEventListener('click', (e) => { 938 e.stopPropagation(); jobs.cancelJob(job.id); 939 }); 940 card.querySelector('.research-synapse-toggle')?.addEventListener('click', (e) => { 941 e.stopPropagation(); _toggleSynapseMinimized(); 942 }); 943 // Click anywhere on the header (title/model/time) toggles the visualization 944 // too â the cancel/synapse buttons stopPropagation so they keep their own. 945 const _runHdr = card.querySelector('.research-job-header'); 946 if (_runHdr) { 947 _runHdr.style.cursor = 'pointer'; 948 _runHdr.addEventListener('click', () => _toggleSynapseMinimized()); 949 } 950 // Attach (or re-attach) the live synapse visualization. Created once per 951 // job so animations/state persist across the _renderJobs() rebuilds that 952 // fire on every progress event. 953 const host = card.querySelector('.research-job-synapse-host'); 954 let entry = _jobSynapses.get(job.id); 955 if (!entry) { 956 const synapse = createResearchSynapse(host, { 957 query: job.query || '', 958 startedAt: job.startedAt || (Date.now() - (job.elapsed || 0) * 1000), 959 compact: true, 960 }); 961 entry = { synapse, status: 'running' }; 962 _jobSynapses.set(job.id, entry); 963 } else { 964 // Move the existing element into the freshly-rendered host 965 host.appendChild(entry.synapse.element); 966 } 967 // Push the current progress state 968 if (job.progress) { 969 entry.synapse.setPhase(job.progress.phase, job.progress); 970 if (typeof job.progress.round === 'number') entry.synapse.setRound(job.progress.round); 971 if (typeof job.progress.total_sources === 'number') entry.synapse.setSourceCount(job.progress.total_sources); 972 } 973 974 } else if (job.status === 'done') { 975 // Library-loaded jobs have sources=null but pre-set sourceCount; fresh jobs 976 // populate sources directly. Prefer the pre-set count if present. 977 const srcCount = job.sources?.length ?? job.sourceCount ?? 0; 978 // 0 sources = the research couldn't gather/extract anything â flag it. 979 const failed = srcCount === 0; 980 if (failed) card.classList.add('research-job-failed'); 981 const doneBadge = failed 982 ? `<span class="research-cat-badge research-cat-failed">${_cancelIcon} no results</span>`
983 : (job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : `<span class="research-cat-badge research-cat-standard">standard</span>`); 984 const failNote = failed 985 ? `<div class="research-job-failnote">Couldn't extract anything â try rephrasing the question, or switch the search engine in Settings.</div>` 986 : ''; 987 card.innerHTML = ` 988 <div class="research-job-header"> 989 <span class="research-job-query">${_esc(job.query)}</span>${doneBadge} 990 ${modelTag} 991 <span class="research-job-meta">${elapsed} -- ${srcCount} sources</span> 992 </div> 993 ${failNote} 994 <div class="research-job-actions"> 995 <button class="research-job-action" data-action="copy" title="Copy report to clipboard">${_copyIcon}</button> 996 <button class="research-job-action" data-action="chat" title="Open follow-up chat with this research as context">${_chatIcon} Discuss</button> 997 <button class="research-job-action research-job-action-report" data-action="report" title="Visual report">${_externalIcon} Visual Report</button> 998 <button class="research-job-action research-job-action-dim" data-action="dismiss" title="Clear from list">${_cancelIcon}</button> 999 <button class="research-job-action research-job-action-dim" data-action="delete" title="Delete from disk">${_trashIcon} Delete</button> 1000 </div> 1001 ${isExpanded ? `<div class="research-job-result">${_renderResult(job)}</div>` : ''} 1002 `; 1003 // Clicking anywhere on the card (except the action buttons, which 1004 // stopPropagation) opens the visual report â same as the Visual Report btn. 1005 card.style.cursor = 'pointer'; 1006 card.addEventListener('click', () => { 1007 window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank'); 1008 }); 1009 card.querySelector('[data-action="copy"]').addEventListener('click', async (e) => { 1010 e.stopPropagation(); 1011 const btn = e.currentTarget; // capture before await â currentTarget becomes null after 1012 if (!job.result) await _ensureResult(job); 1013 _copyResult(job, btn); 1014 }); 1015 card.querySelector('[data-action="report"]').addEventListener('click', (e) => { 1016 e.stopPropagation(); 1017 window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank'); 1018 }); 1019 card.querySelector('[data-action="chat"]').addEventListener('click', (e) => { 1020 e.stopPropagation(); 1021 _chatAboutResearch(job.id, e.currentTarget); 1022 }); 1023 card.querySelector('[data-action="delete"]').addEventListener('click', async (e) => { 1024 e.stopPropagation(); 1025 if (window.styledConfirm) { 1026 const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { c
1026onfirmText: 'Delete', danger: true }); 1027 if (!ok) return; 1028 } 1029 try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {} 1030 _animateOutThenRemove(card, () => jobs.removeJob(job.id)); 1031 }); 1032 card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => { 1033 e.stopPropagation(); 1034 _animateOutThenRemove(card, () => jobs.removeJob(job.id)); 1035 }); 1036 1037 } else { 1038 const errMsg = job.errorMsg ? `<div class="research-job-error">${_esc(job.errorMsg)}</div>` : ''; 1039 card.innerHTML = ` 1040 <div class="research-job-header"> 1041 <span class="research-job-query">${_esc(job.query)}</span>${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""} 1042 <span class="research-job-status">${job.status}</span> 1043 </div> 1044 ${errMsg} 1045 <div class="research-job-actions"> 1046 <button class="research-job-action" data-action="retry" title="Retry">${_retryIcon} Retry</button> 1047 <button class="research-job-action" data-action="edit" title="Edit and retry">${_editIcon} Edit</button> 1048 <button class="research-job-action research-job-action-dim" data-action="dismiss" title="Dismiss">${_cancelIcon}</button> 1049 </div> 1050 `; 1051 card.querySelector('[data-action="retry"]').addEventListener('click', (e) => { 1052 e.stopPropagation(); jobs.retryJob(job.id); 1053 }); 1054 card.querySelector('[data-action="edit"]').addEventListener('click', (e) => { 1055 e.stopPropagation(); _editJob(job); 1056 }); 1057 card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => { 1058 e.stopPropagation(); jobs.removeJob(job.id); 1059 }); 1060 } 1061 1062 return card; 1063} 1064 1065const _CAT_ICONS = { 1066 product: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="2"/><path d="M7 8V5a5 5 0 0 1 10 0v3"/></svg>', 1067 comparison: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v18"/><path d="M16 3v18"/><path d="M3 8h5"/><path d="M16 16h5"/></svg>', 1068 howto: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>', 1069 landscape: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>', 1070 factcheck: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>', 1071}; 1072 1073const _CAT_LABELS = { 1074 product: 'Product', 1075 comparison: 'Comparison', 1076 howto: 'How-to Guide', 1077 landscape: 'Landscape', 1078 factcheck: 'Fact-check', 1079}; 1080 1081function _renderResult(job) { 1082 if (!job.result) return '<div class="research-job-loading">Loading result...</div>'; 1083 const cat = job.category || ''; 1084 const catIcon = _CAT_ICONS[cat] || ''; 1085 const catLabel = _CAT_LABELS[cat] || ''; 1086 1087 let html = ''; 1088 1089 // Category hero banner â only for completed, known-category results 1090 if (cat && catIcon) { 1091 html += ` 1092 <div class="research-hero research-hero-${cat}"> 1093 <span class="research-hero-icon">${catIcon}</span> 1094 <div class="research-hero-text"> 1095 <div class="research-hero-label">${catLabel}</div> 1096 <div class="research-hero-query">${_esc(job.query)}</div> 1097 </div> 1098 </div> 1099 `; 1100 } 1101 1102 if (job.sources?.length) { 1103 html += '<div class="research-job-sources">'; 1104 for (const s of job.sources.slice(0, 10)) { 1105 const title = _esc(s.title || s.url || ''); 1106 const url = _safeSourceHref(s.url); 1107 html += url 1108 ? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>` 1109 : `<span class="research-source-link">${title}</span>`;
1110 } 1111 if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`; 1112 html += '</div>'; 1113 } 1114 1115 const bodyCls = `research-job-report-body${cat ? ' research-body-' + cat : ''}`; 1116 if (_markdownModule) { 1117 html += `<div class="${bodyCls}">${_markdownModule.renderContent(job.result)}</div>`; 1118 } else { 1119 html += `<div class="${bodyCls}"><pre>${_esc(job.result)}</pre></div>`; 1120 } 1121 return html; 1122} 1123 1124async function _ensureResult(job) { 1125 if (job.result) return; 1126 try { 1127 const res = await fetch(`${_apiBase}/api/research/result-peek/${job.id}`, { 1128 method: 'POST', credentials: 'same-origin', 1129 }); 1130 if (!res.ok) return; 1131 const d = await res.json(); 1132 job.result = d.result; 1133 job.sources = d.sources; 1134 job.findings = d.raw_findings; 1135 } catch {} 1136} 1137 1138async function _copyResult(job, btn) { 1139 if (!job.result) return; 1140 let text = `# ${job.query}\n\n${job.result}`; 1141 if (job.findings?.length) { 1142 text += '\n\n---\n## Raw Findings\n'; 1143 for (const f of job.findings) { 1144 text += `\n### ${f.title || 'Untitled'}\nSource: ${f.url || ''}\n${f.summary || ''}\n`; 1145 } 1146 } 1147 if (job.sources?.length) { 1148 const srcList = job.sources.map(s => `- [${s.title || s.url}](${s.url})`).join('\n'); 1149 text += `\n\n---\n## Sources\n${srcList}`; 1150 } 1151 let ok = false; 1152 try { 1153 if (navigator.clipboard && window.isSecureContext) { 1154 await navigator.clipboard.writeText(text); 1155 ok = true; 1156 } 1157 } catch {} 1158 if (!ok) { 1159 // Fallback for non-secure contexts (HTTP self-host) where navigator.clipboard 1160 // is unavailable. The textarea must be in-viewport and focusable for Firefox 1161 // Android / iOS Safari to allow execCommand('copy'). 1162 const ta = document.createElement('textarea'); 1163 ta.value = text; 1164 ta.readOnly = false; 1165 ta.contentEditable = 'true'; 1166 ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;'; 1167 document.body.appendChild(ta); 1168 ta.focus(); 1169 ta.select(); 1170 try { ta.setSelectionRange(0, text.length); } catch {} 1171 try { 1172 const sel = window.getSelection(); 1173 if (sel && (!sel.rangeCount || sel.isCollapsed)) { 1174 const range = document.createRange(); 1175 range.selectNodeContents(ta); 1176 sel.removeAllRanges(); 1177 sel.addRange(range); 1178 ta.setSelectionRange(0, text.length); 1179 } 1180 } catch {} 1181 try { ok = document.execCommand('copy'); } catch {} 1182 ta.remove(); 1183 } 1184 if (btn) { 1185 const orig = btn.innerHTML; 1186 if (ok) { 1187 btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`; 1188 btn.classList.add('research-job-action-copied'); 1189 setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('research-job-action-copied'); }, 2000); 1190 } else { 1191 btn.innerHTML = `${_cancelIcon} Failed`; 1192 setTimeout(() => { btn.innerHTML = orig; }, 2000); 1193 } 1194 } 1195} 1196 1197// ââ Chat about this research (server-side spinoff) ââ 1198 1199async function _chatAboutResearch(researchId, btn) { 1200 if (!researchId) return; 1201 const origLabel = btn ? btn.innerHTML : ''; 1202 if (btn) { btn.disabled = true; btn.innerHTML = `${_chatIcon} Creatingâ¦`; } 1203 try { 1204 const res = await fetch(`${_apiBase}/api/research/spinoff/${researchId}`, { 1205 method: 'POST', credentials: 'same-origin', 1206 }); 1207 if (!res.ok) { 1208 let detail = ''; 1209 try { detail = (await res.json()).detail || ''; } catch {} 1210 throw new Error(detail || `HTTP ${res.status}`); 1211 } 1212 const payload = await res.json(); 1213 if (_sessionModule && _sessionModule.selectSession && payload.session_id) { 1214 if (_sessionModule.loadSessions) await _sessionModule.loadSessions().catch(() => {}); 1215 await _sessionModule.selectSession(payload.session_id); 1216 closePanel(); 1217 } else if (payload.session_id) { 1218 window.location.hash = '#' + payload.session_id; 1219 window.location.reload(); 1220 } else { 1221 // 200 OK but no session_id â server contract violation. Don't leave 1222 // the button stuck on 'Creatingâ¦'; surface the failure instead. 1223 throw new Error('Server returned no session id'); 1224 } 1225 } catch (e) { 1226 if (btn) { btn.disabled = false; btn.innerHTML = origLabel; }
1227 alert('Could not start follow-up chat: ' + e.message); 1228 } 1229} 1230 1231function _esc(s) { 1232 const d = document.createElement('div'); 1233 d.textContent = s || ''; 1234 return d.innerHTML; 1235} 1236 1237function _safeSourceHref(raw) { 1238 try { 1239 const parsed = new URL(String(raw || '').trim(), window.location.origin); 1240 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href); 1241 } catch {} 1242 return ''; 1243}
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.