Research
Multi-step web research with an LLM-in-the-loop agent
All past research found in
`;
}
/** 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 = 'Couldn't extract anything — try rephrasing the question, or switch the search engine in Settings.
`
: '';
card.innerHTML = `
${failNote}
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 += `
';
for (const s of job.sources.slice(0, 10)) {
const title = _esc(s.title || s.url || '');
const url = _safeSourceHref(s.url);
html += url
? `
${title}`
: `
${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 += `