/** * Deep Research side panel — open/close, form, job rendering, library. */ import * as jobs from './jobs.js?v=20260910researcherrorpersist1'; import themeModule from '../theme.js?v=20260909effectspeed1'; import createResearchSynapse from '../researchSynapse.js?v=20260910roundlabels2'; import spinnerModule from '../spinner.js'; import { sortModelIds } from '../modelSort.js'; import { searchProviderLogo } from '../searchProviderIcons.js'; import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from '../actionMenuOrder.js'; import { bindMenuDismiss } from '../escMenuStack.js'; // Rotating research textarea placeholders — pick one at random each // time the panel is rendered so the example keeps feeling fresh. const _RESEARCH_HINTS = [ "e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him", "e.g. Compare Rust and Go for building a high-throughput web API in 2026", "e.g. Fact-check whether honey actually never spoils", "e.g. How to roast a duck so the skin stays crispy", "e.g. The collapse of Bronze Age civilizations — leading theories and the evidence behind each", "e.g. Best M.2 NVMe SSDs under $200 for a home AI workstation", "e.g. Why do cats knead with their paws? Cover the leading behavioural explanations", "e.g. Side effects and benefits of long-term creatine supplementation", "e.g. How does end-to-end encryption work in Signal, step by step", "e.g. The history of the printing press in East Asia, 700 CE → 1600 CE", ]; function _pickResearchHint() { const i = Math.floor(Math.random() * _RESEARCH_HINTS.length); // Escape double-quotes so we can safely splice into a placeholder="…" attribute. return _RESEARCH_HINTS[i].replace(/"/g, '"'); } // jobId -> { synapse, status } — survives across _renderJobs() rebuilds so // the SVG keeps its accumulated nodes/edges between progress events. const _jobSynapses = new Map(); // Running cards rebuild whenever research progress changes. Keep a per-job // collapse choice outside the DOM so a user can inspect another job without // the next progress event reopening it. const _collapsedActiveJobIds = new Set(); const _vizCollapseIcon = ''; const _vizExpandIcon = ''; let _open = false; let _onDocKeydown = null; let _apiBase = ''; let _endpoints = []; let _markdownModule = null; let _sessionModule = null; let _settingsCollapsed = false; let _researchTab = 'research'; let _historySearch = ''; let _historySort = 'recent'; let _historyFilter = 'all'; let _historySelectMode = false; const _historySelectedIds = new Set(); let _visibleHistoryIds = []; let _historyCascadePending = false; const _researchPickers = new Map(); let _researchPickerCleanup = []; const _SETTINGS_KEY = 'odysseus-research-settings'; const _COLLAPSE_KEY = 'odysseus-research-settings-collapsed'; try { _settingsCollapsed = localStorage.getItem(_COLLAPSE_KEY) === '1'; } catch {} function _playHistoryCascade() { const list = document.getElementById('research-past-list'); if (!list?.querySelector('.research-job-card')) { _historyCascadePending = true; return; } _historyCascadePending = false; list.classList.remove('doclib-just-opened'); void list.offsetWidth; list.classList.add('doclib-just-opened'); setTimeout(() => list.classList.remove('doclib-just-opened'), 900); } function _saveSettingsToStorage() { try { 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: document.getElementById('research-category')?.value || '', })); } 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; // Show the full round label (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 = `Round ${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 _magnifyIcon = ''; const _researchIcon = ''; const _closeIcon = ''; const _playIcon = ''; const _selectIcon = SELECT_MENU_ICON; const _cancelIcon = ''; const _trashIcon = ''; const _externalIcon = ''; const _copyIcon = ''; const _retryIcon = ''; const _chevronIcon = ''; const _historyIcon = ''; const _editIcon = ''; const _chatIcon = ''; const _moreIcon = ''; 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; _researchTab = 'research'; 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'; // Use the same stable desktop frame as Memory/Skills. 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 gets a definite height from the first paint. Leaving // this content-sized made endpoint/model hydration grow the pane after it // opened, which is especially jarring beside an already-open document. 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(560px, 90vw);height:78vh;max-height:78vh;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(); const openPicker = document.querySelector('.research-picker.open'); if (openPicker) { _closeResearchPickers(); openPicker.querySelector('.research-picker-btn')?.focus(); return; } 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(); jobs.refreshLibrary?.({ force: true }); 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) { // Deep links must reveal the containing tab before scrolling. The // composer tab is the default, so an existing card can still be hidden. const tab = card.closest('[data-research-panel]')?.dataset.researchPanel; if (tab) _setResearchTab(tab); 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; } _researchPickerCleanup.forEach(cleanup => cleanup()); _researchPickerCleanup = []; _researchPickers.clear(); 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() { const sel = document.getElementById('research-category'); if (sel) { sel.value = ''; _refreshResearchPicker(sel.id); } } 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('[data-research-tab]').forEach((tab) => { tab.addEventListener('click', () => _setResearchTab(tab.dataset.researchTab)); }); pane.querySelector('#research-history-search')?.addEventListener('input', (e) => { _historySearch = e.currentTarget.value; _renderJobs(); }); pane.querySelector('#research-history-sort')?.addEventListener('change', (e) => { _historySort = e.currentTarget.value; _renderJobs(); }); pane.querySelector('#research-history-select-btn')?.addEventListener('click', () => { _historySelectMode ? _exitHistorySelectMode() : _enterHistorySelectMode(); }); pane.querySelector('#research-history-select-all')?.addEventListener('change', (e) => { if (e.currentTarget.checked) _visibleHistoryIds.forEach(id => _historySelectedIds.add(id)); else _visibleHistoryIds.forEach(id => _historySelectedIds.delete(id)); _updateHistoryBulkBar(); _renderJobs(); }); pane.querySelector('#research-history-bulk-cancel')?.addEventListener('click', _exitHistorySelectMode); pane.querySelector('#research-history-bulk-delete')?.addEventListener('click', _bulkDeleteHistory); pane.querySelector('#research-history-clear')?.addEventListener('click', (e) => { e.stopPropagation(); _clearHistory(); }); 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)); const modelSelect = pane.querySelector('#research-model'); modelSelect.addEventListener('change', () => { // The model menu is deliberately cross-endpoint. Selecting a model must // therefore carry its owning endpoint along with it instead of sending a // model name to whichever endpoint happened to be selected before. const selected = modelSelect.options[modelSelect.selectedIndex]; const endpointId = selected?.dataset.endpointId; if (endpointId && endpointSelect.value !== endpointId) { endpointSelect.value = endpointId; _refreshResearchPicker(endpointSelect.id); } }); _setupResearchPickers(pane); _renderJobs(); } function _setResearchTab(tab) { const pane = document.getElementById('research-pane'); if (!pane) return; const activeTab = pane.querySelector('[data-research-tab="active"]'); if (tab === 'active' && activeTab?.hidden) tab = 'history'; if (!['research', 'active', 'history'].includes(tab)) tab = 'research'; const enteringHistory = tab === 'history' && _researchTab !== 'history'; _researchTab = tab; pane.classList.toggle('research-results-view', tab !== 'research'); pane.querySelectorAll('[data-research-tab]').forEach((button) => { const selected = button.dataset.researchTab === tab; button.classList.toggle('active', selected); button.setAttribute('aria-selected', selected ? 'true' : 'false'); }); pane.querySelectorAll('[data-research-panel]').forEach((panel) => { const selected = panel.dataset.researchPanel === tab; panel.classList.toggle('hidden', !selected); panel.hidden = !selected; }); if (enteringHistory) _playHistoryCascade(); } function _syncResearchTabs(allJobs) { const active = allJobs.filter(j => j.status === 'queued' || j.status === 'running'); const history = allJobs.length - active.length; const activeTab = document.querySelector('[data-research-tab="active"]'); const activeCount = document.getElementById('research-active-count'); const historyCount = document.getElementById('research-history-count'); if (activeTab) { activeTab.hidden = active.length === 0; activeTab.setAttribute('aria-hidden', active.length ? 'false' : 'true'); } if (activeCount) activeCount.textContent = active.length ? active.length : ''; if (historyCount) historyCount.textContent = history ? history : ''; if (!active.length && _researchTab === 'active') _researchTab = 'history'; _setResearchTab(_researchTab); } function _readSettings() { const category = document.getElementById('research-category')?.value || 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()); _setResearchTab('active'); 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 || ''; const catSel = document.getElementById('research-category'); if (catSel) catSel.value = 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; _populateModels(s.endpoint_id); } const mEl = document.getElementById('research-model'); if (mEl && s.model) mEl.value = s.model; _syncAllResearchPickers(); // 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 is only for an empty compose field. A freshly typed query // always launches immediately, even when older queued jobs exist. const queuedCount = jobs.getJobs().filter(j => j.status === 'queued').length; if (!query && queuedCount > 1) { _resetCategoryToAuto(); if (window.innerWidth <= 768) _dismissKeyboard(queryEl); const total = jobs.getJobs().filter(j => j.status === 'queued').length; _setResearchTab('active'); _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(); _setResearchTab('active'); _resetCategoryToAuto(); if (_mobile) _dismissKeyboard(queryEl); return; } _saveSettingsToStorage(); const settings = _readSettings(); queryEl.value = ''; // startJob adds the job synchronously before its first await. Switch after // that call so the Active tab is visible and _setResearchTab does not // redirect to History because it still appears hidden. const startPromise = jobs.startJob(query, settings); _setResearchTab('active'); // Mobile: drop the keyboard after sending; desktop: keep focus for fast follow-ups. if (_mobile) _dismissKeyboard(queryEl); else queryEl.focus(); _resetCategoryToAuto(); startPromise.then((job) => { // A rejected launch used to be rendered as a finished history item before // the user could read why it failed. Keep immediate request failures in // the compose view and preserve the query for a corrected retry. if (job?.status !== 'error') return; const detail = job.errorMsg || 'Unable to start research.'; jobs.removeJob(job.id); queryEl.value = query; _setResearchTab('research'); if (typeof uiModule !== 'undefined' && uiModule?.showError) { uiModule.showError(`Research did not start: ${detail}`); } }).catch((e) => { queryEl.value = query; _setResearchTab('research'); if (typeof uiModule !== 'undefined' && uiModule?.showError) { uiModule.showError(`Research did not start: ${e?.message || 'Unable to start research.'}`); } }); } async function _restoreSavedSettings() { const saved = _loadSettingsFromStorage() || {}; if (saved.category !== undefined) { const catSel = document.getElementById('research-category'); if (catSel) catSel.value = 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; // The compose panel must start from the Deep Research default configured in // Settings. Previously it only restored an old local panel selection, so a // fresh panel stayed at "Default" and its model picker had no models. let configured = {}; if (!saved.endpoint_id) { try { const response = await fetch(`${_apiBase}/api/auth/settings`, { credentials: 'same-origin' }); if (response.ok) configured = await response.json() || {}; } catch (_) { /* The endpoint list remains usable without this preference. */ } } const ep = document.getElementById('research-endpoint'); const endpointId = saved.endpoint_id || configured.research_endpoint_id || ''; const modelId = saved.model || configured.research_model || ''; if (ep && endpointId && Array.from(ep.options).some(option => option.value === endpointId)) { ep.value = endpointId; _populateModels(endpointId); const model = document.getElementById('research-model'); if (model && modelId && Array.from(model.options).some(option => option.value === modelId)) { model.value = modelId; } } _syncAllResearchPickers(); } 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 && String(e.model_type || 'llm').toLowerCase() === 'llm').forEach(ep => { const opt = document.createElement('option'); opt.value = ep.id; opt.textContent = ep.name || ep.base_url; sel.appendChild(opt); }); // Populate the model menu immediately. It should remain useful even when // the optional Settings default has not loaded yet. _populateModels(); _refreshResearchPicker(sel.id); } catch {} } function _populateModels(endpointId = '') { const sel = document.getElementById('research-model'); if (!sel) return; const selectedModel = sel.value; sel.innerHTML = ''; // A model is the useful choice here, not an endpoint. Keep every enabled // LLM endpoint in one menu and annotate models with their API name. The // selected option stores its endpoint id, which the change handler above // applies before a research job starts. const endpoints = _endpoints.filter(ep => ( ep.is_enabled && String(ep.model_type || 'llm').toLowerCase() === 'llm' )); const seenModels = new Set(); endpoints.forEach(ep => { sortModelIds(Array.isArray(ep.models) ? ep.models : []).forEach(model => { // The custom picker selects by option value. Keep that value unique so // duplicate model ids advertised by two APIs cannot pick the wrong API. if (seenModels.has(model)) return; seenModels.add(model); const opt = document.createElement('option'); opt.value = model; opt.dataset.endpointId = ep.id; opt.textContent = endpoints.length > 1 ? `${model} · ${ep.name || ep.base_url}` : model; sel.appendChild(opt); }); }); if (selectedModel && Array.from(sel.options).some(option => option.value === selectedModel)) { sel.value = selectedModel; } _refreshResearchPicker(sel.id); } // ── Job rendering ── const _HISTORY_FILTERS = [ ['all', 'All'], ['', 'Standard'], ['product', 'Product'], ['comparison', 'Comparison'], ['howto', 'How-to'], ['factcheck', 'Fact-check'], ['landscape', 'Landscape'], ]; function _historyCategory(job) { return job.category || ''; } function _renderHistoryFilters(items) { const el = document.getElementById('research-history-filters'); if (!el) return; const counts = new Map(); items.forEach(job => counts.set(_historyCategory(job), (counts.get(_historyCategory(job)) || 0) + 1)); el.innerHTML = _HISTORY_FILTERS .filter(([value]) => value === 'all' || counts.has(value)) .map(([value, label]) => { const active = _historyFilter === value ? ' active' : ''; const count = value === 'all' ? items.length : (counts.get(value) || 0); return ``; }).join(''); el.querySelectorAll('[data-research-filter]').forEach(button => { button.addEventListener('click', () => { _historyFilter = button.dataset.researchFilter; _renderJobs(); }); }); } function _getVisibleHistory(items) { const search = _historySearch.trim().toLowerCase(); let visible = items.filter(job => { if (_historyFilter !== 'all' && _historyCategory(job) !== _historyFilter) return false; if (!search) return true; const haystack = [job.query, job.category, _CAT_LABELS[job.category] || '', job.status] .filter(Boolean).join(' ').toLowerCase(); return haystack.includes(search); }); const time = job => Number(job.startedAt) || 0; if (_historySort === 'oldest') visible.sort((a, b) => time(a) - time(b)); else if (_historySort === 'sources') visible.sort((a, b) => (b.sources?.length ?? b.sourceCount ?? 0) - (a.sources?.length ?? a.sourceCount ?? 0) || time(b) - time(a)); else if (_historySort === 'alpha') visible.sort((a, b) => (a.query || '').localeCompare(b.query || '')); else visible.sort((a, b) => time(b) - time(a)); return visible; } function _updateHistoryBulkBar() { const bar = document.getElementById('research-history-bulk'); const button = document.getElementById('research-history-select-btn'); const count = document.getElementById('research-history-selected-count'); const deleteButton = document.getElementById('research-history-bulk-delete'); const all = document.getElementById('research-history-select-all'); if (bar) bar.classList.toggle('hidden', !_historySelectMode); if (button) { button.classList.toggle('active', _historySelectMode); button.innerHTML = _historySelectMode ? `${_cancelIcon} Cancel` : `${_selectIcon} Select`; } if (count) count.textContent = `${_historySelectedIds.size} Selected`; if (deleteButton) deleteButton.disabled = _historySelectedIds.size === 0; if (all) all.checked = _visibleHistoryIds.length > 0 && _visibleHistoryIds.every(id => _historySelectedIds.has(id)); } function _enterHistorySelectMode() { _historySelectMode = true; _historySelectedIds.clear(); _updateHistoryBulkBar(); _renderJobs(); } function _exitHistorySelectMode() { _historySelectMode = false; _historySelectedIds.clear(); _updateHistoryBulkBar(); _renderJobs(); } async function _deleteHistoryJobs(ids) { const selected = new Set(ids); const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id)); await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => { try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {} })); selectedJobs.forEach(job => jobs.removeJob(job.id)); } async function _bulkDeleteHistory() { if (!_historySelectedIds.size) return; const ids = [..._historySelectedIds]; if (window.styledConfirm) { const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true }); if (!ok) return; } await _deleteHistoryJobs(ids); _exitHistorySelectMode(); } async function _clearHistory() { const history = jobs.getJobs().filter(job => job.status !== 'queued' && job.status !== 'running'); if (!history.length) return; if (window.styledConfirm) { const ok = await window.styledConfirm(`Clear ${history.length} research ${history.length === 1 ? 'report' : 'reports'} from history?`, { confirmText: 'Clear all', danger: true }); if (!ok) return; } await _deleteHistoryJobs(history.map(job => job.id)); } 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 allJobs = jobs.getJobs(); const activeList = document.getElementById('research-active-list'); const pastList = document.getElementById('research-past-list'); if (!activeList || !pastList) return; const active = allJobs.filter(j => j.status === 'queued' || j.status === 'running'); const past = allJobs.filter(j => j.status !== 'queued' && j.status !== 'running').reverse(); const visiblePast = _getVisibleHistory(past); _visibleHistoryIds = visiblePast.map(job => job.id); _renderHistoryFilters(past); _syncResearchTabs(allJobs); activeList.innerHTML = ''; pastList.innerHTML = ''; const statsEl = document.getElementById('research-stats'); if (statsEl) { statsEl.textContent = past.length + ' research'; } const historyHeadCount = document.getElementById('research-history-head-count'); if (historyHeadCount) historyHeadCount.textContent = `${visiblePast.length} of ${past.length}`; _historySelectedIds.forEach(id => { if (!past.some(job => job.id === id)) _historySelectedIds.delete(id); }); _updateHistoryBulkBar(); // 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; } // 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 of _collapsedActiveJobIds) { if (!liveIds.has(jobId)) _collapsedActiveJobIds.delete(jobId); } 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); } const appendCards = (list, items, emptyText) => { if (!items.length) { list.innerHTML = `
${emptyText}
`; return; } items.forEach(job => list.appendChild(_buildJobCard(job))); }; appendCards(activeList, active, 'No active research.'); appendCards(pastList, visiblePast, past.length ? 'No research matches your filters.' : 'No research history yet.'); if (_historyCascadePending && _researchTab === 'history') _playHistoryCascade(); } /** 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'); let unregister = () => {}; const close = () => { pop.remove(); unregister(); unregister = () => {}; }; unregister = bindMenuDismiss(pop, close, e => !(pop.contains(e.target) || e.target === anchorBtn)); 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'); const standardVariant = !job.category ? _researchVisualVariant(job) : null; const isHistoryCard = job.status !== 'queued' && job.status !== 'running'; card.className = `doclib-card memory-item research-job-card ${job.status}${job._fromLibrary ? ' from-library' : ''}${standardVariant !== null ? ` research-standard research-standard-v${standardVariant}` : ''}`; card.dataset.jobId = job.id; if (job.category) card.dataset.category = job.category; const elapsed = jobs.formatElapsed(job.elapsed || 0); 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 === -1 ? 'Explain only' : (!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)}
${_esc(meta)}
${_jobOverflowHTML([{ action: 'remove', icon: _cancelIcon, label: 'Remove from queue' }])}
`; const overflow = _wireJobOverflow(card); 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-menu-action="remove"]').addEventListener('click', (e) => { e.stopPropagation(); overflow.close(); 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 explainOnly = job.mode === 'explain' || job.settings?.max_rounds === -1; const pct = explainOnly ? 68 : Math.min(100, Math.round((round / barCap) * 100)); const hasLiveNavigationTrace = Array.isArray(job.navigation_trace) && job.navigation_trace.length; const hasLiveActionTrace = Array.isArray(job.action_trace) && job.action_trace.length; const liveTrace = hasLiveNavigationTrace || hasLiveActionTrace ? `
${_renderNavigationTrace(job.navigation_trace, job.action_trace)}
` : ''; const expanded = !_collapsedActiveJobIds.has(job.id); card.classList.toggle('research-active-expanded', expanded); card.innerHTML = `
${_esc(job.query)} ${modelTag} ${elapsed} ${_jobOverflowHTML([{ action: 'cancel', icon: _cancelIcon, label: 'Cancel research', danger: true }])}
${phase}
${liveTrace}
`; const overflow = _wireJobOverflow(card); card.querySelector('[data-menu-action="cancel"]').addEventListener('click', (e) => { e.stopPropagation(); overflow.close(); jobs.cancelJob(job.id); }); const header = card.querySelector('.research-active-card-header'); const toggleDetail = () => { const shouldExpand = _collapsedActiveJobIds.has(job.id); if (shouldExpand) _collapsedActiveJobIds.delete(job.id); else _collapsedActiveJobIds.add(job.id); const detail = card.querySelector('.research-active-detail'); card.classList.toggle('research-active-expanded', shouldExpand); detail.hidden = !shouldExpand; header.setAttribute('aria-expanded', shouldExpand ? 'true' : 'false'); header.setAttribute('title', shouldExpand ? 'Hide live research' : 'Show live research'); header.querySelector('.research-active-card-chevron').innerHTML = shouldExpand ? _vizCollapseIcon : _vizExpandIcon; }; header.addEventListener('click', (e) => { if (e.target.closest('button, .research-job-menu')) return; toggleDetail(); }); header.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); toggleDetail(); }); // 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 explainOnly = job.mode === 'explain' || job.settings?.max_rounds === -1; const failed = srcCount === 0 && !explainOnly; if (failed) card.classList.add('research-job-failed'); // "visual" describes how this report is presented, not its research // category. Keeping it beside every title made History noisy, so omit // only that label while retaining meaningful category/failure badges. const doneBadge = ''; const failNote = failed ? `
Couldn't extract, try again or change Settings.
` : ''; const thumbSource = (job.sources || []).find(s => s && (s.image || s.og_image)); const thumbUrl = job.thumbnail || thumbSource?.image || thumbSource?.og_image || ''; const thumbnail = thumbUrl ? `` : ''; card.innerHTML = `
${_esc(job.query)}${doneBadge} ${_jobOverflowHTML([ { action: 'copy', icon: _copyIcon, label: 'Copy report' }, { action: 'dismiss', icon: _cancelIcon, label: 'Hide from list' }, { action: 'delete', icon: _trashIcon, label: 'Delete from disk', danger: true }, ])}
${thumbnail}
${elapsed} · ${explainOnly ? 'model only' : `${srcCount} sources`}${failNote}
`; const thumbFrame = card.querySelector('.research-job-thumb-frame'); if (thumbFrame && thumbUrl) { thumbFrame.setAttribute('role', 'button'); thumbFrame.setAttribute('tabindex', '0'); thumbFrame.setAttribute('title', 'Open visual report'); const openReport = (e) => { e.preventDefault(); e.stopPropagation(); window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank'); }; thumbFrame.addEventListener('click', openReport); thumbFrame.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') openReport(e); }); } const overflow = _wireJobOverflow(card); card.querySelector('[data-menu-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); overflow.close(); }); 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-menu-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 {} overflow.close(); _animateOutThenRemove(card, () => jobs.removeJob(job.id)); }); card.querySelector('[data-menu-action="dismiss"]').addEventListener('click', (e) => { e.stopPropagation(); overflow.close(); _animateOutThenRemove(card, () => jobs.removeJob(job.id)); }); } else { const errMsg = job.errorMsg ? `${_esc(job.errorMsg)}` : ''; card.innerHTML = `
${_esc(job.query)} ${errMsg} ${_jobOverflowHTML([{ action: 'dismiss', icon: _cancelIcon, label: 'Hide from list' }])}
`; const overflow = _wireJobOverflow(card); card.querySelector('[data-action="retry"]').addEventListener('click', (e) => { e.stopPropagation(); jobs.retryJob(job.id); _setResearchTab('active'); }); card.querySelector('[data-action="edit"]').addEventListener('click', (e) => { e.stopPropagation(); _editJob(job); }); card.querySelector('[data-menu-action="dismiss"]').addEventListener('click', (e) => { e.stopPropagation(); overflow.close(); jobs.removeJob(job.id); }); } if (_historySelectMode && isHistoryCard) { const header = card.querySelector('.research-job-header'); if (header) { const label = document.createElement('label'); label.className = 'research-history-card-select'; label.title = 'Select research'; label.innerHTML = ``; // Card-level handlers must never consume an individual selection click. const stopCardInteraction = (e) => e.stopPropagation(); ['pointerdown', 'mousedown', 'click'].forEach(type => label.addEventListener(type, stopCardInteraction)); const checkbox = label.querySelector('input'); checkbox.addEventListener('click', stopCardInteraction); checkbox.addEventListener('change', (e) => { if (e.currentTarget.checked) _historySelectedIds.add(job.id); else _historySelectedIds.delete(job.id); _updateHistoryBulkBar(); }); header.prepend(label); card.classList.add('research-history-selectable'); card.addEventListener('click', (e) => { if (e.target.closest('button, a, input, label, .research-job-menu')) return; checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change', { bubbles: true })); }); } } 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', }; const _STANDARD_CAT_ICON = ''; function _jobOverflowHTML(items) { const orderedItems = orderActionMenuItems(items); const menuItems = orderedItems.map((item, index) => `${index > 0 && actionMenuRank(item) >= 700 && actionMenuRank(orderedItems[index - 1]) < 700 ? '' : ''}`).join(''); return `
`; } function _wireJobOverflow(card) { const wrap = card.querySelector('.research-job-overflow'); const button = wrap?.querySelector('.research-job-more'); const menu = wrap?.querySelector('.research-job-menu'); if (!wrap || !button || !menu) return { close() {} }; let unregister = () => {}; const close = () => { menu.hidden = true; menu.style.display = 'none'; wrap.classList.remove('open'); button.setAttribute('aria-expanded', 'false'); unregister(); unregister = () => {}; }; const open = () => { document.querySelectorAll('.research-job-overflow.open').forEach(other => { if (other !== wrap) other.querySelector('.research-job-more')?.click(); }); menu.hidden = false; menu.style.display = 'block'; wrap.classList.add('open'); button.setAttribute('aria-expanded', 'true'); unregister = bindMenuDismiss(menu, () => { close(); button.focus(); }, e => !wrap.contains(e.target)); menu.querySelector('button')?.focus(); }; button.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); menu.hidden ? open() : close(); }); menu.addEventListener('click', e => e.stopPropagation()); return { close }; } function _researchPickerIcon(selectId, value) { if (selectId === 'research-category') return value ? (_CAT_ICONS[value] || '') : _STANDARD_CAT_ICON; if (selectId === 'research-search-provider') return value ? searchProviderLogo(value) : _searchIcon; return ''; } function _closeResearchPickers(exceptId = '') { _researchPickers.forEach((picker, id) => { if (id !== exceptId) picker.close(); }); } function _refreshResearchPicker(selectId) { _researchPickers.get(selectId)?.refresh(); } function _syncAllResearchPickers() { _researchPickers.forEach(picker => picker.refresh()); } function _setupResearchPickers(pane) { const ids = [ 'research-rounds', 'research-category', 'research-search-provider', 'research-endpoint', 'research-model', ]; ids.forEach(id => { const select = pane.querySelector(`#${id}`); if (!select || select.dataset.researchPickerBound === '1') return; select.dataset.researchPickerBound = '1'; select.classList.add('research-native-select'); const picker = document.createElement('div'); picker.className = 'research-picker'; const menuId = `${id}-menu`; picker.innerHTML = ` `; select.insertAdjacentElement('afterend', picker); const button = picker.querySelector('.research-picker-btn'); const current = picker.querySelector('.research-picker-current'); const menu = picker.querySelector('.research-picker-menu'); let unregister = () => {}; const close = () => { menu.hidden = true; picker.classList.remove('open'); button.setAttribute('aria-expanded', 'false'); unregister(); unregister = () => {}; }; const focusItem = (offset) => { const items = Array.from(menu.querySelectorAll('.research-picker-option')); if (!items.length) return; const currentIndex = Math.max(0, items.indexOf(document.activeElement)); items[(currentIndex + offset + items.length) % items.length].focus(); }; const open = () => { _closeResearchPickers(id); menu.hidden = false; picker.classList.add('open'); button.setAttribute('aria-expanded', 'true'); unregister = bindMenuDismiss(menu, close, e => picker.contains(e.target)); menu.querySelector('.active')?.focus(); }; const selectValue = (value) => { if (select.value !== value) { select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); } refresh(); close(); button.focus(); }; const refresh = () => { const selected = select.options[select.selectedIndex] || select.options[0]; if (!selected) return; const icon = _researchPickerIcon(id, selected.value); current.innerHTML = `${icon ? `${icon}` : ''}${_esc(selected.textContent)}`; menu.innerHTML = Array.from(select.options).map(option => { const optionIcon = _researchPickerIcon(id, option.value); const active = option.value === select.value ? ' active' : ''; const safeValue = _esc(option.value).replace(/"/g, '"'); return ``; }).join(''); menu.querySelectorAll('.research-picker-option').forEach(optionButton => { optionButton.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); selectValue(optionButton.dataset.value || ''); }); optionButton.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(-1); } else if (e.key === 'Home') { e.preventDefault(); menu.querySelector('.research-picker-option')?.focus(); } else if (e.key === 'End') { e.preventDefault(); Array.from(menu.querySelectorAll('.research-picker-option')).pop()?.focus(); } else if (e.key === 'Escape') { e.preventDefault(); close(); button.focus(); } }); }); }; button.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); menu.hidden ? open() : close(); }); button.addEventListener('keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); } else if (e.key === 'Escape') { e.preventDefault(); close(); } }); select.addEventListener('change', refresh); const controller = { close, refresh }; _researchPickers.set(id, controller); refresh(); }); } function _researchVisualVariant(job) { // Color belongs to the report format, not to an individual report. return 0; } function _renderResult(job) { if (!job.result) return '
Loading result...
'; const cat = job.category || ''; const isStandard = !cat; const catIcon = _CAT_ICONS[cat] || (isStandard ? _STANDARD_CAT_ICON : ''); const catLabel = _CAT_LABELS[cat] || (isStandard ? 'Research' : ''); const heroVariant = isStandard ? ` research-hero-v${_researchVisualVariant(job)}` : ''; let html = ''; // Standard reports keep one stable identity; specialized formats use // their category palette. if (cat && catIcon) { html += `
${catIcon}
${catLabel}
${_esc(job.query)}
`; } else if (isStandard && 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 = _safeSourceHref(s.url); const badges = _researchSourceBadges(s); html += url ? `${title}${badges}` : `${title}${badges}`; } if (job.sources.length > 10) html += `+${job.sources.length - 10} more`; html += '
'; } if (job.source_state) { html += `
${_esc(job.source_state)}
`; } if (Array.isArray(job.navigation_trace) && job.navigation_trace.length) { html += `
${_renderNavigationTrace(job.navigation_trace, job.action_trace)}
`; } else if (Array.isArray(job.action_trace) && job.action_trace.length) { html += `
${_renderActionTrace(job.action_trace)}
`; } 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; job.analyzed_urls = d.analyzed_urls; job.source_state = d.source_state; job.source_coverage = d.source_coverage || {}; job.navigation_trace = d.navigation_trace; job.action_trace = d.action_trace; } catch {} } function _renderActionTrace(trace) { const rows = (Array.isArray(trace) ? trace : []).slice(-8).map((item) => { const round = Number.isFinite(Number(item.round)) ? `Round ${Number(item.round)}` : 'Plan'; const tool = _esc(item.tool || 'tool'); const target = _esc(item.query || item.url || ''); const skipped = item.status === 'skipped'; const label = skipped ? `${tool}: ${target}` : `${tool}: ${target}`; const requestedMeta = item.requested_by && item.requested_by !== item.tool ? `via ${_esc(item.requested_by)}` : ''; const meta = skipped ? `skipped${item.reason ? ` · ${_esc(item.reason)}` : ''}` : (item.source ? `${_esc(item.source)}${requestedMeta ? ` · ${requestedMeta}` : ''}` : (requestedMeta ? `${requestedMeta}` : '')); return `
${round}${label}${meta}
`; }).join(''); return rows; } function _renderNavigationTrace(trace, actionTrace = []) { const plan = _renderActionTrace(actionTrace); const navigation = Array.isArray(trace) ? trace : []; const rows = navigation.slice(-8).map((item) => { const tool = _esc(item.tool || 'tool'); const status = _esc(item.status || 'unknown'); const target = _esc(item.query || item.title || item.url || ''); const meta = []; if (Number.isFinite(Number(item.results))) meta.push(`${Number(item.results)} results`); if (item.source_kind) meta.push(_esc(item.source_kind)); if (Number.isFinite(Number(item.source_score))) meta.push(`${Number(item.source_score)}/100`); if (item.retrieval && item.retrieval !== 'fetch') meta.push(_esc(item.retrieval)); const metaHtml = meta.length ? `${meta.join(' · ')}` : ''; return `
${tool}${target}${status}${metaHtml}
`; }).join(''); const navigationHtml = rows ? `
Navigation
${rows}` : ''; return `${plan}${plan && navigationHtml ? '
' : ''}${navigationHtml}`; } 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 : ''; let whirlpool = null; if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.innerHTML = ''; try { whirlpool = spinnerModule.createWhirlpool(13); whirlpool.element.style.cssText += ';margin:0;'; btn.appendChild(whirlpool.element); } catch { btn.innerHTML = origLabel; } } 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 (whirlpool) whirlpool.destroy(); if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); 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; } function _safeSourceHref(raw) { try { const parsed = new URL(String(raw || '').trim(), window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href); } catch {} return ''; } function _researchSourceBadges(source) { const badges = []; const kind = String(source?.source_kind || '').trim(); const retrieval = String(source?.retrieval || '').trim(); const score = Number(source?.source_score); if (kind) badges.push(`${_esc(kind)}`); if (retrieval && retrieval !== 'fetch') badges.push(`${_esc(retrieval)}`); if (Number.isFinite(score)) badges.push(`${Math.max(0, Math.min(100, Math.round(score)))}`); return badges.length ? `${badges.join('')}` : ''; }