ayushKishor's picture
Allow longer NVIDIA pipeline runs
d679523
(function () {
'use strict';
const STORAGE_KEYS = {
selectedDocIds: 'pluto.selectedDocIds',
detailLevel: 'pluto.detailLevel',
};
const RUN_TIMEOUT_MS = 600000;
const BENCH_TIMEOUT_MS = 600000;
const stages = ['route', 'extract', 'merge', 'evidence_check'];
const stageEls = {};
const statusEls = {};
const connectors = document.querySelectorAll('.stage-rail__connector');
const queryInput = document.getElementById('queryInput');
const runBtn = document.getElementById('runBtn');
const benchBtn = document.getElementById('benchBtn');
const detailModeSelect = document.getElementById('detailModeSelect');
const queryScopeLabel = document.getElementById('queryScopeLabel');
const answerBody = document.getElementById('answerBody');
const evidenceBody = document.getElementById('evidenceBody');
const traceBody = document.getElementById('traceBody');
const busBody = document.getElementById('busBody');
const confRing = document.getElementById('confRing');
const confValue = document.getElementById('confValue');
const benchPanel = document.getElementById('benchPanel');
const benchBody = document.getElementById('benchBody');
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const uploadStatus = document.getElementById('uploadStatus');
const corpusDocs = document.getElementById('corpusDocs');
const refreshCorpus = document.getElementById('refreshCorpus');
const corpusSelectionSummary = document.getElementById('corpusSelectionSummary');
let uploadProcessingActive = false;
let pipelineRunning = false;
let activeEventSource = null;
let activeSessionId = null;
let previousQuery = '';
let previousQueryTimestamp = null;
let previousSessionId = null;
let latestCorpusDocs = [];
let pendingCorpusDocIds = [];
let selectedDocIds = loadStoredDocIds();
let detailLevel = loadStoredDetailLevel();
let corpusRefreshTimer = null;
stages.forEach((stageName) => {
stageEls[stageName] = document.getElementById(`stage-${stageName}`);
statusEls[stageName] = document.getElementById(`status-${stageName}`);
});
init();
function init() {
detailModeSelect.value = detailLevel;
detailModeSelect.addEventListener('change', () => {
detailLevel = normalizeDetailLevel(detailModeSelect.value);
detailModeSelect.value = detailLevel;
localStorage.setItem(STORAGE_KEYS.detailLevel, detailLevel);
updateSelectionSummary();
});
runBtn.addEventListener('click', runPipeline);
benchBtn.addEventListener('click', runBenchmark);
queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !queryInput.disabled) {
runBtn.click();
}
});
queryInput.addEventListener('input', syncControls);
['dragenter', 'dragover'].forEach((eventName) => {
dropArea.addEventListener(eventName, (event) => {
event.preventDefault();
dropArea.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropArea.addEventListener(eventName, (event) => {
event.preventDefault();
dropArea.classList.remove('dragover');
});
});
dropArea.addEventListener('drop', (event) => {
const files = event.dataTransfer.files;
if (files && files.length) {
uploadFiles(files);
}
});
dropArea.addEventListener('click', () => {
if (!uploadProcessingActive && !pipelineRunning) {
fileInput.click();
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files.length) {
uploadFiles(fileInput.files);
}
fileInput.value = '';
});
refreshCorpus.addEventListener('click', loadCorpus);
loadCorpus();
syncControls();
}
function syncControls() {
const hasText = queryInput.value.trim().length > 0;
const controlsLocked = pipelineRunning || hasBlockingPendingDocs();
queryInput.disabled = controlsLocked;
queryInput.style.opacity = controlsLocked ? '0.7' : '1';
queryInput.style.cursor = controlsLocked ? 'not-allowed' : '';
detailModeSelect.disabled = controlsLocked;
runBtn.disabled = controlsLocked || !hasText;
runBtn.style.opacity = runBtn.disabled ? '0.5' : '1';
runBtn.style.cursor = runBtn.disabled ? 'not-allowed' : '';
benchBtn.disabled = controlsLocked || !hasText;
benchBtn.style.opacity = benchBtn.disabled ? '0.5' : '1';
benchBtn.style.cursor = benchBtn.disabled ? 'not-allowed' : '';
refreshCorpus.disabled = controlsLocked;
refreshCorpus.style.opacity = refreshCorpus.disabled ? '0.5' : '1';
refreshCorpus.style.cursor = refreshCorpus.disabled ? 'not-allowed' : '';
dropArea.style.pointerEvents = controlsLocked ? 'none' : '';
dropArea.style.opacity = controlsLocked ? '0.7' : '1';
}
function hasBlockingPendingDocs() {
if (uploadProcessingActive) {
return true;
}
if (!pendingCorpusDocIds.length) {
return false;
}
if (!selectedDocIds.length) {
return true;
}
return selectedDocIds.some((docId) => pendingCorpusDocIds.includes(docId));
}
async function runPipeline() {
const query = queryInput.value.trim();
if (!query || pipelineRunning || hasBlockingPendingDocs()) {
return;
}
pipelineRunning = true;
const sessionId = createSessionId();
const queryTimestamp = Date.now();
try {
activeSessionId = sessionId;
syncControls();
runBtn.innerHTML = '<span class="spinner"></span> Running...';
resetUI();
listenSSE(sessionId);
const response = await fetchWithTimeout('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildQueryPayload(query, sessionId, queryTimestamp)),
}, RUN_TIMEOUT_MS, 'Pipeline request timed out. The server may still be working; try again or refresh the app.');
const data = await parseJsonResponse(response, 'Server returned an invalid response');
activeSessionId = data.session_id || sessionId;
if (!response.ok || data.error) {
throw new Error(data.error || `Server error: ${response.status}`);
}
renderResult(data);
previousQuery = query;
previousQueryTimestamp = queryTimestamp;
previousSessionId = data.session_id || sessionId;
} catch (error) {
answerBody.innerHTML = renderErrorCard('Pipeline Error', error.message);
console.error(error);
} finally {
closeActiveStream();
pipelineRunning = false;
activeSessionId = null;
runBtn.innerHTML = '<span class="btn-icon">&#9654;</span> Run Pipeline';
syncControls();
}
}
async function runBenchmark() {
const query = queryInput.value.trim();
if (!query || pipelineRunning || hasBlockingPendingDocs()) {
return;
}
pipelineRunning = true;
syncControls();
benchBtn.innerHTML = '<span class="spinner"></span> Benchmarking...';
benchPanel.hidden = false;
benchBody.innerHTML = '<div id="benchLoader" class="bench-loader"><span class="spinner"></span> Running side-by-side comparison...</div>';
try {
const response = await fetchWithTimeout('/api/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildQueryPayload(query)),
}, BENCH_TIMEOUT_MS, 'Benchmark request timed out. Try again after the server finishes the current work.');
const data = await parseJsonResponse(response, 'Benchmark returned an invalid response');
if (!response.ok || data.error) {
throw new Error(data.error || `Benchmark error: ${response.status}`);
}
renderBenchmark(data);
} catch (error) {
benchBody.innerHTML = `<p style="color:var(--accent-red)">Error: ${esc(error.message)}</p>`;
} finally {
pipelineRunning = false;
benchBtn.innerHTML = '<span class="btn-icon">&#9878;</span> Benchmark';
syncControls();
}
}
function buildQueryPayload(query, sessionId = activeSessionId, queryTimestamp = Date.now()) {
return {
query,
session_id: sessionId,
query_timestamp: queryTimestamp,
prev_query: previousQuery,
prev_query_timestamp: previousQueryTimestamp,
prev_session_id: previousSessionId,
selected_doc_ids: [...selectedDocIds],
detail_level: detailLevel,
};
}
function listenSSE(sessionId) {
closeActiveStream();
const eventSource = new EventSource(`/api/stream?session_id=${encodeURIComponent(sessionId)}`);
activeEventSource = eventSource;
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
handleProgress(payload);
if (payload.stage === 'done' || payload.stage === 'error') {
eventSource.close();
if (activeEventSource === eventSource) {
activeEventSource = null;
}
}
} catch (error) {
console.error('Failed to parse stream event', error);
}
};
eventSource.onerror = (error) => {
console.error('Progress stream disconnected', error);
if (activeEventSource === eventSource) {
eventSource.close();
activeEventSource = null;
}
};
}
function closeActiveStream() {
if (activeEventSource) {
activeEventSource.close();
activeEventSource = null;
}
}
function clearCorpusAutoRefresh() {
if (corpusRefreshTimer) {
window.clearTimeout(corpusRefreshTimer);
corpusRefreshTimer = null;
}
}
function scheduleCorpusAutoRefresh() {
clearCorpusAutoRefresh();
if (!pendingCorpusDocIds.length) {
return;
}
corpusRefreshTimer = window.setTimeout(() => {
loadCorpus();
}, 2500);
}
function handleProgress(data) {
const stage = data.stage;
if (stage === 'bus') {
appendBusMessage({
sender: data.sender,
type: data.type,
payload: data.payload,
});
return;
}
if (stage === 'error') {
answerBody.innerHTML = renderErrorCard('Pipeline Error', data.detail || 'Unknown error');
return;
}
if (stage === 'finish' || stage === 'done') {
markAllStagesComplete();
return;
}
if (stage === 'connected' || stage === 'heartbeat' || !stages.includes(stage)) {
return;
}
const status = data.status;
const index = stages.indexOf(stage);
if (status === 'running') {
stageEls[stage].classList.add('active');
stageEls[stage].classList.remove('complete');
statusEls[stage].innerHTML = '<span class="status-dot status-dot--running"></span>running';
answerBody.innerHTML = `<p class="panel__placeholder"><span class="spinner"></span> ${stage.toUpperCase()}: processing...</p>`;
return;
}
if (status === 'complete') {
stageEls[stage].classList.remove('active');
stageEls[stage].classList.add('complete');
let info = 'done';
if (stage === 'route' && data.chunks) {
info = `done (${data.chunks} chunks)`;
} else if (stage === 'extract' && data.extractions) {
info = `done (${data.extractions} facts)`;
} else if (stage === 'merge' && data.key_claims) {
info = `done (${data.key_claims} claims)`;
} else if (stage === 'evidence_check' && data.checked) {
info = `done (${data.checked} checked)`;
}
statusEls[stage].innerHTML = `<span class="status-dot status-dot--complete"></span>${esc(info)}`;
if (index < connectors.length) {
connectors[index].classList.add('active');
}
}
}
function renderResult(data) {
if (data.error) {
answerBody.innerHTML = `<div class="alert-card alert-card--error">${esc(data.error)}</div>`;
return;
}
markAllStagesComplete();
const finalAnswer = data.final_answer || {};
let html = '';
if (Array.isArray(finalAnswer.sections) && finalAnswer.sections.length) {
html = finalAnswer.sections.map((section) => `
<div class="answer-section animate-in">
<div class="answer-section__title">${esc(section.title)}</div>
<div class="answer-section__content">${esc(section.content)}</div>
</div>
`).join('');
} else if (finalAnswer.response) {
html = `
<div class="answer-section animate-in">
<div class="answer-section__content">${esc(finalAnswer.response)}</div>
</div>
`;
}
const gaps = Array.isArray(data.missing_info) ? data.missing_info : [];
const nextActions = Array.isArray(data.next_actions) ? data.next_actions : [];
if (gaps.length) {
const gapTitle = nextActions.length ? 'Evidence Check / Coverage Gaps Found' : 'Coverage Gaps Noted';
const gapIntro = nextActions.length
? 'Some answer points could not be fully supported from the extracted evidence.'
: 'The detailed answer asked for coverage beyond what the document clearly supports in the selected scope.';
const gapPrefix = nextActions.length ? 'Need support:' : 'Not clearly covered:';
html += `
<div class="answer-section animate-in">
<div class="answer-section__title answer-section__title--alert">${esc(gapTitle)}</div>
<div class="answer-section__content">
<div class="gap-item">
<div class="gap-item__title">${esc(gapIntro)}</div>
</div>
${gaps.map((gap) => `
<div class="gap-item">
<div class="gap-item__title">${esc(gapPrefix)} ${esc(typeof gap === 'string' ? gap : JSON.stringify(gap))}</div>
</div>
`).join('')}
</div>
</div>
`;
}
answerBody.innerHTML = html || '<p class="panel__placeholder">No answer generated</p>';
const evidence = Array.isArray(data.evidence) ? data.evidence : [];
if (evidence.length) {
evidenceBody.innerHTML = evidence.map((item) => `
<div class="evidence-card animate-in">
<div class="evidence-card__source">${esc(item.doc_id)} / ${esc(item.chunk_id)} - ${esc(item.where)}</div>
<div class="evidence-card__quote">"${esc(item.quote)}"</div>
<div class="evidence-card__supports">Supports: ${esc(item.supports)}</div>
</div>
`).join('');
} else {
evidenceBody.innerHTML = '<p class="panel__placeholder">No evidence found</p>';
}
const trace = data.trace_summary || {};
traceBody.innerHTML = `
<div class="trace-item">
<span class="trace-item__label">Real Switching</span>
<span class="trace-item__value">${trace.real_switching ? 'Yes' : 'No'}</span>
</div>
<div class="trace-item">
<span class="trace-item__label">Chunks Processed</span>
<span class="trace-item__value">${trace.chunks_processed || 0}</span>
</div>
<div class="trace-item">
<span class="trace-item__label">Models Used</span>
<span class="trace-item__value">${esc((trace.models_used || []).join(', ') || '-')}</span>
</div>
${renderModeCounts(trace.modes_used_counts || {})}
<div class="trace-item">
<span class="trace-item__label">Docs Opened</span>
<span class="trace-item__value">${esc((trace.docs_opened || []).join(', ') || '-')}</span>
</div>
<div class="trace-item">
<span class="trace-item__label">Budget</span>
<span class="trace-item__value">${esc(trace.budget_notes || '-')}</span>
</div>
<div class="trace-item">
<span class="trace-item__label">Cache Hits</span>
<span class="trace-item__value trace-item__value--success">${data.cache_hits || 0}</span>
</div>
<div class="trace-item">
<span class="trace-item__label">Cache Misses</span>
<span class="trace-item__value">${data.cache_misses || 0}</span>
</div>
`;
const busMessages = Array.isArray(data.bus_messages) ? data.bus_messages : [];
if (busMessages.length) {
busBody.innerHTML = '';
busMessages.forEach((message) => appendBusMessage(message));
} else {
busBody.innerHTML = '<p class="panel__placeholder">No agent messages were emitted for this run</p>';
}
setConfidence(data.confidence || 0);
}
function renderModeCounts(counts) {
return Object.entries(counts).map(([mode, count]) => {
const cls = mode.includes('QUICK') ? 'quick' : mode.includes('VISION') ? 'vision' : 'reasoning';
return `
<div class="trace-item">
<span class="trace-item__label">
<span class="mode-badge mode-badge--${cls}">${esc(mode)}</span>
</span>
<span class="trace-item__value">${count} calls</span>
</div>
`;
}).join('');
}
function appendBusMessage(message) {
if (busBody.querySelector('.panel__placeholder')) {
busBody.innerHTML = '';
}
const element = document.createElement('div');
element.className = 'bus-message animate-in';
element.innerHTML = `
<div class="bus-message__sender">${esc(message.sender || '')}</div>
<div class="bus-message__type">${esc(message.type || '')}</div>
<div class="bus-message__content">${esc(describeBusPayload(message.payload || {}))}</div>
`;
busBody.appendChild(element);
busBody.scrollTop = busBody.scrollHeight;
}
function describeBusPayload(payload) {
if (!payload || typeof payload !== 'object') {
return String(payload || '');
}
if (typeof payload.synthesis === 'string' && payload.synthesis.trim()) {
return payload.synthesis;
}
if (Array.isArray(payload.flaws) && payload.flaws.length) {
return payload.flaws.join(' | ');
}
if (Array.isArray(payload.gaps) && payload.gaps.length) {
return payload.gaps.join(' | ');
}
if (payload.status) {
return `status=${payload.status}`;
}
if (Array.isArray(payload.plan)) {
return `Planned ${payload.plan.length} chunk(s)`;
}
if (Array.isArray(payload.supplement) && payload.supplement.length) {
return `Suggested ${payload.supplement.length} supplementary item(s)`;
}
return JSON.stringify(payload);
}
function renderBenchmark(data) {
const pluto = data.pluto || {};
const baseline = data.baseline || {};
const winner = data.winner || 'Unavailable';
const yesNoClass = (value) => value ? 'bench-stat__value bench-stat__value--good' : 'bench-stat__value bench-stat__value--bad';
const createColumn = (title, stats, isWinner) => `
<div class="bench-col ${isWinner ? 'bench-col--winner' : ''}">
<h3 class="bench-col__title">
${isWinner ? 'Winner' : 'Runner-up'} ${esc(title)}
</h3>
<div class="bench-stat">
<span class="bench-stat__label">Latency</span>
<div class="bench-stat__value">${stats.latency_s || 0}s</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Real Model Switching</span>
<div class="${yesNoClass(stats.real_switching)}">${stats.real_switching ? 'Yes' : 'No'}</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Evidence Check</span>
<div class="${yesNoClass(stats.evidence_checked)}">${stats.evidence_checked ? 'Enabled' : 'Disabled'}</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Evidence Count</span>
<div class="bench-stat__value">${stats.evidence_count || 0}</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Chunks Scanned</span>
<div class="bench-stat__value">${stats.chunks_processed || 0}</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Models Used</span>
<div class="bench-stat__value">${esc((stats.models_used || []).join(', ') || '-')}</div>
</div>
<div class="bench-stat">
<span class="bench-stat__label">Answer Preview</span>
<div class="bench-answer">${esc(stats.answer_preview || stats.error || 'No preview available')}</div>
</div>
</div>
`;
benchBody.innerHTML = `
${createColumn('Pluto', pluto, winner === 'Pluto')}
<div class="bench-vs">VS</div>
${createColumn('Single Model Baseline', baseline, winner === 'Baseline')}
`;
}
function markAllStagesComplete() {
stages.forEach((stageName, index) => {
stageEls[stageName].classList.remove('active');
stageEls[stageName].classList.add('complete');
statusEls[stageName].textContent = 'done';
if (connectors[index]) {
connectors[index].classList.add('active');
}
});
}
function resetUI() {
stages.forEach((stageName) => {
stageEls[stageName].classList.remove('active', 'complete');
statusEls[stageName].textContent = 'idle';
});
connectors.forEach((connector) => connector.classList.remove('active'));
benchPanel.hidden = true;
answerBody.innerHTML = '<p class="panel__placeholder"><span class="spinner"></span> Processing...</p>';
evidenceBody.innerHTML = '<p class="panel__placeholder">Waiting...</p>';
traceBody.innerHTML = '<p class="panel__placeholder">Waiting...</p>';
busBody.innerHTML = '<p class="panel__placeholder">Agent activity will stream here...</p>';
confRing.style.strokeDashoffset = '327';
confValue.textContent = '-';
}
function setConfidence(value) {
const circumference = 2 * Math.PI * 52;
const offset = circumference - (value * circumference);
confRing.style.strokeDashoffset = String(offset);
confValue.textContent = `${Math.round(value * 100)}%`;
}
async function uploadFiles(fileList) {
if (uploadProcessingActive || pipelineRunning) {
return;
}
uploadProcessingActive = true;
uploadStatus.innerHTML = '';
syncControls();
const steps = [
{ id: 'upload', label: 'Uploading file to server' },
{ id: 'convert', label: 'Converting to text' },
{ id: 'chunk', label: 'Splitting into chunks' },
{ id: 'understand', label: 'AI reading and understanding document' },
{ id: 'ready', label: 'Ready for questions!' },
];
const filenames = Array.from(fileList).map((file) => file.name).join(', ');
uploadStatus.innerHTML = `
<div class="upload-steps">
<div class="upload-steps__title">
Processing: ${esc(filenames)}
</div>
${steps.map((step, index) => `
<div id="upload-step-${step.id}" class="upload-step ${index === 0 ? 'upload-step--active' : ''}">
<span id="upload-icon-${step.id}" class="upload-step__icon">${index === 0 ? '<span class="spinner" style="width:14px;height:14px;"></span>' : 'o'}</span>
<span>${esc(step.label)}</span>
</div>
`).join('')}
</div>
`;
const timers = [
window.setTimeout(() => activateUploadStep(steps, 1), 1200),
window.setTimeout(() => activateUploadStep(steps, 2), 2400),
window.setTimeout(() => activateUploadStep(steps, 3), 4000),
];
const formData = new FormData();
Array.from(fileList).forEach((file) => formData.append('files', file));
try {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await parseJsonResponse(response, 'Upload returned an invalid response');
timers.forEach((timerId) => window.clearTimeout(timerId));
['upload', 'convert', 'chunk'].forEach(completeUploadStep);
(data.uploaded || []).forEach((item) => {
addStatusItem(item.filename, `Indexed as "${item.doc_id}" (${item.chunks} chunks)`, 'success');
});
(data.errors || []).forEach((item) => {
addStatusItem(item.filename, item.error, 'error');
});
await loadCorpus();
const docsToWatch = (data.uploaded || []).filter((item) => item.understanding === 'in_progress');
if (!docsToWatch.length) {
completeUploadStep('understand');
completeUploadStep('ready');
uploadProcessingActive = false;
syncControls();
return;
}
activateUploadStep(steps, 3);
await pollDocumentReadiness(docsToWatch);
completeUploadStep('understand');
completeUploadStep('ready');
} catch (error) {
timers.forEach((timerId) => window.clearTimeout(timerId));
uploadStatus.innerHTML = '';
addStatusItem('Upload', error.message, 'error');
} finally {
uploadProcessingActive = false;
syncControls();
await loadCorpus();
}
}
async function pollDocumentReadiness(docsToWatch) {
const pendingIds = new Set(docsToWatch.map((doc) => doc.doc_id));
while (pendingIds.size > 0) {
await delay(2000);
for (const docId of Array.from(pendingIds)) {
const response = await fetch(`/api/doc-status/${encodeURIComponent(docId)}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
});
const data = await parseJsonResponse(response, 'Document status returned an invalid response');
if (data.status === 'failed') {
throw new Error(data.error || `Document understanding failed for ${docId}`);
}
if (data.status === 'ready') {
pendingIds.delete(docId);
}
}
}
}
function activateUploadStep(steps, index) {
if (index > 0) {
completeUploadStep(steps[index - 1].id);
}
const currentStep = steps[index];
if (!currentStep) {
return;
}
const icon = document.getElementById(`upload-icon-${currentStep.id}`);
const row = document.getElementById(`upload-step-${currentStep.id}`);
if (icon) {
icon.innerHTML = '<span class="spinner" style="width:14px;height:14px;"></span>';
}
if (row) {
row.classList.add('upload-step--active');
row.classList.remove('upload-step--complete');
}
}
function completeUploadStep(stepId) {
const icon = document.getElementById(`upload-icon-${stepId}`);
const row = document.getElementById(`upload-step-${stepId}`);
if (icon) {
icon.innerHTML = '&#10003;';
}
if (row) {
row.classList.remove('upload-step--active');
row.classList.add('upload-step--complete');
}
}
function addStatusItem(name, message, type) {
const element = document.createElement('div');
element.className = `upload-status-item upload-status-item--${type}`;
const icon = type === 'success' ? '&#10003;' : type === 'error' ? '&#10007;' : '...';
element.innerHTML = `<strong>${icon} ${esc(name)}</strong> - ${esc(message)}`;
uploadStatus.appendChild(element);
}
async function loadCorpus() {
try {
clearCorpusAutoRefresh();
const response = await fetch('/api/corpus', {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
});
const data = await parseJsonResponse(response, 'Corpus response was invalid');
latestCorpusDocs = Array.isArray(data.documents) ? data.documents : [];
pendingCorpusDocIds = latestCorpusDocs
.filter((doc) => doc.processing_status === 'understanding')
.map((doc) => doc.doc_id);
const validDocIds = new Set(latestCorpusDocs.map((doc) => doc.doc_id));
const prunedSelection = selectedDocIds.filter((docId) => validDocIds.has(docId));
if (prunedSelection.length !== selectedDocIds.length) {
selectedDocIds = prunedSelection;
persistSelectedDocIds();
}
renderCorpusDocs();
updateSelectionSummary();
syncControls();
scheduleCorpusAutoRefresh();
} catch (error) {
scheduleCorpusAutoRefresh();
corpusDocs.innerHTML = '<span style="color:var(--accent-red);">Failed to load</span>';
}
}
function renderCorpusDocs() {
if (!latestCorpusDocs.length) {
corpusDocs.innerHTML = '<span style="color:var(--text-muted);">No documents in corpus</span>';
return;
}
corpusDocs.innerHTML = latestCorpusDocs.map((doc) => {
const isSelected = selectedDocIds.includes(doc.doc_id);
const isReady = doc.processing_status === 'ready' || doc.is_processed === true;
const stateLabel = isReady ? 'Ready' : doc.processing_status === 'failed' ? 'Failed' : 'Understanding';
const chipClasses = [
'corpus-doc-chip',
isSelected ? 'corpus-doc-chip--selected' : '',
!isReady ? 'corpus-doc-chip--muted' : '',
].filter(Boolean).join(' ');
return `
<div class="${chipClasses}" data-doc-id="${esc(doc.doc_id)}" data-selectable="${isReady ? 'true' : 'false'}" title="${isReady ? 'Click to use this document for the next query' : 'This document is not ready yet'}">
<span class="corpus-doc-chip__name">${esc(doc.filename)}</span>
<span class="corpus-doc-chip__size">${formatSize(doc.size)}</span>
<span class="corpus-doc-chip__state">${esc(stateLabel)}</span>
<button class="corpus-doc-chip__delete" type="button" data-delete-doc="${esc(doc.doc_id)}" title="Remove">x</button>
</div>
`;
}).join('');
corpusDocs.querySelectorAll('.corpus-doc-chip').forEach((chip) => {
chip.addEventListener('click', () => {
if (chip.dataset.selectable !== 'true' || uploadProcessingActive || pipelineRunning) {
return;
}
toggleDocSelection(chip.dataset.docId || '');
});
});
corpusDocs.querySelectorAll('.corpus-doc-chip__delete').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
deleteDoc(button.dataset.deleteDoc || '');
});
});
}
function createSessionId() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
return window.crypto.randomUUID();
}
return `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function toggleDocSelection(docId) {
if (!docId) {
return;
}
if (selectedDocIds.includes(docId)) {
selectedDocIds = selectedDocIds.filter((item) => item !== docId);
} else {
selectedDocIds = [...selectedDocIds, docId];
}
persistSelectedDocIds();
renderCorpusDocs();
updateSelectionSummary();
}
async function deleteDoc(docId) {
if (!docId || !window.confirm(`Remove "${docId}" from corpus?`)) {
return;
}
try {
await fetch(`/api/corpus/${encodeURIComponent(docId)}`, { method: 'DELETE' });
selectedDocIds = selectedDocIds.filter((item) => item !== docId);
persistSelectedDocIds();
await loadCorpus();
} catch (error) {
console.error('Failed to delete document', error);
}
}
function updateSelectionSummary() {
const selectedDocs = latestCorpusDocs.filter((doc) => selectedDocIds.includes(doc.doc_id));
let scopeText = 'Scope: entire corpus';
let helpText = 'Click a ready corpus document to limit the next query to that document.';
if (selectedDocs.length === 1) {
scopeText = `Scope: ${selectedDocs[0].filename}`;
helpText = 'This query will only use the selected document. Click it again to clear the filter.';
} else if (selectedDocs.length > 1) {
scopeText = `Scope: ${selectedDocs.length} selected documents`;
helpText = 'This query will only use the selected documents. Click a selected chip again to clear it.';
}
const detailText = detailLevel === 'detailed' ? 'Detailed answer' : 'Standard answer';
queryScopeLabel.textContent = `${scopeText} | ${detailText}`;
corpusSelectionSummary.textContent = helpText;
}
function persistSelectedDocIds() {
localStorage.setItem(STORAGE_KEYS.selectedDocIds, JSON.stringify(selectedDocIds));
}
function loadStoredDocIds() {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.selectedDocIds) || '[]');
return Array.isArray(parsed)
? parsed.map((value) => String(value || '').trim()).filter(Boolean)
: [];
} catch (_) {
return [];
}
}
function loadStoredDetailLevel() {
return normalizeDetailLevel(localStorage.getItem(STORAGE_KEYS.detailLevel));
}
function normalizeDetailLevel(value) {
return String(value || '').toLowerCase() === 'detailed' ? 'detailed' : 'standard';
}
async function parseJsonResponse(response, errorPrefix) {
try {
return await response.json();
} catch (_) {
throw new Error(`${errorPrefix} (${response.status} ${response.statusText})`);
}
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 120000, timeoutMessage = 'Request timed out') {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (error) {
if (error && error.name === 'AbortError') {
throw new Error(timeoutMessage);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
}
function formatSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function renderErrorCard(title, message) {
return `<div class="alert-card alert-card--error"><strong>${esc(title)}:</strong> ${esc(message)}</div>`;
}
function esc(value) {
const div = document.createElement('div');
div.textContent = String(value == null ? '' : value);
return div.innerHTML;
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
})();