| 'use strict'; |
|
|
| |
| const LOG_PREFIX = '[HealthExpert]'; |
| const diag = { |
| info: (...a) => console.info( `${LOG_PREFIX}`, ...a), |
| warn: (...a) => console.warn( `${LOG_PREFIX}`, ...a), |
| error: (...a) => console.error(`${LOG_PREFIX}`, ...a), |
| group: (label) => console.group(`${LOG_PREFIX} ${label}`), |
| groupEnd: () => console.groupEnd(), |
| }; |
|
|
| |
| |
| |
| |
| const MOBILE_BREAKPOINT = 768; |
|
|
| function detectDevice() { |
| const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); |
| const isNarrow = window.innerWidth <= MOBILE_BREAKPOINT; |
| const isMobileUA = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); |
| const isMobile = isTouchDevice || isNarrow || isMobileUA; |
|
|
| if (isMobile) { |
| document.body.classList.add('is-mobile'); |
| } else { |
| document.body.classList.remove('is-mobile'); |
| } |
| diag.info(`Device detected β mobile: ${isMobile} (touch:${isTouchDevice}, narrow:${isNarrow}, mobileUA:${isMobileUA})`); |
| return isMobile; |
| } |
|
|
| |
| detectDevice(); |
| |
| let _resizeTimer = null; |
| window.addEventListener('resize', () => { |
| clearTimeout(_resizeTimer); |
| _resizeTimer = setTimeout(detectDevice, 150); |
| }); |
|
|
| |
| const state = { |
| isQuerying: false, |
| isIngesting: false, |
| currentJobId: null, |
| lastAnswer: '', |
| isAdmin: false, |
| }; |
|
|
| |
| const $ = id => document.getElementById(id); |
| const dropZone = $('drop-zone'); |
| const fileInput = $('file-input'); |
| const docList = $('doc-list'); |
| const ingestProgress = $('ingest-progress'); |
| const progressFill = $('progress-fill'); |
| const ingestStatusTxt = $('ingest-status-text'); |
| const ingestLog = $('ingest-log'); |
| const ingestLogDetails= $('ingest-log-details'); |
| const chatHistory = $('chat-history'); |
| const queryInput = $('query-input'); |
| const sendBtn = $('send-btn'); |
| const outputContainer = $('output-container'); |
| const copyBtn = $('copy-btn'); |
| const citationsBlock = $('citations-block'); |
| const metricsBanner = $('metrics-banner'); |
| const notifContainer = $('notif-container'); |
| const dockerModal = $('docker-modal'); |
| const dockerModalTitle= $('docker-modal-title'); |
| const dockerModalBody = $('docker-modal-body'); |
|
|
| |
| function notify(msg, type = 'info', duration = 5000) { |
| diag.info(`notify [${type}]`, msg); |
| const el = document.createElement('div'); |
| el.className = `notif ${type}`; |
| el.textContent = msg; |
| notifContainer.appendChild(el); |
| setTimeout(() => el.remove(), duration); |
| } |
|
|
| |
| function appendLog(msg) { |
| const line = document.createElement('div'); |
| line.className = 'log-line'; |
| line.textContent = `${new Date().toLocaleTimeString()} ${msg}`; |
| ingestLog.appendChild(line); |
| ingestLog.scrollTop = ingestLog.scrollHeight; |
| } |
|
|
| function clearLog() { |
| ingestLog.innerHTML = ''; |
| } |
|
|
| |
| async function refreshStatus() { |
| diag.info('Refreshing statusβ¦'); |
| try { |
| const r = await fetch('/api/status'); |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| const d = await r.json(); |
| diag.info('Status response:', d); |
|
|
| const setPill = (id, ok, label) => { |
| const pill = $(id); |
| if (!pill) return; |
| pill.className = `status-pill ${ok ? 'ok' : 'warn'}`; |
| pill.querySelector('.status-label').textContent = label; |
| }; |
|
|
| const vecOk = (d.vector_db?.chunks ?? -1) >= 0; |
| const graphOk = d.graph_db?.available; |
| const genOk = d.nvidia_llm?.online; |
| const embedOk = d.embed_llm?.online; |
| |
| state.isAdmin = !!d.is_admin; |
| |
| const adminMode = (window.APP_CONFIG && window.APP_CONFIG.adminMode !== false) ? true : false; |
| const showAdmin = state.isAdmin && adminMode; |
| const adminBadge = $('admin-badge'); |
| if (adminBadge) adminBadge.style.display = showAdmin ? 'inline' : 'none'; |
| const adminControls = $('admin-controls'); |
| if (adminControls) adminControls.style.display = showAdmin ? 'flex' : 'none'; |
|
|
| setPill('status-vector', vecOk, `Vector Β· ${d.vector_db?.chunks ?? '?'} chunks`); |
| setPill('status-graph', graphOk, graphOk ? `Kuzu DB Β· ${d.graph_db.nodes} nodes, ${d.graph_db.relationships} edges` : 'Kuzu DB Β· offline'); |
| |
| const genStatus = genOk |
| ? `Gen Β· ${(d.nvidia_llm?.model || '').split('/').pop()} (GPU: ${d.nvidia_llm?.gpu_id} | KV: ${d.nvidia_llm?.kv_cache_length} tkns)` |
| : 'Gen LLM Β· offline'; |
| setPill('status-gen', genOk, genStatus); |
| |
| setPill('status-embed', embedOk, embedOk ? `Embed Β· ${(d.embed_llm?.model || '').split('/').pop()}` : 'Embed LLM Β· offline'); |
|
|
| if (!genOk) diag.warn('nvidia_llm server is offline'); |
| if (!embedOk) diag.warn('embed_llm server is offline'); |
| } catch(err) { |
| diag.error('Status refresh failed:', err); |
| ['status-vector','status-graph','status-gen','status-embed'].forEach(id => { |
| const p = $(id); |
| if (p) p.className = 'status-pill err'; |
| }); |
| } |
| } |
|
|
| |
| async function dockerAction(action) { |
| const labels = { up: 'βΆ Starting', down: 'β Stopping' }; |
| dockerModalTitle.textContent = `${labels[action] || action} Databasesβ¦`; |
| dockerModalBody.textContent = 'Running docker compose, please waitβ¦'; |
| dockerModal.style.display = 'flex'; |
| diag.info('Docker action:', action); |
|
|
| try { |
| const r = await fetch(`/api/docker/${action}`, { method: 'POST' }); |
| const data = await r.json(); |
| diag.info('Docker response:', data); |
| dockerModalBody.innerHTML = |
| `<pre class="modal-pre">${escHtml(data.output || 'No output')}</pre>`; |
| if (data.ok) { |
| notify(`Database ${action} succeeded`, 'success'); |
| setTimeout(refreshStatus, 3000); |
| } else { |
| notify(`Database ${action} failed`, 'error', 8000); |
| } |
| } catch(err) { |
| diag.error('Docker action failed:', err); |
| dockerModalBody.textContent = `Error: ${err.message}`; |
| notify(`Docker ${action} error: ${err.message}`, 'error', 8000); |
| } |
| } |
|
|
| async function adminPurge() { |
| if (!confirm("Are you sure you want to completely wipe ChromaDB and Kuzu? This cannot be undone.")) return; |
| diag.info('Purge DB action'); |
| try { |
| const r = await fetch(`/api/admin/purge`, { method: 'POST' }); |
| const data = await r.json(); |
| if (data.ok) { |
| notify(data.msg, 'success', 5000); |
| await loadDocuments(); |
| await refreshStatus(); |
| } else { |
| notify(`Purge failed: ${data.error}`, 'error', 8000); |
| } |
| } catch(err) { |
| notify(`Purge error: ${err.message}`, 'error', 8000); |
| } |
| } |
|
|
| async function adminKill() { |
| if (!confirm("EMERGENCY KILL SWITCH: This will stop all databases and instantly kill the application server. Are you sure?")) return; |
| diag.info('Kill switch activated'); |
| try { |
| const r = await fetch(`/api/admin/kill`, { method: 'POST' }); |
| const data = await r.json(); |
| if (data.ok) { |
| notify(data.msg, 'success', 10000); |
| document.body.innerHTML = "<h1 style='color:red; text-align:center; margin-top:20%'>APPLICATION TERMINATED</h1><p style='text-align:center'>Please restart the server manually.</p>"; |
| } else { |
| notify(`Kill switch failed: ${data.error}`, 'error', 8000); |
| } |
| } catch(err) { |
| notify(`Kill switch error: ${err.message}`, 'error', 8000); |
| } |
| } |
|
|
| if ($('db-up-btn')) $('db-up-btn').addEventListener('click', () => dockerAction('up')); |
| if ($('db-down-btn')) $('db-down-btn').addEventListener('click', () => dockerAction('down')); |
| if ($('db-purge-btn')) $('db-purge-btn').addEventListener('click', adminPurge); |
| if ($('app-kill-btn')) $('app-kill-btn').addEventListener('click', adminKill); |
| if ($('docker-modal-close')) $('docker-modal-close').addEventListener('click', () => { dockerModal.style.display = 'none'; }); |
|
|
| |
| function _resBarClass(pct) { |
| if (pct >= 90) return 'crit'; |
| if (pct >= 70) return 'warn'; |
| return ''; |
| } |
|
|
| async function pollSysInfo() { |
| try { |
| const r = await fetch('/api/sysinfo'); |
| if (!r.ok) return; |
| const d = await r.json(); |
| if (d.error) return; |
|
|
| |
| const cpuPct = d.cpu_pct ?? 0; |
| const cpuCls = _resBarClass(cpuPct); |
| const cpuBar = $('res-cpu-bar'); |
| const cpuPctEl = $('res-cpu-pct'); |
| const cpuLabel = $('res-cpu-label'); |
| if (cpuBar) { cpuBar.style.width = cpuPct + '%'; cpuBar.className = 'res-bar ' + cpuCls; } |
| if (cpuPctEl) { cpuPctEl.textContent = cpuPct.toFixed(0) + '%'; cpuPctEl.className = 'res-pct ' + cpuCls; } |
| if (cpuLabel) { |
| |
| const brand = (d.cpu_brand || 'CPU').split(' ').slice(0,2).join(' '); |
| const mhz = d.cpu_mhz ? ` @ ${(d.cpu_mhz/1000).toFixed(1)}GHz` : ''; |
| cpuLabel.textContent = `${brand} Γ${d.cpu_cores}${mhz}`; |
| } |
|
|
| |
| const ramPct = d.ram_pct ?? 0; |
| const ramCls = _resBarClass(ramPct); |
| const ramBar = $('res-ram-bar'); |
| const ramPctEl = $('res-ram-pct'); |
| const ramLabel = $('res-ram-label'); |
| if (ramBar) { ramBar.style.width = ramPct + '%'; ramBar.className = 'res-bar ' + ramCls; } |
| if (ramPctEl) { ramPctEl.textContent = ramPct.toFixed(0) + '%'; ramPctEl.className = 'res-pct ' + ramCls; } |
| if (ramLabel) ramLabel.textContent = `RAM: ${d.ram_used_gb} / ${d.ram_total_gb} GB`; |
|
|
| |
| const diskPct = d.disk_pct ?? 0; |
| const diskCls = _resBarClass(diskPct); |
| const diskBar = $('res-disk-bar'); |
| const diskPctEl = $('res-disk-pct'); |
| const diskLabel = $('res-disk-label'); |
| if (diskBar) { diskBar.style.width = diskPct + '%'; diskBar.className = 'res-bar ' + diskCls; } |
| if (diskPctEl) { diskPctEl.textContent = diskPct.toFixed(0) + '%'; diskPctEl.className = 'res-pct ' + diskCls; } |
| if (diskLabel) diskLabel.textContent = `Disk: ${d.disk_free_gb} / ${d.disk_total_gb} GB free`; |
|
|
| |
| const badge = $('res-mode-badge'); |
| if (badge) { |
| const isHf = d.hf_mode || (window.APP_CONFIG && window.APP_CONFIG.hfMode); |
| badge.textContent = isHf ? 'β‘ HF / CPU Mode' : 'π₯ GPU Mode'; |
| badge.className = 'res-mode-badge ' + (isHf ? 'hf-mode' : 'gpu-mode'); |
| } |
|
|
| |
| const gpuItem = $('res-gpu'); |
| if (gpuItem) { |
| gpuItem.style.display = d.gpu_available ? 'flex' : 'none'; |
| } |
|
|
| |
| const cpuCoresSelect = $('cpu-cores-select'); |
| if (cpuCoresSelect && d.cpu_cores) { |
| const maxCores = d.cpu_cores; |
| const current = parseInt(cpuCoresSelect.value || '2', 10); |
| cpuCoresSelect.innerHTML = ''; |
| let n = 2; |
| while (n <= maxCores) { |
| const opt = document.createElement('option'); |
| opt.value = String(n); |
| opt.textContent = String(n); |
| if (n === current || (n === 2 && current < 2)) opt.selected = true; |
| cpuCoresSelect.appendChild(opt); |
| n *= 2; |
| } |
| |
| if (!cpuCoresSelect.options.length) { |
| const opt = document.createElement('option'); |
| opt.value = '2'; opt.textContent = '2'; opt.selected = true; |
| cpuCoresSelect.appendChild(opt); |
| } |
| } |
|
|
| |
| const activeGraph = $('status-graph-active'); |
| const dbGraph = $('status-graph'); |
| if (d.active_graph_tasks > 0) { |
| if (activeGraph) activeGraph.style.display = 'flex'; |
| if (dbGraph) dbGraph.style.display = 'none'; |
| } else { |
| if (activeGraph) activeGraph.style.display = 'none'; |
| if (dbGraph) dbGraph.style.display = 'flex'; |
| } |
| } catch(err) { |
| diag.warn('pollSysInfo failed:', err); |
| } |
| } |
|
|
| |
| const TYPE_ICONS = { |
| pdf:'π', docx:'π', txt:'π', xlsx:'π', csv:'π', |
| png:'πΌοΈ', jpg:'πΌοΈ', jpeg:'πΌοΈ', webp:'πΌοΈ', |
| }; |
|
|
| async function loadDocuments() { |
| diag.info('Loading document listβ¦'); |
| try { |
| const r = await fetch('/api/documents'); |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| const data = await r.json(); |
| diag.info(`Document list: ${data.total} document(s)`); |
| docList.innerHTML = ''; |
|
|
| if (!data.documents?.length) { |
| docList.innerHTML = '<div class="empty-state">π No documents ingested yet.<br>Upload files above to get started.</div>'; |
| return; |
| } |
|
|
| data.documents.forEach(doc => { |
| const icon = TYPE_ICONS[doc.file_type] || 'π'; |
| const item = document.createElement('div'); |
| item.className = 'doc-item'; |
| const tierBadge = doc.tier === 'foundation' |
| ? `<span style="font-size:10px; background:var(--blue); color:#fff; padding:2px 4px; border-radius:4px; margin-left:8px;">FOUNDATION</span>` |
| : `<span style="font-size:10px; background:var(--bg-panel); border:1px solid var(--border); padding:2px 4px; border-radius:4px; margin-left:8px;">EXTENDED</span>`; |
| |
| item.innerHTML = ` |
| <button class="doc-delete" title="Remove document" data-source="${escHtml(doc.source)}" data-tier="${doc.tier}">β</button> |
| <span class="doc-icon">${icon}</span> |
| <div class="doc-info"> |
| <div class="doc-name" title="${escHtml(doc.source)}">${escHtml(doc.source)} ${tierBadge}</div> |
| <div class="doc-type">${doc.file_type.toUpperCase()}</div> |
| </div>`; |
| docList.appendChild(item); |
| }); |
|
|
| docList.querySelectorAll('.doc-delete').forEach(btn => { |
| btn.addEventListener('click', () => deleteDocument(btn.dataset.source, btn.dataset.tier)); |
| }); |
| } catch(err) { |
| diag.error('loadDocuments failed:', err); |
| notify(`Failed to load documents: ${err.message}`, 'error'); |
| } |
| } |
|
|
| async function deleteDocument(source, tier) { |
| diag.info('Deleting document:', source, tier); |
| try { |
| const r = await fetch(`/api/documents/${encodeURIComponent(source)}?tier=${tier}`, { method: 'DELETE' }); |
| const data = await r.json(); |
| diag.info('Delete response:', data); |
| notify(`Removed "${source}" (${data.deleted_chunks} chunks)`, 'info'); |
| await loadDocuments(); |
| await refreshStatus(); |
| } catch(err) { |
| diag.error('Delete failed:', err); |
| notify(`Delete failed: ${err.message}`, 'error'); |
| } |
| } |
|
|
| $('refresh-docs-btn').addEventListener('click', loadDocuments); |
|
|
| |
| let pendingFiles = []; |
| const tierModal = $('tier-modal'); |
| const tierCancel = $('tier-modal-cancel'); |
| const tierConfirm = $('tier-modal-confirm'); |
| const tierFoundationLabel = $('tier-foundation-label'); |
|
|
| function openTierModal(files) { |
| if (!files.length) return; |
| pendingFiles = files; |
| if (!state.isAdmin) { |
| if(tierFoundationLabel) { |
| tierFoundationLabel.style.opacity = '0.5'; |
| tierFoundationLabel.querySelector('input').disabled = true; |
| } |
| document.querySelector('input[value="extended"]').checked = true; |
| } else { |
| if(tierFoundationLabel) { |
| tierFoundationLabel.style.opacity = '1'; |
| tierFoundationLabel.querySelector('input').disabled = false; |
| } |
| } |
| if (tierModal) tierModal.style.display = 'flex'; |
| } |
|
|
| if (tierCancel) { |
| tierCancel.addEventListener('click', () => { |
| tierModal.style.display = 'none'; |
| pendingFiles = []; |
| }); |
| } |
| let expectedCaptchaAnswer = 0; |
| const captchaModal = $('captcha-modal'); |
| const captchaQuestion = $('captcha-question'); |
| const captchaInput = $('captcha-input'); |
| const captchaError = $('captcha-error'); |
| const captchaCancel = $('captcha-modal-cancel'); |
| const captchaVerify = $('captcha-modal-verify'); |
|
|
| function generateCaptcha() { |
| const a = Math.floor(Math.random() * 10) + 1; |
| const b = Math.floor(Math.random() * 10) + 1; |
| expectedCaptchaAnswer = a + b; |
| if (captchaQuestion) captchaQuestion.textContent = `${a} + ${b} =`; |
| if (captchaInput) captchaInput.value = ''; |
| if (captchaError) captchaError.style.display = 'none'; |
| } |
|
|
| if (tierConfirm) { |
| tierConfirm.addEventListener('click', () => { |
| tierModal.style.display = 'none'; |
| generateCaptcha(); |
| if (captchaModal) captchaModal.style.display = 'flex'; |
| if (captchaInput) captchaInput.focus(); |
| }); |
| } |
|
|
| if (captchaCancel) { |
| captchaCancel.addEventListener('click', () => { |
| captchaModal.style.display = 'none'; |
| pendingFiles = []; |
| }); |
| } |
|
|
| if (captchaVerify) { |
| const verifyAndProceed = () => { |
| const userAns = parseInt(captchaInput.value, 10); |
| if (userAns === expectedCaptchaAnswer) { |
| captchaModal.style.display = 'none'; |
| const selectedTier = document.querySelector('input[name="kb_tier"]:checked').value; |
| ingestFiles(pendingFiles, selectedTier); |
| } else { |
| captchaError.style.display = 'block'; |
| captchaInput.value = ''; |
| captchaInput.focus(); |
| } |
| }; |
| captchaVerify.addEventListener('click', verifyAndProceed); |
| if (captchaInput) { |
| captchaInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter') verifyAndProceed(); |
| }); |
| } |
| } |
|
|
| dropZone.addEventListener('click', () => fileInput.click()); |
| dropZone.addEventListener('keydown', e => { if (e.key === 'Enter') fileInput.click(); }); |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); |
| dropZone.addEventListener('drop', e => { |
| e.preventDefault(); |
| dropZone.classList.remove('drag-over'); |
| const files = Array.from(e.dataTransfer.files); |
| diag.info(`Dropped ${files.length} file(s):`, files.map(f => f.name)); |
| openTierModal(files); |
| }); |
| fileInput.addEventListener('change', () => { |
| const files = Array.from(fileInput.files); |
| diag.info(`Selected ${files.length} file(s):`, files.map(f => f.name)); |
| openTierModal(files); |
| }); |
|
|
| |
| async function ingestFiles(files, tier) { |
| if (!files.length) { |
| notify('No files selected.', 'error'); |
| return; |
| } |
| if (state.isIngesting) { |
| notify('Ingestion already in progress β please wait.', 'warn'); |
| return; |
| } |
|
|
| state.isIngesting = true; |
| clearLog(); |
| ingestLogDetails.open = true; |
| ingestProgress.style.display = 'block'; |
| progressFill.style.width = '5%'; |
| appendLog(`Starting upload of ${files.length} file(s)β¦`); |
| files.forEach(f => appendLog(` β ${f.name} (${(f.size/1024).toFixed(1)} KB)`)); |
|
|
| const fd = new FormData(); |
| files.forEach(f => fd.append('files', f)); |
| fd.append('tier', tier); |
|
|
| diag.group('Ingestion'); |
| diag.info('POST /api/ingest with', files.length, 'file(s) for tier:', tier); |
|
|
| try { |
| const r = await fetch('/api/ingest', { method: 'POST', body: fd }); |
| const data = await r.json(); |
| diag.info('Ingest response:', data); |
|
|
| if (!r.ok || data.error) { |
| const msg = data.error || `HTTP ${r.status}`; |
| appendLog(`β Upload rejected: ${msg}`); |
| notify(`Upload failed: ${msg}`, 'error', 8000); |
| diag.error('Ingest API error:', msg); |
| return; |
| } |
|
|
| if (data.rejected?.length) { |
| data.rejected.forEach(rej => { |
| appendLog(`β οΈ Rejected: ${rej}`); |
| notify(`Rejected: ${rej}`, 'warn', 7000); |
| }); |
| } |
|
|
| appendLog(`β
Uploaded ${data.files.length} file(s). Job ID: ${data.job_id}`); |
| ingestStatusTxt.textContent = `Processing ${data.files.join(', ')}β¦`; |
| progressFill.style.width = '20%'; |
| state.currentJobId = data.job_id; |
| pollJobStatus(data.job_id); |
|
|
| } catch(err) { |
| appendLog(`β Network error: ${err.message}`); |
| diag.error('Ingest fetch error:', err); |
| notify(`Upload failed: ${err.message}`, 'error', 8000); |
| state.isIngesting = false; |
| ingestProgress.style.display = 'none'; |
| } finally { |
| diag.groupEnd(); |
| } |
| } |
|
|
| async function pollJobStatus(jobId) { |
| const poll = async () => { |
| try { |
| const r = await fetch(`/api/ingest/status/${jobId}`); |
| if (!r.ok) throw new Error(`Status HTTP ${r.status}`); |
| const data = await r.json(); |
|
|
| if (data.error) { |
| appendLog(`β Job error: ${data.error}`); |
| notify(data.error, 'error', 8000); |
| state.isIngesting = false; |
| ingestProgress.style.display = 'none'; |
| return; |
| } |
|
|
| |
| const allLog = data.log || []; |
| const shown = ingestLog.childElementCount; |
| allLog.slice(shown).forEach(line => appendLog(line)); |
|
|
| const pct = Math.min(20 + (data.results.length / Math.max(data.total, 1)) * 70, 90); |
| progressFill.style.width = `${pct}%`; |
| ingestStatusTxt.textContent = `Processing⦠${data.results.length}/${data.total} file(s) done`; |
|
|
| if (data.status === 'done') { |
| progressFill.style.width = '100%'; |
| const ok = data.results.filter(r => r.ok).length; |
| const bad = data.results.filter(r => !r.ok).length; |
|
|
| |
| data.results.forEach(res => { |
| if (res.ok) { |
| appendLog(`β
${res.file}: ${res.result}`); |
| } else { |
| appendLog(`β ${res.file}: FAILED β ${res.result}`); |
| diag.error('File failed:', res.file, res.result); |
| } |
| }); |
|
|
| ingestStatusTxt.textContent = `Done β ${ok} succeeded${bad ? `, ${bad} failed` : ''}`; |
| if (ok) notify(`Ingested ${ok} file(s) successfully`, 'success', 5000); |
| if (bad) notify(`${bad} file(s) failed β see diagnostic log`, 'error', 8000); |
| if (data.rejected?.length) |
| notify(`${data.rejected.length} file(s) rejected (unsupported type)`, 'warn', 7000); |
|
|
| setTimeout(() => { |
| ingestProgress.style.display = 'none'; |
| progressFill.style.width = '0%'; |
| }, 3000); |
|
|
| state.isIngesting = false; |
| fileInput.value = ''; |
| await loadDocuments(); |
| await refreshStatus(); |
| diag.info('Ingestion complete β ok:', ok, 'failed:', bad); |
| } else { |
| setTimeout(poll, 1200); |
| } |
| } catch(err) { |
| diag.error('pollJobStatus error:', err); |
| appendLog(`β οΈ Poll error: ${err.message} β retryingβ¦`); |
| setTimeout(poll, 3000); |
| } |
| }; |
| poll(); |
| } |
|
|
| |
| $('preset-gen').addEventListener('click', async () => { |
| diag.info('Probing nvidia_llm serverβ¦'); |
| notify('Testing Gen LLM server (port 8002)β¦', 'info', 3000); |
| const btn = $('preset-gen'); |
| btn.disabled = true; |
| try { |
| const r = await fetch('/api/probe/gen', { method: 'POST' }); |
| const data = await r.json(); |
| diag.info('Gen LLM probe result:', data); |
| if (data.ok) { |
| addChatMsg( |
| `π§ <strong>Gen LLM probe succeeded</strong><br>` + |
| `Model: <code>${escHtml(data.model)}</code><br>` + |
| `Response: <em>${escHtml(data.response)}</em>`, |
| 'assistant' |
| ); |
| renderOutput(`## Gen LLM Test β
\n**Model:** \`${data.model}\`\n\n**Response:** ${data.response}`); |
| notify('Gen LLM is online and responding!', 'success'); |
| } else { |
| addChatMsg(`β Gen LLM offline: ${escHtml(data.error)}`, 'assistant'); |
| notify(`Gen LLM probe failed: ${data.error}`, 'error', 8000); |
| } |
| } catch(err) { |
| diag.error('Gen probe fetch error:', err); |
| notify(`Gen LLM probe error: ${err.message}`, 'error', 8000); |
| } |
| btn.disabled = false; |
| }); |
|
|
| |
| const questionSelect = document.getElementById('question-select'); |
| if (questionSelect) { |
| questionSelect.addEventListener('change', () => { |
| const val = questionSelect.value; |
| if (!val) return; |
| queryInput.value = val; |
| queryInput.style.height = 'auto'; |
| queryInput.style.height = Math.min(queryInput.scrollHeight, 160) + 'px'; |
| questionSelect.value = ''; |
| submitQuery(); |
| }); |
| } |
|
|
| $('preset-embed').addEventListener('click', async () => { |
| diag.info('Probing embed_llm serverβ¦'); |
| notify('Testing Embed LLM server (port 8003)β¦', 'info', 3000); |
| const btn = $('preset-embed'); |
| btn.disabled = true; |
| try { |
| const r = await fetch('/api/probe/embed', { method: 'POST' }); |
| const data = await r.json(); |
| diag.info('Embed LLM probe result:', data); |
| if (data.ok) { |
| addChatMsg( |
| `π’ <strong>Embed LLM probe succeeded</strong><br>` + |
| `Model: <code>${escHtml(data.model)}</code><br>` + |
| `Dimension: <strong>${data.dim}</strong> Β· ` + |
| `Sample: <code>[${data.sample.map(v => v.toFixed(4)).join(', ')}β¦]</code>`, |
| 'assistant' |
| ); |
| renderOutput( |
| `## Embed LLM Test β
\n**Model:** \`${data.model}\`\n\n` + |
| `**Embedding dimension:** ${data.dim}\n\n` + |
| `**First 5 values:** \`[${data.sample.map(v => v.toFixed(6)).join(', ')}]\`` |
| ); |
| notify(`Embed LLM is online! Dim=${data.dim}`, 'success'); |
| } else { |
| addChatMsg(`β Embed LLM offline: ${escHtml(data.error)}`, 'assistant'); |
| notify(`Embed LLM probe failed: ${data.error}`, 'error', 8000); |
| } |
| } catch(err) { |
| diag.error('Embed probe fetch error:', err); |
| notify(`Embed LLM probe error: ${err.message}`, 'error', 8000); |
| } |
| btn.disabled = false; |
| }); |
|
|
| |
| document.querySelectorAll('.prompt-fill-btn').forEach(btn => { |
| btn.addEventListener('click', () => { |
| queryInput.value = btn.innerText; |
| queryInput.style.height = 'auto'; |
| queryInput.style.height = Math.min(queryInput.scrollHeight, 160) + 'px'; |
| submitQuery(); |
| }); |
| }); |
|
|
| queryInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| submitQuery(); |
| } |
| }); |
| queryInput.addEventListener('input', () => { |
| queryInput.style.height = 'auto'; |
| queryInput.style.height = Math.min(queryInput.scrollHeight, 160) + 'px'; |
| }); |
| sendBtn.addEventListener('click', submitQuery); |
|
|
| function addChatMsg(html, role) { |
| const wrap = document.createElement('div'); |
| wrap.className = `chat-msg ${role}`; |
| const avatar = role === 'user' ? 'π€' : 'π₯'; |
| const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| wrap.innerHTML = ` |
| <div class="chat-avatar">${avatar}</div> |
| <div> |
| <div class="chat-bubble">${role === 'user' ? escHtml(html) : html}</div> |
| <div class="chat-time">${time}</div> |
| </div>`; |
| chatHistory.appendChild(wrap); |
| wrap.scrollIntoView({ behavior: 'smooth', block: 'end' }); |
| return wrap; |
| } |
|
|
| function addThinkingMsg(qId) { |
| const wrap = document.createElement('div'); |
| wrap.className = 'chat-msg assistant'; |
| wrap.innerHTML = ` |
| <div class="chat-avatar">π₯</div> |
| <div> |
| <div class="chat-bubble"> |
| <div class="milestone-graph" id="milestone-graph-${qId}"> |
| <div class="milestone" id="ms-graph-${qId}"> |
| <div class="milestone-dot"></div> |
| <div> |
| <span>GraphDB <span class="milestone-timer" data-time="0">(0.0s)</span></span> |
| <div class="milestone-chunks"></div> |
| </div> |
| </div> |
| <div class="milestone-line"></div> |
| <div class="milestone" id="ms-vector-${qId}"> |
| <div class="milestone-dot"></div> |
| <div> |
| <span>Vector DB <span class="milestone-timer" data-time="0">(0.0s)</span></span> |
| <div class="milestone-chunks"></div> |
| </div> |
| </div> |
| <div class="milestone-line"></div> |
| <div class="milestone" id="ms-bm25-${qId}"> |
| <div class="milestone-dot"></div> |
| <div> |
| <span>BM25 Search <span class="milestone-timer" data-time="0">(0.0s)</span></span> |
| <div class="milestone-chunks"></div> |
| </div> |
| </div> |
| <div class="milestone-line"></div> |
| <div class="milestone" id="ms-ranking-${qId}"> |
| <div class="milestone-dot"></div> |
| <span>Cross-Encoder Reranking <span class="milestone-timer" data-time="0">(0.0s)</span></span> |
| </div> |
| <div class="milestone-line"></div> |
| <div class="milestone" id="ms-analysis-${qId}"> |
| <div class="milestone-dot"></div> |
| <div> |
| <span>LLM Analysis <span class="milestone-timer" data-time="0">(0.0s)</span></span> |
| <div class="milestone-tokens"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div>`; |
| chatHistory.appendChild(wrap); |
| wrap.scrollIntoView({ behavior: 'smooth', block: 'end' }); |
| return wrap; |
| } |
|
|
| async function submitQuery() { |
| const q = queryInput.value.trim(); |
| if (!q || state.isQuerying) return; |
|
|
| state.isQuerying = true; |
| sendBtn.disabled = true; |
| queryInput.value = ''; |
| queryInput.style.height = 'auto'; |
|
|
| diag.group('Query'); |
| diag.info('Query:', q); |
| addChatMsg(q, 'user'); |
|
|
| |
| if (document.body.classList.contains('is-mobile')) { |
| const chatPanel = document.getElementById('chat-panel'); |
| if (chatPanel) chatPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| } |
| const qId = Date.now(); |
| const thinkingEl = addThinkingMsg(qId); |
|
|
| outputContainer.innerHTML = `<div class="output-placeholder"> |
| <div class="ph-icon">βοΈ</div> |
| <div>Analyzing healthcare policy documentsβ¦</div> |
| </div>`; |
| metricsBanner.style.display = 'none'; |
| metricsBanner.innerHTML = ''; |
| citationsBlock.innerHTML = ''; |
| copyBtn.style.display = 'none'; |
|
|
| let fullText = ''; |
| let chunkCount = 0; |
|
|
| |
| |
| let currentTimerInterval = null; |
| let currentMsId = null; |
| let currentStartTime = 0; |
|
|
| function startTimer(msId) { |
| if (currentTimerInterval) clearInterval(currentTimerInterval); |
| currentMsId = msId; |
| currentStartTime = Date.now(); |
| currentTimerInterval = setInterval(() => { |
| const el = $(currentMsId); |
| if (!el) return; |
| const timerSpan = el.querySelector('.milestone-timer'); |
| if (timerSpan) { |
| const s = (Date.now() - currentStartTime) / 1000; |
| timerSpan.innerText = `(${s.toFixed(1)}s)`; |
| timerSpan.dataset.time = s.toFixed(1); |
| } |
| }, 100); |
| } |
|
|
| function stopTimer() { |
| if (currentTimerInterval) clearInterval(currentTimerInterval); |
| currentTimerInterval = null; |
| } |
|
|
| try { |
| const topK = parseInt($('top-k-select')?.value || 10, 10); |
| const maxTokens = parseInt($('max-tokens-select')?.value || 1024, 10); |
| const useVector = $('chk-vector')?.checked ?? true; |
| const useGraph = $('chk-graph')?.checked ?? true; |
| const useBm25 = $('chk-bm25')?.checked ?? true; |
| const useGpu = $('chk-gpu')?.checked ?? false; |
| const cpuThreads = parseInt($('cpu-cores-select')?.value || 2, 10); |
| diag.info('POST /api/query', { query: q, top_k: topK, max_tokens: maxTokens, use_vector: useVector, use_graph: useGraph, use_bm25: useBm25, use_gpu: useGpu, cpu_threads: cpuThreads }); |
| |
| |
| if (!useGraph) $(`ms-graph-${qId}`)?.classList.add('disabled'); |
| if (!useVector) $(`ms-vector-${qId}`)?.classList.add('disabled'); |
| if (!useBm25) $(`ms-bm25-${qId}`)?.classList.add('disabled'); |
|
|
| const startResp = await fetch('/api/query/start', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ query: q, top_k: topK, max_tokens: maxTokens, use_vector: useVector, use_graph: useGraph, use_bm25: useBm25, use_gpu: useGpu, cpu_threads: cpuThreads }), |
| }); |
|
|
| if (!startResp.ok) { |
| const errData = await startResp.json(); |
| throw new Error(errData.error || `HTTP ${startResp.status}`); |
| } |
|
|
| const startData = await startResp.json(); |
| const jobId = startData.job_id; |
|
|
| let currentOffset = 0; |
| let jobDone = false; |
|
|
| while (!jobDone) { |
| try { |
| const resp = await fetch(`/api/query/stream/${jobId}?offset=${currentOffset}`); |
| if (!resp.ok) { |
| const errData = await resp.json().catch(() => ({})); |
| if (resp.status === 404) throw new Error("Job not found or expired on server."); |
| throw new Error(errData.error || `HTTP ${resp.status}`); |
| } |
|
|
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| while (!jobDone) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
|
|
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| let payload; |
| try { payload = JSON.parse(line.slice(6)); } |
| catch(pe) { diag.warn('SSE parse error:', pe, line); continue; } |
|
|
| |
| currentOffset++; |
|
|
| if (payload.error) { |
| jobDone = true; |
| throw new Error(payload.error); |
| } |
|
|
| if (payload.status) { |
| diag.info('SSE status:', payload.status); |
| const msGraph = $(`ms-graph-${qId}`); |
| const msVector = $(`ms-vector-${qId}`); |
| const msBm25 = $(`ms-bm25-${qId}`); |
| const msRanking = $(`ms-ranking-${qId}`); |
| const msAnalysis = $(`ms-analysis-${qId}`); |
| if (!msGraph || !msAnalysis) continue; |
|
|
| |
| [msGraph, msVector, msBm25, msRanking, msAnalysis].forEach(el => { |
| if (!el) return; |
| const dot = el.querySelector('.milestone-dot'); |
| if (dot && dot.className.includes('executing')) { |
| dot.className = 'milestone-dot complete'; |
| } |
| }); |
|
|
| if (payload.status === 'graph') { |
| msGraph.querySelector('.milestone-dot').className = 'milestone-dot executing'; |
| startTimer(`ms-graph-${qId}`); |
| } else if (payload.status === 'inference' || payload.status === 'vector') { |
| stopTimer(); |
| if(msGraph) msGraph.querySelector('.milestone-dot').className = 'milestone-dot complete'; |
| if(msVector) msVector.querySelector('.milestone-dot').className = 'milestone-dot executing'; |
| startTimer(`ms-vector-${qId}`); |
| } else if (payload.status === 'bm25') { |
| stopTimer(); |
| if(msVector) msVector.querySelector('.milestone-dot').className = 'milestone-dot complete'; |
| if(msBm25) msBm25.querySelector('.milestone-dot').className = 'milestone-dot executing'; |
| startTimer(`ms-bm25-${qId}`); |
| } else if (payload.status === 'reranking') { |
| stopTimer(); |
| if(msBm25) msBm25.querySelector('.milestone-dot').className = 'milestone-dot complete'; |
| if(msRanking) msRanking.querySelector('.milestone-dot').className = 'milestone-dot executing'; |
| startTimer(`ms-ranking-${qId}`); |
| } else if (payload.status === 'gatekeeping') { |
| |
| stopTimer(); |
| if(msRanking) msRanking.querySelector('.milestone-dot').className = 'milestone-dot complete'; |
| } else if (payload.status === 'analysis') { |
| stopTimer(); |
| if(msRanking) msRanking.querySelector('.milestone-dot').className = 'milestone-dot complete'; |
| if(msAnalysis) msAnalysis.querySelector('.milestone-dot').className = 'milestone-dot executing'; |
| startTimer(`ms-analysis-${qId}`); |
| } |
| |
| if (payload.chunks !== undefined) { |
| let targetMs = null; |
| if (payload.status === 'graph') targetMs = msGraph; |
| if (payload.status === 'vector') targetMs = msVector; |
| if (payload.status === 'bm25') targetMs = msBm25; |
| if (targetMs) { |
| const chunkDiv = targetMs.querySelector('.milestone-chunks'); |
| if (chunkDiv) { |
| chunkDiv.innerText = `${payload.chunks} chunk(s) retrieved`; |
| chunkDiv.classList.add('visible'); |
| } |
| } |
| } |
| } |
|
|
| |
| if (payload.chunk) { |
| |
| if (currentMsId) { |
| const el = $(currentMsId); |
| const dot = el ? el.querySelector('.milestone-dot') : null; |
| if (dot && dot.className.includes('executing')) { |
| dot.className = 'milestone-dot complete'; |
| stopTimer(); |
| } |
| } |
| fullText += payload.chunk; |
| chunkCount++; |
| renderOutput(fullText); |
| } |
|
|
| |
| if (payload.metrics) { |
| const m = payload.metrics; |
| const min = Math.floor(m.time_seconds / 60); |
| const sec = Math.round(m.time_seconds % 60); |
| const timeStr = min > 0 ? `${min}m ${sec}s` : `${sec}s`; |
| const carbonStr = m.carbon_kg < 0.001 ? '< 1g' : (m.carbon_kg * 1000).toFixed(2) + 'g'; |
| renderMetricsBanner(m.tokens_in, m.tokens_out, timeStr, carbonStr); |
| |
| const localMsAnalysis = document.getElementById(`ms-analysis-${qId}`); |
| if (localMsAnalysis) { |
| const tokDiv = localMsAnalysis.querySelector('.milestone-tokens'); |
| if (tokDiv) { |
| tokDiv.innerText = `In: ${m.tokens_in} | Out: ${m.tokens_out}`; |
| } |
| } |
| } |
|
|
| if (payload.done) { |
| diag.info('SSE: done'); |
| jobDone = true; |
| break; |
| } |
| } |
| } |
| } catch (err) { |
| if (err.message.includes("Job not found") || jobDone) { |
| throw err; |
| } |
| diag.warn('Network dropped, reconnecting to job...', err); |
| await new Promise(r => setTimeout(r, 1500)); |
| } |
| } |
|
|
| diag.info(`Query complete β ${chunkCount} SSE chunks, ${fullText.length} chars`); |
| stopTimer(); |
| |
| [$(`ms-inference-${qId}`), $(`ms-kuzu-${qId}`), $(`ms-analysis-${qId}`)].forEach(el => { |
| if (!el) return; |
| const dot = el.querySelector('.milestone-dot'); |
| if (dot && !dot.className.includes('failed')) dot.className = 'milestone-dot complete'; |
| }); |
| addChatMsg('Answer generated β see the Output panel β', 'assistant'); |
| copyBtn.style.display = 'block'; |
| state.lastAnswer = fullText; |
| notify('Answer ready!', 'success', 2500); |
|
|
| |
| if (document.body.classList.contains('is-mobile')) { |
| const outputPanel = document.getElementById('output-panel'); |
| if (outputPanel) setTimeout(() => outputPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }), 300); |
| } |
|
|
| } catch(err) { |
| diag.error('Query error:', err); |
| stopTimer(); |
| |
| let errorMsg = err.message; |
|
|
| if (currentMsId) { |
| const el = $(currentMsId); |
| if (el) el.querySelector('.milestone-dot').className = 'milestone-dot failed'; |
| } |
| addChatMsg(`β ${escHtml(errorMsg)}`, 'assistant'); |
| outputContainer.innerHTML = `<div class="output-placeholder" style="color:var(--red)"> |
| <div class="ph-icon">β οΈ</div><div>${escHtml(errorMsg)}</div> |
| </div>`; |
| notify(errorMsg, 'error', 8000); |
| } finally { |
| state.isQuerying = false; |
| sendBtn.disabled = false; |
| diag.groupEnd(); |
| } |
| } |
|
|
| |
| function renderOutput(mdText) { |
| outputContainer.innerHTML = marked.parse(mdText); |
| outputContainer.scrollTop = outputContainer.scrollHeight; |
| } |
|
|
| |
| function renderMetricsBanner(tokIn, tokOut, timeStr, carbonStr) { |
| metricsBanner.innerHTML = ` |
| <div class="metrics-banner-inner"> |
| <span class="metrics-title">π Inference Metrics</span> |
| <span class="metrics-pill">π’ <strong>${tokIn}</strong> in / <strong>${tokOut}</strong> out tokens</span> |
| <span class="metrics-pill">β± <strong>${timeStr}</strong></span> |
| <span class="metrics-pill">π± <strong>${carbonStr}</strong> COβ</span> |
| </div>`; |
| metricsBanner.style.display = 'block'; |
| } |
|
|
| function extractAndShowCitations(mdText) { |
| const matches = [...new Set(mdText.match(/\[Source:\s*([^\]]+)\]/g) || [])]; |
| if (!matches.length) return; |
| citationsBlock.innerHTML = '<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">π Sources</div>' |
| + matches.map(m => `<span class="citation-tag">π ${escHtml(m)}</span>`).join(''); |
| } |
|
|
| |
| copyBtn.addEventListener('click', async () => { |
| try { |
| await navigator.clipboard.writeText(outputContainer.innerText); |
| copyBtn.textContent = 'β Copied!'; |
| notify('Response copied to clipboard', 'info', 2000); |
| } catch(err) { |
| diag.error('Clipboard write failed:', err); |
| notify('Could not copy β try selecting text manually', 'warn'); |
| } |
| setTimeout(() => { copyBtn.textContent = 'β Copy Response'; }, 2000); |
| }); |
|
|
| |
| function escHtml(s) { |
| return String(s) |
| .replace(/&/g,'&').replace(/</g,'<') |
| .replace(/>/g,'>').replace(/"/g,'"'); |
| } |
|
|
| |
|
|
| const autoIngestBanner = $('auto-ingest-banner'); |
| const autoIngestBar = $('auto-ingest-bar'); |
| const autoIngestLabel = $('auto-ingest-label'); |
| const autoIngestSub = $('auto-ingest-sub'); |
| const autoIngestCount = $('auto-ingest-count'); |
|
|
| let _autoIngestPollTimer = null; |
| let _autoIngestDoneNotified = false; |
|
|
| async function pollAutoIngestStatus() { |
| try { |
| const r = await fetch('/api/auto-ingest/status'); |
| if (!r.ok) return; |
| const d = await r.json(); |
|
|
| const isActive = d.running || (d.total > 0 && !d.done); |
| const isDone = d.done && d.total > 0; |
|
|
| if (isActive) { |
| |
| if (autoIngestBanner) { |
| autoIngestBanner.style.display = 'block'; |
| document.body.classList.add('ingest-banner-visible'); |
| } |
| const pct = d.total > 0 ? Math.round((d.completed / d.total) * 100) : 5; |
| if (autoIngestBar) autoIngestBar.style.width = pct + '%'; |
| if (autoIngestLabel) autoIngestLabel.textContent = 'Indexing knowledge baseβ¦'; |
| if (autoIngestSub) autoIngestSub.textContent = d.current_file ? `Processing: ${d.current_file}` : 'Preparingβ¦'; |
| if (autoIngestCount) autoIngestCount.textContent = `${d.completed} / ${d.total}`; |
|
|
| } else if (isDone && !_autoIngestDoneNotified) { |
| |
| _autoIngestDoneNotified = true; |
| if (autoIngestBanner) autoIngestBanner.style.display = 'block'; |
| if (autoIngestBar) autoIngestBar.style.width = '100%'; |
|
|
| const ok = (d.results || []).filter(x => x.ok).length; |
| const bad = (d.results || []).filter(x => !x.ok).length; |
|
|
| if (autoIngestLabel) autoIngestLabel.textContent = `Knowledge base ready β ${ok} file${ok !== 1 ? 's' : ''} indexed`; |
| if (autoIngestSub) autoIngestSub.textContent = bad > 0 ? `β οΈ ${bad} file(s) failed` : 'β All files ingested successfully'; |
| if (autoIngestCount) autoIngestCount.textContent = `${ok} / ${d.total}`; |
|
|
| if (ok > 0) notify(`Auto-ingest complete: ${ok} knowledge base file${ok !== 1 ? 's' : ''} indexed`, 'success', 6000); |
| if (bad > 0) notify(`Auto-ingest: ${bad} file(s) failed to ingest`, 'warn', 8000); |
|
|
| |
| await loadDocuments(); |
| await refreshStatus(); |
|
|
| |
| setTimeout(() => { |
| if (autoIngestBanner) autoIngestBanner.style.display = 'none'; |
| document.body.classList.remove('ingest-banner-visible'); |
| }, 4000); |
|
|
| |
| if (_autoIngestPollTimer) clearInterval(_autoIngestPollTimer); |
| _autoIngestPollTimer = null; |
| return; |
|
|
| } else if (d.error && !_autoIngestDoneNotified) { |
| _autoIngestDoneNotified = true; |
| notify(`Auto-ingest error: ${d.error}`, 'error', 8000); |
| if (_autoIngestPollTimer) clearInterval(_autoIngestPollTimer); |
| _autoIngestPollTimer = null; |
| return; |
|
|
| } else { |
| |
| if (autoIngestBanner) autoIngestBanner.style.display = 'none'; |
| document.body.classList.remove('ingest-banner-visible'); |
| } |
|
|
| } catch (err) { |
| diag.warn('pollAutoIngestStatus error:', err); |
| } |
| } |
|
|
| |
| (async () => { |
| diag.info('App initialisingβ¦'); |
| await refreshStatus(); |
| await loadDocuments(); |
| await pollSysInfo(); |
| await pollAutoIngestStatus(); |
|
|
| setInterval(refreshStatus, 30_000); |
| setInterval(pollSysInfo, 10_000); |
|
|
| |
| _autoIngestPollTimer = setInterval(pollAutoIngestStatus, 2000); |
|
|
| diag.info('App ready.'); |
| })(); |
|
|