| (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">▶</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">⚖</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 = '✓'; |
| } |
| 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' ? '✓' : type === 'error' ? '✗' : '...'; |
| 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)); |
| } |
| })(); |
|
|