Files
dobetternorge-tools/assets/js/deep-research.js
T
daveadmin bffc714541 Add My Docs picker to deep-research, advocate, barnevernet, korrespond, citations
- PHP: Add docPickerSection (button + chips + hidden input) to all 5 tool pages
- JS: Send doc_ids in payload for deep-research, advocate, barnevernet, korrespond
- Backend: Inject selected corpus doc content into paste_text/narrative/notes via dbnToolsInjectDocContent
- Citations: Add upload zone (file → api/extract.php → textarea) + paste textarea with live Norwegian legal reference extraction (regex) + ref chips → title search; doc picker populates titleInput via MutationObserver

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:04:45 +02:00

774 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* deep-research.js — page-scoped UI for /deep-research.php */
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastResult = null;
let branchContext = null;
const SLICE_DEFS = [
{ id: 'family_core', label: 'Family Law Core' },
{ id: 'child_welfare', label: 'Child Welfare' },
{ id: 'echr', label: 'ECHR' },
{ id: 'hague', label: 'Hague Convention' },
{ id: 'norwegian_courts', label: 'Norwegian Courts' },
{ id: 'bufdir_guidance', label: 'Bufdir Guidance' },
{ id: 'broader_legal', label: 'Broader Legal Support' },
{ id: 'dbn_resources', label: 'DBN Resources' },
];
const STEP_LABELS = [
'Query interpretation',
'Query expansion',
'Slice resolution',
'Upload indexing',
'Retrieval',
'Synthesis',
'Citation confidence',
];
document.addEventListener('DOMContentLoaded', () => {
if (!document.body.dataset.activeTool || document.body.dataset.activeTool !== 'deep-research') return;
Object.assign(els, {
form: document.getElementById('deepResearchForm'),
input: document.getElementById('drInput'),
status: document.getElementById('drStatus'),
runButton: document.getElementById('drRunButton'),
results: document.getElementById('drResults'),
traceList: document.getElementById('traceList'),
slices: Array.from(document.querySelectorAll('.dr-slice')),
langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')),
engineRadios: Array.from(document.querySelectorAll('input[name="drEngine"]')),
subQ: document.getElementById('drSubQ'),
subQVal: document.getElementById('drSubQValue'),
chunkLimit: document.getElementById('drChunkLimit'),
chunkLimitVal: document.getElementById('drChunkLimitValue'),
sim: document.getElementById('drSim'),
simVal: document.getElementById('drSimValue'),
topK: document.getElementById('drTopK'),
topKVal: document.getElementById('drTopKValue'),
temp: document.getElementById('drTemp'),
tempVal: document.getElementById('drTempValue'),
uploadZone: document.getElementById('drUploadZone'),
uploadInput: document.getElementById('drUploadInput'),
uploadPrompt: document.getElementById('drUploadPrompt'),
uploadFileInfo: document.getElementById('drUploadFileInfo'),
uploadFileList: document.getElementById('drUploadFileList'),
uploadClear: document.getElementById('drUploadClear'),
modal: document.getElementById('drSourceModal'),
modalClose: document.getElementById('drSourceModalClose'),
modalTitle: document.getElementById('drSourceModalTitle'),
modalEyebrow: document.getElementById('drSourceModalEyebrow'),
modalMeta: document.getElementById('drSourceModalMeta'),
modalText: document.getElementById('drSourceModalText'),
branchPanel: document.getElementById('drBranchPanel'),
branchClear: document.getElementById('drBranchClear'),
branchOrigin: document.getElementById('drBranchOrigin'),
branchSummary: document.getElementById('drBranchSummary'),
branchNotes: document.getElementById('drBranchNotes'),
});
if (!els.form) return;
bindSlices();
bindLang();
bindRanges();
bindUpload();
bindModal();
bindBranch();
els.form.addEventListener('submit', onSubmit);
els.results.addEventListener('click', (e) => {
const btn = e.target.closest('.dr-branch-btn');
if (btn) branchFromSubQ(btn.dataset.question || '');
});
// Pre-render placeholder trace
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
});
function bindSlices() {
els.slices.forEach((btn) => {
btn.addEventListener('click', () => {
const isOn = btn.classList.toggle('is-on');
btn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
const badge = btn.querySelector('.dr-slice__badge');
if (badge) badge.textContent = isOn ? 'on' : 'off';
});
});
}
function bindLang() {
els.langButtons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.lang === lang);
b.addEventListener('click', () => {
els.langButtons.forEach((x) => x.classList.remove('is-active'));
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
});
});
}
function bindRanges() {
const pairs = [
[els.subQ, els.subQVal, (v) => v],
[els.chunkLimit, els.chunkLimitVal, (v) => v],
[els.sim, els.simVal, (v) => Number(v).toFixed(2)],
[els.topK, els.topKVal, (v) => v],
[els.temp, els.tempVal, (v) => Number(v).toFixed(2)],
];
pairs.forEach(([range, label, fmt]) => {
if (!range || !label) return;
const sync = () => { label.textContent = fmt(range.value); };
range.addEventListener('input', sync);
sync();
});
}
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 5);
if (uploadFiles.length + files.length > 5) {
setStatus('At most 5 files can be uploaded per request.', 'error');
return;
}
files.forEach((f) => {
if (f.size > 8 * 1024 * 1024) {
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${f.name} is not a supported file type.`, 'error');
return;
}
uploadFiles.push(f);
});
renderUploadList();
};
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drop');
onFiles(e.dataTransfer?.files);
});
els.uploadClear?.addEventListener('click', () => {
uploadFiles = [];
els.uploadInput.value = '';
renderUploadList();
});
}
function renderUploadList() {
if (!uploadFiles.length) {
els.uploadFileInfo.classList.add('is-hidden');
els.uploadPrompt.classList.remove('is-hidden');
return;
}
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
els.uploadFileList.innerHTML = uploadFiles.map((f, i) => {
const kb = (f.size / 1024).toFixed(0);
return `<li><span class="upload-filename">${escapeHtml(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).join('');
}
function bindModal() {
els.modalClose?.addEventListener('click', closeModal);
els.modal?.addEventListener('click', (e) => {
if (e.target === els.modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && els.modal && !els.modal.classList.contains('is-hidden')) closeModal();
});
}
function closeModal() {
els.modal?.classList.add('is-hidden');
}
function openModal(source) {
if (!source) return;
els.modalEyebrow.textContent = source.source_origin === 'upload' ? 'Uploaded file' : 'Corpus source';
els.modalTitle.textContent = source.title || 'Source';
const metaRows = [
['Number', `[${source.n}]`],
source.section ? ['Section', source.section] : null,
['Corpus / package', source.package_or_corpus || '—'],
source.authority_type ? ['Authority', source.authority_type] : null,
source.jurisdiction ? ['Jurisdiction', source.jurisdiction] : null,
source.similarity != null ? ['Similarity', String(source.similarity)] : null,
source.reranker_score != null ? ['Rerank score', String(source.reranker_score)] : null,
source.matched_sub_questions?.length ? ['Matched sub-Q', source.matched_sub_questions.join(', ')] : null,
].filter(Boolean);
els.modalMeta.innerHTML = '<dl>' + metaRows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('') + '</dl>';
const summary = source.summary || '';
const chunkText = source.chunk_text || source.excerpt || '';
const isUpload = source.source_origin === 'upload';
const hasDocId = source.document_id != null;
let html = summary
? `<div class="dr-modal-summary">${escapeHtml(summary)}</div>`
: `<div class="dr-modal-summary dr-modal-summary--empty"><em>Summary not yet generated — showing raw chunk below.</em></div>`;
if (chunkText) {
html += `<button class="dr-modal-chunk-toggle" type="button">Show matching chunk ▼</button>`;
html += `<div class="dr-modal-chunk-text is-hidden">${escapeHtml(chunkText)}</div>`;
}
if (!isUpload && hasDocId) {
html += `<button class="dr-modal-all-chunks" type="button" data-document-id="${source.document_id}">View all document chunks →</button>`;
html += `<div class="dr-modal-chunks-list"></div>`;
}
els.modalText.innerHTML = html;
const chunkToggle = els.modalText.querySelector('.dr-modal-chunk-toggle');
const chunkDiv = els.modalText.querySelector('.dr-modal-chunk-text');
chunkToggle?.addEventListener('click', () => {
const isHidden = chunkDiv.classList.toggle('is-hidden');
chunkToggle.textContent = isHidden ? 'Show matching chunk ▼' : 'Hide matching chunk ▲';
});
const allChunksBtn = els.modalText.querySelector('.dr-modal-all-chunks');
const chunksListDiv = els.modalText.querySelector('.dr-modal-chunks-list');
if (allChunksBtn && chunksListDiv) {
allChunksBtn.addEventListener('click', async () => {
allChunksBtn.disabled = true;
allChunksBtn.textContent = 'Loading…';
try {
const res = await fetch(`api/document-chunks.php?document_id=${source.document_id}`, { credentials: 'same-origin' });
const data = await res.json();
if (data.ok && data.chunks) {
chunksListDiv.innerHTML =
`<div class="dr-modal-chunks-head">${escapeHtml(data.document?.title || '')} · ${data.chunks.length} chunks</div>` +
data.chunks.map((c) => `<div class="dr-modal-chunk-item">
<span class="dr-modal-chunk-idx">#${c.chunk_index + 1}${c.section_title ? ' · ' + escapeHtml(c.section_title) : ''}</span>
<p class="dr-modal-chunk-preview">${escapeHtml(truncate(c.content, 300))}</p>
</div>`).join('');
allChunksBtn.remove();
} else {
allChunksBtn.textContent = 'Could not load chunks.';
allChunksBtn.disabled = false;
}
} catch (_) {
allChunksBtn.textContent = 'Error loading chunks.';
allChunksBtn.disabled = false;
}
});
}
els.modal.classList.remove('is-hidden');
}
function getSelectedSlices() {
const out = {};
SLICE_DEFS.forEach((s) => {
const btn = els.slices.find((b) => b.dataset.slice === s.id);
out[s.id] = !!(btn && btn.classList.contains('is-on'));
});
return out;
}
function getEngine() {
const checked = els.engineRadios.find((r) => r.checked);
return checked ? checked.value : 'azure_mini';
}
function getControls() {
return {
sub_q_count: parseInt(els.subQ.value, 10),
chunk_limit: parseInt(els.chunkLimit.value, 10),
similarity_threshold: parseFloat(els.sim.value),
reranker_top_k: parseInt(els.topK.value, 10),
temperature: parseFloat(els.temp.value),
};
}
async function onSubmit(e) {
e.preventDefault();
const query = (els.input.value || '').trim();
if (!query && uploadFiles.length === 0) {
setStatus('Type a question or upload a file before running deep research.', 'error');
return;
}
const slices = getSelectedSlices();
if (!Object.values(slices).some(Boolean)) {
setStatus('Enable at least one corpus slice.', 'error');
return;
}
const engine = getEngine();
const expectedDuration = engine === 'azure_full'
? '60180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '3090 seconds on GPU' : '1545 seconds with Azure gpt-4o-mini');
setStatus(`Running deep research… (${expectedDuration})`, 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>The agent is expanding your question and researching the corpus. Live progress in the right-hand panel. Expect ${expectedDuration}.</p></div>`;
// Initialise the trace with all 7 steps as 'idle'
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
renderTrace(stepState);
const payload = {
query,
paste_text: '',
slices,
engine,
language: lang,
controls: getControls(),
};
const _drDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean);
if (_drDocIds.length) payload.doc_ids = _drDocIds;
if (branchContext) {
payload.prior_context = branchContext;
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
}
const stepKeyToIndex = {
interpretation: 0,
expansion: 1,
slice_resolution: 2,
upload_indexing: 3,
retrieval: 4,
synthesis: 5,
confidence: 6,
};
let response;
try {
if (uploadFiles.length > 0) {
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
response = await fetch('api/deep-research.php', { method: 'POST', body: form, credentials: 'same-origin' });
} else {
response = await fetch('api/deep-research.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin',
});
}
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
stepState[0] = { ...stepState[0], status: 'error', detail: String(err) };
renderTrace(stepState);
return;
}
if (!response.ok || !response.body) {
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
// Read the NDJSON stream
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
let progressDetail = '';
while (true) {
let chunk;
try {
chunk = await reader.read();
} catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt;
try { evt = JSON.parse(trimmed); } catch (_) { continue; }
handleStreamEvent(evt);
}
}
if (done) break;
}
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
els.runButton.disabled = false;
// Mark the currently-running step as error
const runningIdx = stepState.findIndex((s) => s.status === 'running');
if (runningIdx >= 0) {
stepState[runningIdx] = { ...stepState[runningIdx], status: 'error', detail: errorEvent.message };
renderTrace(stepState);
}
return;
}
if (!finalResult) {
setStatus('Stream ended without a final result.', 'error');
els.runButton.disabled = false;
return;
}
finalResult.query = query;
lastResult = finalResult;
const meta = finalResult.trace_metadata || {};
const rc = meta.retrieval_counts || {};
const countSummary = (rc.post_filter_corpus != null)
? `${rc.post_filter_corpus} corpus${rc.filtered_website ? ` (${rc.filtered_website} website filtered)` : ''}${rc.raw_upload ? ` + ${rc.raw_upload} upload` : ''}`
: `${meta.source_count || 0} sources`;
setStatus(
`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${countSummary} · confidence ${meta.citation_confidence || '?'}`,
'ok'
);
els.runButton.disabled = false;
renderTrace(finalResult.trace || []);
renderResults(finalResult);
function handleStreamEvent(evt) {
if (!evt || !evt.event) return;
if (evt.event === 'progress') {
progressDetail = evt.detail || '';
if (progressDetail) setStatus(progressDetail, 'busy');
return;
}
if (evt.event === 'start') {
setStatus(`Running… engine=${evt.engine}, uploads=${evt.upload_count || 0}`, 'busy');
return;
}
if (evt.event === 'step') {
const idx = stepKeyToIndex[evt.step];
if (idx === undefined) return;
stepState[idx] = {
label: evt.label || stepState[idx].label,
detail: evt.detail || stepState[idx].detail,
status: evt.status || stepState[idx].status,
};
renderTrace(stepState);
return;
}
if (evt.event === 'subq') {
setStatus(`Retrieving sub-question ${evt.index}/${evt.total}: ${evt.question.slice(0, 80)}${evt.question.length > 80 ? '…' : ''}`, 'busy');
return;
}
if (evt.event === 'final') {
finalResult = evt.result;
return;
}
if (evt.event === 'error') {
errorEvent = evt;
return;
}
}
}
function setStatus(message, kind) {
els.status.textContent = message;
els.status.style.color = kind === 'error' ? '#b41e1e' : (kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)');
}
function renderTrace(steps) {
if (!els.traceList) return;
els.traceList.classList.add('is-rich');
els.traceList.innerHTML = steps.map((step, i) => {
const statusClass = step.status === 'running' ? 'is-running'
: step.status === 'complete' ? 'is-done'
: step.status === 'warning' ? 'is-warning'
: step.status === 'error' ? 'is-error'
: '';
const marker = step.status === 'complete' ? '✓'
: step.status === 'warning' ? '!'
: step.status === 'error' ? '×'
: (i + 1);
return `<li class="trace-step ${statusClass}">
<span class="trace-step__marker">${marker}</span>
<div>
<span class="trace-step__label">${escapeHtml(step.label || '')}</span>
<span class="trace-step__detail">${escapeHtml(step.detail || '')}</span>
</div>
</li>`;
}).join('');
}
function renderResults(data) {
const sources = data.sources || [];
const subs = data.sub_questions || [];
const briefHtml = renderBrief(data.brief_markdown || '', sources);
// Per-sub-question report cards — the "what each agent researched" view
const subQReportsHtml = subs.length ? `
<div class="dr-result-block">
<div class="dr-sources-head">
<h3>What each sub-question agent researched</h3>
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'}, top 3 sources each</small>
</div>
<div class="dr-subq-list">
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
</div>
</div>` : '';
const sourcesHtml = `
<div class="dr-result-block">
<div class="dr-sources-head">
<h3>All sources (${sources.length})</h3>
<small>Click a card to see the full chunk + scores · external link opens the original article</small>
</div>
<div class="dr-source-list">
${sources.map((s) => renderSourceCard(s)).join('')}
</div>
</div>`;
const uncertHtml = (data.what_remains_uncertain || []).length ? `
<div class="dr-result-block">
<h3 style="margin:0 0 8px;font-size:0.95rem;color:var(--muted)">What remains uncertain</h3>
<ul style="padding-left:1.2em;margin:0;color:var(--muted);line-height:1.55">
${(data.what_remains_uncertain || []).map((u) => `<li>${escapeHtml(String(u))}</li>`).join('')}
</ul>
</div>` : '';
const nextHtml = data.next_practical_step ? `
<div class="dr-result-block">
<h3 style="margin:0 0 6px;font-size:0.95rem">Next practical step</h3>
<p style="margin:0;color:var(--ink);line-height:1.5">${escapeHtml(data.next_practical_step)}</p>
</div>` : '';
els.results.innerHTML = `
${subQReportsHtml}
<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:1rem">Synthesised brief</h3>
<div class="dr-brief">${briefHtml}</div>
</div>
${sourcesHtml}
${uncertHtml}
${nextHtml}
`;
// Save-to-corpus button (inject after brief block)
const briefEl = els.results.querySelector('.dr-brief');
if (briefEl) {
briefEl.id = 'drBriefText';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'deep-research';
saveBtn.dataset.contentId = 'drBriefText';
saveBtn.dataset.suggestedTitle = 'Research: ' + (document.getElementById('drQuery')?.value?.slice(0, 80) ?? 'Report');
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '12px';
briefEl.insertAdjacentElement('afterend', saveBtn);
}
// Bind source-card click handlers (open modal) — but ignore clicks on inner <a>
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return; // let anchor handle its own click
const n = parseInt(node.dataset.sourceN, 10);
const src = sources.find((s) => s.n === n);
if (src) {
openModal(src);
flashSource(n);
}
});
});
// Bind inline citation markers in brief → flash + open modal
els.results.querySelectorAll('.dr-cite[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const n = parseInt(node.dataset.sourceN, 10);
const src = sources.find((s) => s.n === n);
if (src) {
flashSource(n);
}
});
});
}
function renderSubQReport(sq, idx) {
const top = sq.top_sources || [];
const sourceItems = top.length
? top.map((s) => {
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `<span class="dr-mini-source__title">${escapeHtml(s.title || 'Untitled')}</span>`;
const meta = [];
if (s.section) meta.push(escapeHtml(s.section));
if (s.authority_label) meta.push(escapeHtml(s.authority_label));
if (s.source_origin === 'upload') meta.push('your upload');
return `<li class="dr-mini-source">
<span class="dr-mini-source__n">[${s.n ?? '?'}]</span>
<div class="dr-mini-source__body">
${titleHtml}
${meta.length ? `<div class="dr-mini-source__meta">${meta.join(' · ')}</div>` : ''}
<div class="dr-mini-source__excerpt">${escapeHtml(truncate(s.excerpt || '', 180))}</div>
</div>
</li>`;
}).join('')
: `<li class="dr-mini-source dr-mini-source--empty"><em>No sources retrieved for this sub-question.</em></li>`;
return `<div class="dr-subq-report">
<div class="dr-subq-report__head">
<span class="dr-subq-report__index">${escapeHtml(sq.id || ('q' + (idx + 1)))}</span>
<div class="dr-subq-report__body">
<div class="dr-subq-report__question">${escapeHtml(sq.question || '')}</div>
${sq.rationale ? `<div class="dr-subq-report__rationale">${escapeHtml(sq.rationale)}</div>` : ''}
</div>
<button class="dr-branch-btn" type="button" data-question="${escapeHtml(sq.question || '')}" aria-label="Branch research from this sub-question">Branch ↓</button>
</div>
<ul class="dr-mini-source-list">${sourceItems}</ul>
</div>`;
}
function bindBranch() {
if (!els.branchClear) return;
els.branchClear.addEventListener('click', clearBranch);
}
function clearBranch() {
branchContext = null;
if (els.branchPanel) els.branchPanel.classList.add('is-hidden');
if (els.branchNotes) els.branchNotes.value = '';
}
function branchFromSubQ(question) {
if (!lastResult || !question) return;
branchContext = {
original_query: lastResult.query || '',
brief_summary: (lastResult.brief_markdown || '').slice(0, 600),
what_we_found: lastResult.what_we_found || '',
top_sources: (lastResult.sources || []).slice(0, 5).map((s) => ({
n: s.n, title: s.title, excerpt: (s.excerpt || '').slice(0, 200),
})),
};
els.input.value = question;
if (els.branchOrigin) els.branchOrigin.textContent = 'Original query: ' + branchContext.original_query;
if (els.branchSummary) els.branchSummary.textContent = branchContext.brief_summary;
if (els.branchPanel) els.branchPanel.classList.remove('is-hidden');
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function flashSource(n) {
document.querySelectorAll('.dr-source-card.is-highlight').forEach((c) => c.classList.remove('is-highlight'));
const target = document.querySelector(`.dr-source-card[data-source-n="${n}"]`);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => target.classList.remove('is-highlight'), 1800);
}
}
function renderSourceCard(s) {
const score = s.reranker_score != null ? s.reranker_score : s.similarity;
const originTagClass = s.source_origin === 'upload' ? 'dr-source-tag dr-source-tag--upload' : 'dr-source-tag';
const originLabel = s.source_origin === 'upload' ? 'upload' : 'corpus';
const link = s.deep_link || s.source_url;
const titleHtml = link
? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener" class="dr-source-title-link">${escapeHtml(s.title || 'Untitled')} <span class="dr-external-link" aria-hidden="true">↗</span></a>`
: `${escapeHtml(s.title || 'Untitled')}`;
return `<div class="dr-source-card" data-source-n="${s.n}" role="button" tabindex="0">
<span class="dr-source-number">${s.n}</span>
<div class="dr-source-body">
<div class="dr-source-title">${titleHtml}</div>
${s.section ? `<div class="dr-source-meta"><span class="dr-source-tag">${escapeHtml(s.section)}</span></div>` : ''}
<div class="dr-source-meta">
<span class="${originTagClass}">${originLabel}</span>
${s.authority_label ? `<span class="dr-source-tag">${escapeHtml(s.authority_label)}</span>` : ''}
<span class="dr-source-tag dr-source-tag--score">${escapeHtml(s.package_or_corpus || '—')}</span>
${(s.matched_sub_questions || []).map((q) => `<span class="dr-source-tag">${escapeHtml(q)}</span>`).join('')}
</div>
<p class="dr-source-excerpt">${escapeHtml(truncate(s.excerpt || '', 240))}</p>
</div>
<div class="dr-source-aside">
<span>score<br><b>${score != null ? Number(score).toFixed(2) : '—'}</b></span>
${s.reranker_score != null && s.similarity != null ? `<span>sim<br><b>${Number(s.similarity).toFixed(2)}</b></span>` : ''}
</div>
</div>`;
}
// Markdown renderer — minimal: paragraphs, bold/italic, code, [n] citation badges
function renderBrief(markdown, sources) {
if (!markdown) return '<p><em>No brief was returned.</em></p>';
const sourceSet = new Set((sources || []).map((s) => s.n));
const escaped = escapeHtml(markdown);
// Citation markers [1], [1,2], [1-3]
const withCites = escaped.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => {
const nums = expandCiteGroup(group);
return nums.map((n) => {
const known = sourceSet.has(n);
const cls = known ? 'dr-cite' : 'dr-cite';
return `<span class="${cls}" data-source-n="${n}" role="button" tabindex="0">${n}</span>`;
}).join('');
});
// Bold/italic
const withBold = withCites
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
// Paragraphs
const paragraphs = withBold.split(/\n{2,}/).map((p) => {
const t = p.trim();
if (!t) return '';
if (/^### /.test(t)) return `<h4 style="margin:14px 0 6px;color:var(--ink);font-size:1rem">${t.replace(/^### /, '')}</h4>`;
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
}).join('');
return paragraphs;
}
function expandCiteGroup(group) {
const out = [];
group.split(',').forEach((part) => {
const range = part.trim().match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
const a = parseInt(range[1], 10);
const b = parseInt(range[2], 10);
for (let i = a; i <= b; i++) out.push(i);
} else {
const n = parseInt(part.trim(), 10);
if (!Number.isNaN(n)) out.push(n);
}
});
return Array.from(new Set(out));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.slice(0, n - 1) + '…';
}
})();