/** * Deep Research side panel — open/close, form, job rendering, library. */ import * as jobs from './jobs.js'; import themeModule from '../theme.js'; import createResearchSynapse from '../researchSynapse.js'; import spinnerModule from '../spinner.js'; import { sortModelIds } from '../modelSort.js'; // jobId -> { synapse, status } — survives across _renderJobs() rebuilds so // the SVG keeps its accumulated nodes/edges between progress events. const _jobSynapses = new Map(); // Which foldable job sections ('active' / 'past') the user has collapsed — kept // across re-renders so the panel doesn't re-expand on every job-state change. const _collapsedSections = new Set(); // Persisted preference to minimize (hide) the per-job synapse "tree" visual. // Stored globally so it survives the frequent _renderJobs() card rebuilds and // applies to every running job. const _SYNAPSE_MIN_KEY = 'research.synapseMinimized'; let _synapseMinimized = (() => { try { return localStorage.getItem(_SYNAPSE_MIN_KEY) === '1'; } catch { return false; } })(); const _vizCollapseIcon = ''; const _vizExpandIcon = ''; function _toggleSynapseMinimized() { _synapseMinimized = !_synapseMinimized; try { localStorage.setItem(_SYNAPSE_MIN_KEY, _synapseMinimized ? '1' : '0'); } catch {} // Apply live to all rendered cards without forcing a full rebuild. document.querySelectorAll('.research-job-synapse-host') .forEach(h => h.classList.toggle('synapse-collapsed', _synapseMinimized)); document.querySelectorAll('.research-synapse-toggle').forEach(b => { b.classList.toggle('active', _synapseMinimized); b.title = _synapseMinimized ? 'Show visualization' : 'Minimize visualization'; b.innerHTML = _synapseMinimized ? _vizExpandIcon : _vizCollapseIcon; }); } let _open = false; let _onDocKeydown = null; let _apiBase = ''; let _endpoints = []; let _expandedJobId = null; let _markdownModule = null; let _sessionModule = null; let _settingsCollapsed = false; const _SETTINGS_KEY = 'odysseus-research-settings'; const _COLLAPSE_KEY = 'odysseus-research-settings-collapsed'; try { _settingsCollapsed = localStorage.getItem(_COLLAPSE_KEY) === '1'; } catch {} function _saveSettingsToStorage() { try { const activeCat = document.querySelector('.research-cat.active'); localStorage.setItem(_SETTINGS_KEY, JSON.stringify({ max_rounds: document.getElementById('research-rounds')?.value || '0', search_provider: document.getElementById('research-search-provider')?.value || '', endpoint_id: document.getElementById('research-endpoint')?.value || '', model: document.getElementById('research-model')?.value || '', category: activeCat?.dataset.cat || '', })); } catch {} } function _loadSettingsFromStorage() { try { const raw = localStorage.getItem(_SETTINGS_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function _showBadge() { const btn = document.getElementById('tool-research-btn'); if (!btn || btn.querySelector('.research-badge')) return; const dot = document.createElement('span'); dot.className = 'research-badge'; btn.appendChild(dot); } function _clearBadge() { const dot = document.querySelector('#tool-research-btn .research-badge'); if (dot) dot.remove(); } // Live sidebar/rail feedback — mirrors the cookbook pattern. While // research jobs are running, the rail button pulses; errors flag red; // nothing running clears it. Panel-independent so it works with the // modal closed. Called from _renderJobs on every job-state change. function _syncResearchRail() { let running = 0, errored = 0, runningJob = null; try { for (const j of jobs.getJobs()) { if (j.status === 'running' || j.status === 'queued') { running++; if (j.status === 'running' && !runningJob) runningJob = j; } else if (j.status === 'error') errored++; } } catch { return; } const railBtn = document.getElementById('rail-research'); const toolBtn = document.getElementById('tool-research-btn'); const active = running > 0 || errored > 0; // Shared flag so sessions.js:_updateRailNotifs (which lights the same // rail button for INLINE research mode) ORs with us instead of // clobbering — otherwise a session re-render would clear our dot. window._researchJobsActive = active; if (railBtn) { railBtn.classList.remove('rail-notify', 'rail-notify-success', 'rail-notify-error', 'research-notif-active'); if (active) { railBtn.classList.add('rail-notify', errored ? 'rail-notify-error' : 'rail-notify-success', 'research-notif-active'); } } if (toolBtn) { toolBtn.classList.toggle('research-notif-active', active); toolBtn.style.opacity = active ? '1' : ''; // Sidebar feedback while running — a small pulsing dot + round text, // same style as Cookbook's running indicator (no glow). let wrap = toolBtn.querySelector('.research-sb-running'); if (running > 0) { if (!wrap) { wrap = document.createElement('span'); wrap.className = 'research-sb-running'; wrap.innerHTML = ''; toolBtn.appendChild(wrap); } const round = runningJob && runningJob.progress && runningJob.progress.round; // Just the round as "R1", "R2", … (empty until the first round lands). // Only update when we actually have a round — don't blank it out on // progress ticks that lack one, or it flickers on/off between rounds. if (round) wrap.querySelector('.research-sb-status').textContent = `R${round}`; } else if (wrap) { wrap.remove(); } } // Orbiting edge animation: faster when a job is running, slower while idle // (ambient). The rAF loop in _ensureOrbit drives --research-orbit-angle on // the pane element — CSS-only @property animation silently no-op'd in some // browsers, so JS drives it for universal compatibility. _orbitSpeedDegPerSec = running > 0 ? 60 : 22; // 6s/rev vs ~16s/rev _ensureOrbit(); if (window._syncRailDynamic) window._syncRailDynamic(); } // ── Orbit-angle rAF driver ───────────────────────────────────── // Universally-supported alternative to a CSS @property angle animation. // Walks --research-orbit-angle on the #research-pane element every frame // while the panel is open. Stops itself when the pane is gone. let _orbitRAF = null; let _orbitAngle = 0; let _orbitLastTs = 0; let _orbitSpeedDegPerSec = 22; // idle ambient default function _ensureOrbit() { if (_orbitRAF) return; _orbitLastTs = 0; const tick = (ts) => { const pane = document.getElementById('research-pane'); if (!pane) { _orbitRAF = null; return; } // panel closed → stop loop if (_orbitLastTs) { const dt = (ts - _orbitLastTs) / 1000; _orbitAngle = (_orbitAngle + _orbitSpeedDegPerSec * dt) % 360; pane.style.setProperty('--research-orbit-angle', _orbitAngle.toFixed(2) + 'deg'); } _orbitLastTs = ts; _orbitRAF = requestAnimationFrame(tick); }; _orbitRAF = requestAnimationFrame(tick); } /** Fetch the count of saved research items and populate the header chip. */ async function _updateResearchCount() { const el = document.getElementById('research-stats'); if (!el) return; try { const res = await fetch('/api/research/library?limit=1', { credentials: 'same-origin' }); if (!res.ok) return; const data = await res.json(); const n = data.total || 0; el.textContent = n + (n === 1 ? ' research' : ' research'); } catch {} } const _searchIcon = ''; const _closeIcon = ''; const _playIcon = ''; const _cancelIcon = ''; const _trashIcon = ''; const _externalIcon = ''; const _copyIcon = ''; const _retryIcon = ''; const _chevronIcon = ''; const _editIcon = ''; const _chatIcon = ''; export function init(apiBase, markdownMod, sessionMod) { _apiBase = apiBase; _markdownModule = markdownMod; _sessionModule = sessionMod; jobs.init(apiBase); jobs.setRenderCallback(_renderJobs); jobs.onComplete(() => { if (!_open) _showBadge(); }); } export function isOpen() { return _open; } export function toggle() { if (_open) { // If minimized, restore instead of closing const overlay = document.getElementById('research-overlay'); if (overlay && overlay.style.display === 'none') { overlay.style.display = ''; const btn = document.getElementById('tool-research-btn'); if (btn) btn.classList.remove('minimized'); return; } closePanel(); } else { openPanel(); } } export function openPanel(focusJobId) { if (_open) { const overlay = document.getElementById('research-overlay'); if (overlay && overlay.style.display === 'none') { overlay.style.display = ''; const btn = document.getElementById('tool-research-btn'); if (btn) btn.classList.remove('minimized'); } document.body.classList.add('research-panel-view'); if (focusJobId) _focusJob(focusJobId); return; } _open = true; const container = document.getElementById('chat-container'); if (!container) return; document.body.classList.add('research-panel-view'); const btn = document.getElementById('tool-research-btn'); if (btn) btn.classList.add('active'); const overlay = document.createElement('div'); overlay.id = 'research-overlay'; overlay.className = 'modal research-overlay'; // Match doclib/gallery/calendar modal sizing exactly so research feels like // the rest of the modal family (centered, ~640px, 85vh). const pane = document.createElement('div'); pane.id = 'research-pane'; pane.className = 'modal-content doclib-modal-content research-pane'; // Mobile: full-screen so the content has room and the jobs list can scroll // inside it. Desktop: centered ~640px / 85vh modal like the rest. pane.style.cssText = (window.innerWidth <= 768) ? 'width:100vw;max-width:100vw;height:90dvh;max-height:90dvh;border-radius:14px 14px 0 0;background:var(--bg);' : 'width:min(640px, 92vw);max-height:85vh;background:var(--bg);'; pane.innerHTML = _buildPanelHTML(); overlay.appendChild(pane); document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); }); // Document-level ESC handler — overlay-only listener never fired because // overlay isn't focused. Tracked in module scope so closePanel can detach. _onDocKeydown = (e) => { if (e.key === 'Escape' && _open) { e.preventDefault(); closePanel(); } }; document.addEventListener('keydown', _onDocKeydown); // Make the pane draggable by its header — same pattern as Library/Calendar. const paneHeader = pane.querySelector('.research-pane-header'); if (themeModule && themeModule.makeDraggable && paneHeader) { themeModule.makeDraggable(pane, paneHeader); } _wireEvents(pane); _loadEndpoints().then(_restoreSavedSettings); _clearBadge(); _updateResearchCount(); if ('Notification' in window && Notification.permission === 'default') { try { Notification.requestPermission(); } catch {} } if (focusJobId) _focusJob(focusJobId); } // Scroll to + highlight a research job card by session id. Used by the // chat anchor-link delegate ([Topic](#research-)). function _focusJob(jobId) { if (!jobId) return; // jobs may still be loading from /api/research/active — retry a few times. let tries = 0; const tryFocus = () => { const card = document.querySelector(`[data-job-id="${jobId}"]`); if (card) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.classList.add('research-card-flash'); setTimeout(() => card.classList.remove('research-card-flash'), 2000); return; } if (tries++ < 8) setTimeout(tryFocus, 400); }; setTimeout(tryFocus, 200); } export function closePanel() { if (!_open) return; _open = false; if (_onDocKeydown) { document.removeEventListener('keydown', _onDocKeydown); _onDocKeydown = null; } document.body.classList.remove('research-panel-view'); const btn = document.getElementById('tool-research-btn'); if (btn) btn.classList.remove('active'); const overlay = document.getElementById('research-overlay'); if (overlay) overlay.remove(); } function _buildPanelHTML() { const searchProviders = ['', 'searxng', 'duckduckgo', 'tavily', 'brave', 'google', 'serper']; const providerOpts = searchProviders.map(p => `` ).join(''); let roundOpts = ''; for (let i = 1; i <= 20; i++) { roundOpts += ``; } const settingsHidden = _settingsCollapsed ? ' style="display:none"' : ''; const chevronCls = _settingsCollapsed ? ' collapsed' : ''; return ` `; } /** Fade/slide a card out, then run the removal — matches cookbook's smooth exit. */ function _animateOutThenRemove(el, removeFn) { if (!el || !el.style) { removeFn(); return; } el.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; el.style.opacity = '0'; el.style.transform = 'translateX(-10px)'; setTimeout(removeFn, 320); } /** Dismiss the mobile keyboard by stealing focus into a throwaway readonly * input (blur() alone is often ignored on Firefox mobile). */ function _dismissKeyboard(input) { try { if (input) input.blur(); const tmp = document.createElement('input'); tmp.setAttribute('readonly', 'readonly'); tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;'; document.body.appendChild(tmp); tmp.focus(); setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 60); } catch {} } /** Reset the category selector back to "Auto" (called after each start). */ function _resetCategoryToAuto() { document.querySelectorAll('.research-cat').forEach(b => b.classList.toggle('active', (b.dataset.cat || '') === '')); } function _wireEvents(pane) { pane.querySelector('#research-panel-close').addEventListener('click', closePanel); pane.querySelector('#research-panel-minimize')?.addEventListener('click', () => { const overlay = document.getElementById('research-overlay'); if (overlay) overlay.style.display = 'none'; const btn = document.getElementById('tool-research-btn'); if (btn) btn.classList.add('minimized'); }); pane.querySelector('#research-start-btn').addEventListener('click', _handleStart); pane.querySelector('#research-add-btn').addEventListener('click', _handleAdd); pane.querySelectorAll('.research-cat').forEach(btn => { btn.addEventListener('click', () => { pane.querySelectorAll('.research-cat').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); pane.querySelector('#research-settings-toggle').addEventListener('click', () => { const body = document.getElementById('research-settings-body'); const btn = document.getElementById('research-settings-toggle'); if (!body || !btn) return; _settingsCollapsed = !_settingsCollapsed; body.style.display = _settingsCollapsed ? 'none' : ''; btn.classList.toggle('collapsed', _settingsCollapsed); try { localStorage.setItem('odysseus-research-settings-collapsed', _settingsCollapsed ? '1' : '0'); } catch {} }); const queryInput = pane.querySelector('#research-query'); queryInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); _handleStart(); } }); const endpointSelect = pane.querySelector('#research-endpoint'); endpointSelect.addEventListener('change', () => _populateModels(endpointSelect.value)); _renderJobs(); } function _readSettings() { const activeCat = document.querySelector('.research-cat.active'); const category = activeCat?.dataset.cat || undefined; const settings = { max_rounds: parseInt(document.getElementById('research-rounds')?.value || '0', 10), search_provider: document.getElementById('research-search-provider')?.value || undefined, endpoint_id: document.getElementById('research-endpoint')?.value || undefined, model: document.getElementById('research-model')?.value || undefined, category: category || undefined, }; const epSel = document.getElementById('research-endpoint'); if (epSel && epSel.value) { const opt = epSel.options[epSel.selectedIndex]; settings._endpointName = opt?.textContent || ''; } const modelSel = document.getElementById('research-model'); if (modelSel && modelSel.value) settings._modelName = modelSel.value; Object.keys(settings).forEach(k => { if (!settings[k]) delete settings[k]; }); return settings; } function _handleAdd() { const queryEl = document.getElementById('research-query'); const query = (queryEl?.value || '').trim(); if (!query) { queryEl?.focus(); return; } _saveSettingsToStorage(); jobs.addToQueue(query, _readSettings()); queryEl.value = ''; queryEl.focus(); } // Move a job's data back into the compose form so user can edit and re-queue function _editJob(job) { const queryEl = document.getElementById('research-query'); if (queryEl) { queryEl.value = job.query || ''; queryEl.focus(); queryEl.setSelectionRange(queryEl.value.length, queryEl.value.length); } // Restore category const cat = job.category || ''; document.querySelectorAll('.research-cat').forEach(b => { b.classList.toggle('active', b.dataset.cat === cat); }); // Restore settings const s = job.settings || {}; const roundsEl = document.getElementById('research-rounds'); if (roundsEl && s.max_rounds) roundsEl.value = s.max_rounds; const spEl = document.getElementById('research-search-provider'); if (spEl && s.search_provider) spEl.value = s.search_provider; const epEl = document.getElementById('research-endpoint'); if (epEl && s.endpoint_id) epEl.value = s.endpoint_id; const mEl = document.getElementById('research-model'); if (mEl && s.model) mEl.value = s.model; // Remove the old job so clicking Start/Queue makes a fresh one jobs.removeJob(job.id); // Scroll the form into view queryEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } async function _handleStart() { const queryEl = document.getElementById('research-query'); const startBtn = document.getElementById('research-start-btn'); const query = (queryEl?.value || '').trim(); // "Start All" mode: more than one job queued → let the user pick parallel // vs sequential before launching. Queue any freshly-typed query first so // it joins the batch, then open the picker anchored to this button. const queuedCount = jobs.getJobs().filter(j => j.status === 'queued').length; if (queuedCount > 1) { if (query) { _saveSettingsToStorage(); jobs.addToQueue(query, _readSettings()); queryEl.value = ''; } _resetCategoryToAuto(); if (window.innerWidth <= 768) _dismissKeyboard(queryEl); const total = jobs.getJobs().filter(j => j.status === 'queued').length; _promptParallelOrSequential(total, startBtn); return; } // Visual + spinner feedback while the launch request is in flight const _setBusy = (busy) => { if (!startBtn) return; if (busy) { startBtn.disabled = true; startBtn.dataset._origHTML = startBtn.dataset._origHTML || startBtn.innerHTML; startBtn.innerHTML = ''; try { const _wp = spinnerModule.createWhirlpool(14); _wp.element.style.cssText += ';vertical-align:middle;margin-right:5px;position:relative;top:-1px;'; startBtn.appendChild(_wp.element); } catch {} startBtn.appendChild(document.createTextNode('Starting')); startBtn.classList.add('research-start-busy'); } else { startBtn.disabled = false; startBtn.classList.remove('research-start-busy'); if (startBtn.dataset._origHTML) { startBtn.innerHTML = startBtn.dataset._origHTML; } } }; // Show busy briefly for click feedback. Don't await the full launch — // the per-job card immediately shows "Starting..." progress, and the // backend POST can take a while. _setBusy(true); setTimeout(() => _setBusy(false), 1500); const _mobile = window.innerWidth <= 768; if (!query) { jobs.startAllQueued(); _resetCategoryToAuto(); if (_mobile) _dismissKeyboard(queryEl); return; } _saveSettingsToStorage(); const settings = _readSettings(); queryEl.value = ''; // Mobile: drop the keyboard after sending; desktop: keep focus for fast follow-ups. if (_mobile) _dismissKeyboard(queryEl); else queryEl.focus(); _resetCategoryToAuto(); jobs.startJob(query, settings).catch((e) => { if (typeof uiModule !== 'undefined' && uiModule?.showError) uiModule.showError('Failed to start research'); queryEl.value = query; // restore so user can retry }); } function _restoreSavedSettings() { const saved = _loadSettingsFromStorage(); if (!saved) return; if (saved.category !== undefined) { document.querySelectorAll('.research-cat').forEach(b => { b.classList.toggle('active', b.dataset.cat === saved.category); }); } // Rounds intentionally defaults to "Auto" on every open — don't restore. // Users can pick a specific cap each time if needed. const search = document.getElementById('research-search-provider'); if (search && saved.search_provider !== undefined) search.value = saved.search_provider; const ep = document.getElementById('research-endpoint'); if (ep && saved.endpoint_id) { ep.value = saved.endpoint_id; _populateModels(saved.endpoint_id); if (saved.model) { setTimeout(() => { const model = document.getElementById('research-model'); if (model) model.value = saved.model; }, 50); } } } async function _loadEndpoints() { try { const res = await fetch(`${_apiBase}/api/model-endpoints`, { credentials: 'same-origin' }); if (!res.ok) return; _endpoints = await res.json(); const sel = document.getElementById('research-endpoint'); if (!sel) return; _endpoints.filter(e => e.is_enabled && e.model_type === 'llm').forEach(ep => { const opt = document.createElement('option'); opt.value = ep.id; opt.textContent = ep.name || ep.base_url; sel.appendChild(opt); }); } catch {} } function _populateModels(endpointId) { const sel = document.getElementById('research-model'); if (!sel) return; sel.innerHTML = ''; if (!endpointId) return; const ep = _endpoints.find(e => e.id === endpointId); if (!ep || !ep.models) return; sortModelIds(ep.models).forEach(m => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; sel.appendChild(opt); }); } // ── Job rendering ── function _renderJobs() { // Keep the rail/sidebar indicator in sync on every job-state change, // even when the panel is closed (no container yet). _syncResearchRail(); const container = document.getElementById('research-jobs-list'); if (!container) return; const allJobs = jobs.getJobs(); if (!allJobs.length) { // No empty-state text in the body — the query box above is the call to // action. But still surface the "All past research found in Library, // Research" hint under the main title, since the Past section won't // render to host it (this is exactly the case the dynamic hint targets). container.innerHTML = ''; const noPastHint = document.getElementById('research-no-past-hint'); if (noPastHint) { noPastHint.style.display = ''; if (!noPastHint.dataset._wired) { noPastHint.dataset._wired = '1'; noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => { e.stopPropagation(); closePanel(); if (window.documentModule && window.documentModule.openLibrary) { window.documentModule.openLibrary({ tab: 'research' }); } }); } } return; } container.innerHTML = ''; const active = allJobs.filter(j => j.status === 'queued' || j.status === 'running' || j.status === 'error' || j.status === 'cancelled'); const past = allJobs.filter(j => j.status === 'done' && j._fromLibrary); const recentDone = allJobs.filter(j => j.status === 'done' && !j._fromLibrary).reverse(); // Keep the header "(N research)" chip in sync with the Past-section count. // _updateResearchCount fetches the library total only, which under-counts // when there's a session-completed job not yet persisted to the library. const statsEl = document.getElementById('research-stats'); if (statsEl) { const n = recentDone.length + past.length; statsEl.textContent = n + ' research'; } // The main Start button doubles as "Start All (N)" when more than one job // is queued — clicking it then opens the parallel/sequential picker. No // separate queue-bar button (that was the redundant second button). const queued = active.filter(j => j.status === 'queued'); const startBtn = document.getElementById('research-start-btn'); if (startBtn && !startBtn.classList.contains('research-start-busy')) { startBtn.innerHTML = queued.length > 1 ? `${_playIcon} Start All (${queued.length})` : `${_playIcon} Start`; startBtn.dataset._origHTML = startBtn.innerHTML; } // Dynamic Past hint: when the Past section won't render (no past items), // surface the "All past research found in Library, Research" line under // the main Research title instead, so the link is always discoverable. const noPastHint = document.getElementById('research-no-past-hint'); if (noPastHint) { const hasPast = past.length + recentDone.length > 0; noPastHint.style.display = hasPast ? 'none' : ''; if (!hasPast && !noPastHint.dataset._wired) { noPastHint.dataset._wired = '1'; noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => { e.stopPropagation(); closePanel(); if (window.documentModule && window.documentModule.openLibrary) { window.documentModule.openLibrary({ tab: 'research' }); } }); } } // Clean up synapses for jobs that finished or disappeared. complete() // marks the SVG green for ~800ms before destroy removes it. const liveIds = new Set(allJobs.filter(j => j.status === 'running').map(j => j.id)); for (const [jobId, entry] of _jobSynapses) { if (liveIds.has(jobId)) continue; try { entry.synapse.complete(); } catch {} setTimeout(() => { try { entry.synapse.destroy(); } catch {} }, 800); _jobSynapses.delete(jobId); } // Group into foldable sections: "Active" (in-progress) and "Past research" // (everything done — this session + library). Each has a clickable title // that collapses its body. Collapsed state persists across re-renders via // the module-level _collapsedSections set. const _addSection = (key, title, arr) => { if (!arr.length) return; const collapsed = _collapsedSections.has(key); const sec = document.createElement('div'); sec.className = 'research-section' + (collapsed ? ' collapsed' : ''); const header = document.createElement('div'); header.className = 'research-section-header'; // Status dot on the right (visible even when folded): // • Active = pulsing accent glow (work in progress) // • any failed/cancelled job in Active = solid red // • Past (done) = solid green (success) let dotColor, dotPulse = false; if (key === 'active') { const failed = arr.some(j => j.status === 'error' || j.status === 'cancelled'); if (failed) { dotColor = '#f44336'; } else { dotColor = 'var(--accent, var(--red))'; dotPulse = true; } } else { dotColor = 'var(--color-success)'; } // Both sections carry a "Clear all" button in the header (cookbook-running // section style); it clears all research and must not toggle the fold. const clearAllHtml = ''; header.innerHTML = '' + title + '' + '' + arr.length + ' research' + '' + clearAllHtml + '' + '' + ''; header.addEventListener('click', () => { const nowCollapsed = sec.classList.toggle('collapsed'); if (nowCollapsed) _collapsedSections.add(key); else _collapsedSections.delete(key); }); header.querySelector('.research-section-clear')?.addEventListener('click', (e) => { e.stopPropagation(); // Gracefully fade + collapse the whole section block(s) out, then clear. container.querySelectorAll('.research-section').forEach(s => { s.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; s.style.opacity = '0'; s.style.transform = 'translateX(-10px)'; }); setTimeout(() => jobs.clearAll(), 320); }); const body = document.createElement('div'); body.className = 'research-section-body'; // Hint inside the "Past research" header (second line, styled like the main // Research description) — past research is kept in the Library's Research tab. if (key === 'past') { const hint = document.createElement('div'); hint.className = 'memory-desc doclib-desc research-library-hint'; hint.innerHTML = 'All past research found in '; hint.querySelector('.research-library-link').addEventListener('click', (e) => { e.stopPropagation(); // Close the research panel first so the Library opens ABOVE it on mobile // (otherwise it stacks under the full-screen panel). closePanel(); if (window.documentModule && window.documentModule.openLibrary) { window.documentModule.openLibrary({ tab: 'research' }); } }); header.appendChild(hint); } arr.forEach(j => body.appendChild(_buildJobCard(j))); sec.appendChild(header); sec.appendChild(body); container.appendChild(sec); }; // ("Clear all" lives inside the Past research section header — see _addSection.) _addSection('active', 'Active', active); _addSection('past', 'Past research', recentDone.concat(past)); } /** Pick parallel vs sequential as a small popover anchored to the * Start-All button. Drops down by default; flips to drop-up if there * isn't enough room below the button. Outside-click / Esc dismiss. */ function _promptParallelOrSequential(count, anchorBtn) { // Strip any prior instance so a second click closes-then-reopens cleanly. const existing = document.getElementById('research-run-mode-popover'); if (existing) { existing.remove(); return; } if (!anchorBtn) return; const rect = anchorBtn.getBoundingClientRect(); const pop = document.createElement('div'); pop.id = 'research-run-mode-popover'; pop.className = 'research-run-mode-popover'; // Same parallel / sequential glyphs the model-comparison picker uses. const ICON_PARALLEL = ''; const ICON_SEQUENTIAL = ''; pop.innerHTML = '' + ''; document.body.appendChild(pop); // Position: prefer dropping down from the button's bottom-right corner. // If there isn't enough room below the viewport, flip to drop-up above. const popHeight = pop.offsetHeight; const margin = 6; const spaceBelow = window.innerHeight - rect.bottom; const goUp = spaceBelow < popHeight + margin && rect.top > popHeight + margin; const top = goUp ? (rect.top - popHeight - margin) : (rect.bottom + margin); // Right-align to the button so the menu doesn't extend off-screen on the right const right = Math.max(8, window.innerWidth - rect.right); pop.style.top = `${Math.round(top)}px`; pop.style.right = `${Math.round(right)}px`; pop.classList.add(goUp ? 'rrm-up' : 'rrm-down'); const close = () => { pop.remove(); document.removeEventListener('click', onDocClick, true); document.removeEventListener('keydown', onKey, true); }; const onDocClick = (e) => { if (pop.contains(e.target) || e.target === anchorBtn) return; close(); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; setTimeout(() => { document.addEventListener('click', onDocClick, true); document.addEventListener('keydown', onKey, true); }, 0); pop.querySelectorAll('.research-run-mode-row').forEach(b => { b.addEventListener('click', () => { const mode = b.dataset.mode; close(); if (mode === 'parallel') jobs.startAllQueued(); else jobs.startAllQueuedSequential(); }); }); } function _buildJobCard(job) { const card = document.createElement('div'); card.className = `research-job-card ${job.status}${job._fromLibrary ? ' from-library' : ''}`; card.dataset.jobId = job.id; if (job.category) card.dataset.category = job.category; const elapsed = jobs.formatElapsed(job.elapsed || 0); const isExpanded = _expandedJobId === job.id; const modelTag = (job.modelName || job.settings?._modelName) ? `${_esc(job.modelName || job.settings._modelName)}` : ''; if (job.status === 'queued') { const rounds = job.settings?.max_rounds; const roundsLabel = !rounds ? 'Auto rounds' : `${rounds} rounds`; const epName = job.settings?._endpointName || ''; const mName = job.settings?._modelName || ''; const meta = [mName, epName, roundsLabel].filter(Boolean).join(' -- '); card.innerHTML = `
${_esc(job.query)}${job.category ? `${_esc(job.category)}` : ""}
${_esc(meta)}
`; card.querySelector('[data-action="start"]').addEventListener('click', (e) => { e.stopPropagation(); jobs.startQueued(job.id); }); card.querySelector('[data-action="edit"]').addEventListener('click', (e) => { e.stopPropagation(); _editJob(job); }); card.querySelector('[data-action="remove"]').addEventListener('click', (e) => { e.stopPropagation(); jobs.removeJob(job.id); }); } else if (job.status === 'running') { // Auto mode (max_rounds=0/undefined) — show round number without total, // and base the progress bar on a heuristic cap of 8 rounds. const userMaxR = job.settings?.max_rounds || 0; const phaseMaxR = userMaxR || 0; // 0 = formatPhase shows "Round X" without total const phase = jobs.formatPhase(job.progress, phaseMaxR); const round = job.progress?.round || 0; const barCap = userMaxR || 8; const pct = Math.min(100, Math.round((round / barCap) * 100)); card.innerHTML = `
${_esc(job.query)}${job.category ? `${_esc(job.category)}` : ""} ${modelTag} ${elapsed}
${phase}
`; card.querySelector('.research-job-cancel').addEventListener('click', (e) => { e.stopPropagation(); jobs.cancelJob(job.id); }); card.querySelector('.research-synapse-toggle')?.addEventListener('click', (e) => { e.stopPropagation(); _toggleSynapseMinimized(); }); // Click anywhere on the header (title/model/time) toggles the visualization // too — the cancel/synapse buttons stopPropagation so they keep their own. const _runHdr = card.querySelector('.research-job-header'); if (_runHdr) { _runHdr.style.cursor = 'pointer'; _runHdr.addEventListener('click', () => _toggleSynapseMinimized()); } // Attach (or re-attach) the live synapse visualization. Created once per // job so animations/state persist across the _renderJobs() rebuilds that // fire on every progress event. const host = card.querySelector('.research-job-synapse-host'); let entry = _jobSynapses.get(job.id); if (!entry) { const synapse = createResearchSynapse(host, { query: job.query || '', startedAt: job.startedAt || (Date.now() - (job.elapsed || 0) * 1000), compact: true, }); entry = { synapse, status: 'running' }; _jobSynapses.set(job.id, entry); } else { // Move the existing element into the freshly-rendered host host.appendChild(entry.synapse.element); } // Push the current progress state if (job.progress) { entry.synapse.setPhase(job.progress.phase, job.progress); if (typeof job.progress.round === 'number') entry.synapse.setRound(job.progress.round); if (typeof job.progress.total_sources === 'number') entry.synapse.setSourceCount(job.progress.total_sources); } } else if (job.status === 'done') { // Library-loaded jobs have sources=null but pre-set sourceCount; fresh jobs // populate sources directly. Prefer the pre-set count if present. const srcCount = job.sources?.length ?? job.sourceCount ?? 0; // 0 sources = the research couldn't gather/extract anything — flag it. const failed = srcCount === 0; if (failed) card.classList.add('research-job-failed'); const doneBadge = failed ? `${_cancelIcon} no results` : (job.category ? `${_esc(job.category)}` : `standard`); const failNote = failed ? `
Couldn't extract anything — try rephrasing the question, or switch the search engine in Settings.
` : ''; card.innerHTML = `
${_esc(job.query)}${doneBadge} ${modelTag} ${elapsed} -- ${srcCount} sources
${failNote}
${isExpanded ? `
${_renderResult(job)}
` : ''} `; // Clicking anywhere on the card (except the action buttons, which // stopPropagation) opens the visual report — same as the Visual Report btn. card.style.cursor = 'pointer'; card.addEventListener('click', () => { window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank'); }); card.querySelector('[data-action="copy"]').addEventListener('click', async (e) => { e.stopPropagation(); const btn = e.currentTarget; // capture before await — currentTarget becomes null after if (!job.result) await _ensureResult(job); _copyResult(job, btn); }); card.querySelector('[data-action="report"]').addEventListener('click', (e) => { e.stopPropagation(); window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank'); }); card.querySelector('[data-action="chat"]').addEventListener('click', (e) => { e.stopPropagation(); _chatAboutResearch(job.id, e.currentTarget); }); card.querySelector('[data-action="delete"]').addEventListener('click', async (e) => { e.stopPropagation(); if (window.styledConfirm) { const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true }); if (!ok) return; } try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {} _animateOutThenRemove(card, () => jobs.removeJob(job.id)); }); card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => { e.stopPropagation(); _animateOutThenRemove(card, () => jobs.removeJob(job.id)); }); } else { const errMsg = job.errorMsg ? `
${_esc(job.errorMsg)}
` : ''; card.innerHTML = `
${_esc(job.query)}${job.category ? `${_esc(job.category)}` : ""} ${job.status}
${errMsg}
`; card.querySelector('[data-action="retry"]').addEventListener('click', (e) => { e.stopPropagation(); jobs.retryJob(job.id); }); card.querySelector('[data-action="edit"]').addEventListener('click', (e) => { e.stopPropagation(); _editJob(job); }); card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => { e.stopPropagation(); jobs.removeJob(job.id); }); } return card; } const _CAT_ICONS = { product: '', comparison: '', howto: '', landscape: '', factcheck: '', }; const _CAT_LABELS = { product: 'Product', comparison: 'Comparison', howto: 'How-to Guide', landscape: 'Landscape', factcheck: 'Fact-check', }; function _renderResult(job) { if (!job.result) return '
Loading result...
'; const cat = job.category || ''; const catIcon = _CAT_ICONS[cat] || ''; const catLabel = _CAT_LABELS[cat] || ''; let html = ''; // Category hero banner — only for completed, known-category results if (cat && catIcon) { html += `
${catIcon}
${catLabel}
${_esc(job.query)}
`; } if (job.sources?.length) { html += '
'; for (const s of job.sources.slice(0, 10)) { const title = _esc(s.title || s.url || ''); const url = _esc(s.url || ''); html += `${title}`; } if (job.sources.length > 10) html += `+${job.sources.length - 10} more`; html += '
'; } const bodyCls = `research-job-report-body${cat ? ' research-body-' + cat : ''}`; if (_markdownModule) { html += `
${_markdownModule.renderContent(job.result)}
`; } else { html += `
${_esc(job.result)}
`; } return html; } async function _ensureResult(job) { if (job.result) return; try { const res = await fetch(`${_apiBase}/api/research/result-peek/${job.id}`, { method: 'POST', credentials: 'same-origin', }); if (!res.ok) return; const d = await res.json(); job.result = d.result; job.sources = d.sources; job.findings = d.raw_findings; } catch {} } async function _copyResult(job, btn) { if (!job.result) return; let text = `# ${job.query}\n\n${job.result}`; if (job.findings?.length) { text += '\n\n---\n## Raw Findings\n'; for (const f of job.findings) { text += `\n### ${f.title || 'Untitled'}\nSource: ${f.url || ''}\n${f.summary || ''}\n`; } } if (job.sources?.length) { const srcList = job.sources.map(s => `- [${s.title || s.url}](${s.url})`).join('\n'); text += `\n\n---\n## Sources\n${srcList}`; } let ok = false; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); ok = true; } } catch {} if (!ok) { // Fallback for non-secure contexts (HTTP self-host) where navigator.clipboard // is unavailable. The textarea must be in-viewport and focusable for Firefox // Android / iOS Safari to allow execCommand('copy'). const ta = document.createElement('textarea'); ta.value = text; ta.readOnly = false; ta.contentEditable = 'true'; ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;'; document.body.appendChild(ta); ta.focus(); ta.select(); try { ta.setSelectionRange(0, text.length); } catch {} try { const sel = window.getSelection(); if (sel && (!sel.rangeCount || sel.isCollapsed)) { const range = document.createRange(); range.selectNodeContents(ta); sel.removeAllRanges(); sel.addRange(range); ta.setSelectionRange(0, text.length); } } catch {} try { ok = document.execCommand('copy'); } catch {} ta.remove(); } if (btn) { const orig = btn.innerHTML; if (ok) { btn.innerHTML = ``; btn.classList.add('research-job-action-copied'); setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('research-job-action-copied'); }, 2000); } else { btn.innerHTML = `${_cancelIcon} Failed`; setTimeout(() => { btn.innerHTML = orig; }, 2000); } } } // ── Chat about this research (server-side spinoff) ── async function _chatAboutResearch(researchId, btn) { if (!researchId) return; const origLabel = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = `${_chatIcon} Creating…`; } try { const res = await fetch(`${_apiBase}/api/research/spinoff/${researchId}`, { method: 'POST', credentials: 'same-origin', }); if (!res.ok) { let detail = ''; try { detail = (await res.json()).detail || ''; } catch {} throw new Error(detail || `HTTP ${res.status}`); } const payload = await res.json(); if (_sessionModule && _sessionModule.selectSession && payload.session_id) { if (_sessionModule.loadSessions) await _sessionModule.loadSessions().catch(() => {}); await _sessionModule.selectSession(payload.session_id); closePanel(); } else if (payload.session_id) { window.location.hash = '#' + payload.session_id; window.location.reload(); } else { // 200 OK but no session_id — server contract violation. Don't leave // the button stuck on 'Creating…'; surface the failure instead. throw new Error('Server returned no session id'); } } catch (e) { if (btn) { btn.disabled = false; btn.innerHTML = origLabel; } alert('Could not start follow-up chat: ' + e.message); } } function _esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }