'use strict';
// ── Logging helper (browser console with timestamps) ───────────────────────
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(),
};
// ── Device detection ──────────────────────────────────────────────────────────
// Applies `body.is-mobile` when the device is a phone/touch device OR the
// viewport is narrower than 768px. Re-evaluated on every resize so the layout
// adapts when the window is resized (e.g. DevTools responsive mode).
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;
}
// Run immediately so the correct class is present before first paint
detectDevice();
// Re-run on resize (debounced to avoid excessive calls)
let _resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(detectDevice, 150);
});
// ── State ──────────────────────────────────────────────────────────────────────
const state = {
isQuerying: false,
isIngesting: false,
currentJobId: null,
lastAnswer: '',
isAdmin: false,
};
// ── DOM refs ───────────────────────────────────────────────────────────────────
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');
// ── Notifications ──────────────────────────────────────────────────────────────
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);
}
// ── Diagnostic log (ingestion accordion) ──────────────────────────────────────
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 = '';
}
// ── Status bar ─────────────────────────────────────────────────────────────────
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;
// Admin controls: only show if server reports admin mode enabled AND caller 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';
});
}
}
// ── Admin controls ────────────────────────────────────────────────────────────
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 =
`
${escHtml(data.output || 'No output')}`;
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 = "APPLICATION TERMINATED
Please restart the server manually.
";
} 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'; });
// ── Resource Monitor Banner ────────────────────────────────────────────────────
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;
// CPU
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) {
// Shorten: show first word of brand + core count
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}`;
}
// RAM
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`;
// Disk
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`;
// Mode badge
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');
}
// GPU checkbox — show only if GPU is available
const gpuItem = $('res-gpu');
if (gpuItem) {
gpuItem.style.display = d.gpu_available ? 'flex' : 'none';
}
// CPU cores — populate up to system max (powers of 2)
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;
}
// Ensure at least value=2 is present even if cpu_cores < 2
if (!cpuCoresSelect.options.length) {
const opt = document.createElement('option');
opt.value = '2'; opt.textContent = '2'; opt.selected = true;
cpuCoresSelect.appendChild(opt);
}
}
// Graph Extraction Active
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);
}
}
// ── Document list ──────────────────────────────────────────────────────────────
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 = '📂 No documents ingested yet.
Upload files above to get started.
';
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'
? `FOUNDATION`
: `EXTENDED`;
item.innerHTML = `
${icon}
${escHtml(doc.source)} ${tierBadge}
${doc.file_type.toUpperCase()}
`;
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);
// ── Drag & Drop & Tier Selection ───────────────────────────────────────────
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);
});
// ── Ingestion ──────────────────────────────────────────────────────────────────
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;
}
// Sync new log lines
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;
// Show per-file results
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();
}
// ── Default prompt buttons ─────────────────────────────────────────────────────
$('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(
`🧠 Gen LLM probe succeeded
` +
`Model: ${escHtml(data.model)}
` +
`Response: ${escHtml(data.response)}`,
'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;
});
// ── Question dropdown ─────────────────────────────────────────────────────────
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(
`🔢 Embed LLM probe succeeded
` +
`Model: ${escHtml(data.model)}
` +
`Dimension: ${data.dim} · ` +
`Sample: [${data.sample.map(v => v.toFixed(4)).join(', ')}…]`,
'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;
});
// ── Query / Chat ───────────────────────────────────────────────────────────────
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 = `
${avatar}
${role === 'user' ? escHtml(html) : html}
${time}
`;
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 = `
🏥
Cross-Encoder Reranking (0.0s)
`;
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');
// On mobile, scroll the chat panel into view so the user sees the response
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 = `
⚙️
Analyzing healthcare policy documents…
`;
metricsBanner.style.display = 'none';
metricsBanner.innerHTML = '';
citationsBlock.innerHTML = '';
copyBtn.style.display = 'none';
let fullText = '';
let chunkCount = 0;
// Hoisted outside try{} so catch{} and finally{} can access them
// (let/function inside try{} are block-scoped and invisible to catch{})
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 });
// Grey out disabled milestones
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; }
// Successfully received a valid JSON payload, increment offset for next reconnect
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;
// Mark previous executing dot as complete before starting next
[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') {
// Gatekeeper is instant
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');
}
}
}
}
// ── Answer chunks → render output ───────────────────────────
if (payload.chunk) {
// Mark analysis milestone complete on first chunk arrival
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);
}
// ── Metrics → banner at top of output panel ─────────────────
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();
// Ensure all milestone dots are marked complete
[$(`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);
// On mobile, scroll to output panel after answer is ready
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 = ``;
notify(errorMsg, 'error', 8000);
} finally {
state.isQuerying = false;
sendBtn.disabled = false;
diag.groupEnd();
}
}
// ── Output rendering ───────────────────────────────────────────────────────────
function renderOutput(mdText) {
outputContainer.innerHTML = marked.parse(mdText);
outputContainer.scrollTop = outputContainer.scrollHeight;
}
// ── Metrics banner (pinned at top of output panel) ────────────────────────────
function renderMetricsBanner(tokIn, tokOut, timeStr, carbonStr) {
metricsBanner.innerHTML = `
📊 Inference Metrics
🔢 ${tokIn} in / ${tokOut} out tokens
⏱ ${timeStr}
🌱 ${carbonStr} CO₂
`;
metricsBanner.style.display = 'block';
}
function extractAndShowCitations(mdText) {
const matches = [...new Set(mdText.match(/\[Source:\s*([^\]]+)\]/g) || [])];
if (!matches.length) return;
citationsBlock.innerHTML = '📌 Sources
'
+ matches.map(m => `📎 ${escHtml(m)}`).join('');
}
// ── Copy button ────────────────────────────────────────────────────────────────
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);
});
// ── Utils ──────────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// ── Auto-ingest background progress banner ─────────────────────────────────────
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) {
// Show and update banner
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) {
// Show completion state briefly then hide
_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);
// Refresh document list to show newly ingested docs
await loadDocuments();
await refreshStatus();
// Fade out banner after 4 seconds
setTimeout(() => {
if (autoIngestBanner) autoIngestBanner.style.display = 'none';
document.body.classList.remove('ingest-banner-visible');
}, 4000);
// Stop polling
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 {
// Not started yet or no files — keep banner hidden
if (autoIngestBanner) autoIngestBanner.style.display = 'none';
document.body.classList.remove('ingest-banner-visible');
}
} catch (err) {
diag.warn('pollAutoIngestStatus error:', err);
}
}
// ── Init ───────────────────────────────────────────────────────────────────────
(async () => {
diag.info('App initialising…');
await refreshStatus();
await loadDocuments();
await pollSysInfo(); // Initial resource banner population
await pollAutoIngestStatus(); // Check if auto-ingest is already running
setInterval(refreshStatus, 30_000);
setInterval(pollSysInfo, 10_000); // Update resource banner every 10 s
// Poll auto-ingest every 2 s (self-cancels when done)
_autoIngestPollTimer = setInterval(pollAutoIngestStatus, 2000);
diag.info('App ready.');
})();