// Sentinel AI — frontend logic. // Features: live analysis, example chips, session history, confidence bars, // keyboard shortcut (Ctrl+Enter), share via URL, dark/light mode toggle. const GAUGE_CIRCUMFERENCE = 2 * Math.PI * 52; const MAX_HISTORY = 8; let sessionHistory = []; let lastResult = null; function $(id) { return document.getElementById(id); } // ---------------- Theme ---------------- const THEME_KEY = 'sentinel-theme'; function applyTheme(theme) { document.body.classList.toggle('light', theme === 'light'); const btn = $('theme-toggle'); if (btn) btn.textContent = theme === 'light' ? '●' : '◐'; // Re-render chart so axis/gridline colors update for the new theme renderPRChart(); } function initTheme() { const saved = localStorage.getItem(THEME_KEY) || 'dark'; applyTheme(saved); const btn = $('theme-toggle'); if (btn) { btn.addEventListener('click', () => { const next = document.body.classList.contains('light') ? 'dark' : 'light'; localStorage.setItem(THEME_KEY, next); applyTheme(next); }); } } initTheme(); // ---------------- Keyboard shortcut ---------------- document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); const form = $('analyze-form'); if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } }); // ---------------- Chips ---------------- document.querySelectorAll('.chip').forEach((chip) => { chip.addEventListener('click', () => { const text = chip.dataset.text; const input = $('text-input'); if (!input) return; input.value = text; input.focus(); $('analyze-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); }); // ---------------- Analyze flow ---------------- const form = $('analyze-form'); const textInput = $('text-input'); const runBtn = $('run-btn'); const runHint = $('run-hint'); const errorBox = $('error-box'); const resultBlock = $('result-block'); // Auto-run from URL ?q= param (function checkUrlParam() { const params = new URLSearchParams(window.location.search); const q = params.get('q'); if (q && textInput) { textInput.value = q; setTimeout(() => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }, 400); } })(); form.addEventListener('submit', async (e) => { e.preventDefault(); const text = textInput.value.trim(); if (!text) { showError('Please enter some text to analyze.'); return; } setLoading(true); hideError(); try { const res = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); const data = await res.json(); if (!res.ok) { showError(data.error || 'Something went wrong.'); setLoading(false); return; } lastResult = data; renderResult(data); addToHistory(data); updateShareUrl(text); } catch (err) { showError('Could not reach the server. Check your connection.'); } finally { setLoading(false); } }); function setLoading(isLoading) { runBtn.disabled = isLoading; runBtn.textContent = isLoading ? 'ANALYZING...' : 'RUN ANALYSIS'; runHint.textContent = isLoading ? 'running on cpu...' : '~1-3s on cpu · ctrl+enter'; const status = $('sys-status'); if (status) { status.textContent = isLoading ? 'BUSY' : 'READY'; status.className = isLoading ? 'status-warn' : 'status-ok'; } } function showError(msg) { errorBox.textContent = msg; errorBox.hidden = false; } function hideError() { errorBox.hidden = true; } // ---------------- Render result ---------------- function renderResult(data) { resultBlock.hidden = false; const banner = $('verdict-banner'); const dot = $('verdict-dot'); const text = $('verdict-text'); const sub = $('verdict-sub'); if (data.is_failure_risk) { banner.className = 'verdict-banner warn'; dot.className = 'dot warn'; text.className = 'verdict-text warn'; } else { banner.className = 'verdict-banner ok'; dot.className = 'dot ok'; text.className = 'verdict-text'; } text.textContent = data.warning; sub.textContent = `failure probability ${data.failure_probability.toFixed(3)} · threshold ${data.failure_threshold.toFixed(3)}`; // Gauge const gaugeFill = $('gauge-fill'); const gaugeValue = $('gauge-value'); const pct = Math.max(0, Math.min(1, data.failure_probability)); const offset = GAUGE_CIRCUMFERENCE * (1 - pct); gaugeFill.style.transition = 'none'; gaugeFill.style.strokeDashoffset = GAUGE_CIRCUMFERENCE; void gaugeFill.offsetWidth; gaugeFill.style.transition = ''; gaugeFill.style.strokeDashoffset = offset; gaugeFill.style.stroke = data.is_failure_risk ? '#fbbf24' : '#4ade80'; gaugeValue.textContent = `${Math.round(pct * 100)}%`; // Evidence rows + confidence bars const bertConf = data.bert_confidence; const vaderConf = Math.min(1, Math.abs(data.vader_score)); const lrConf = data.lr_confidence; const entropy = data.bert_entropy; $('ev-bert').textContent = `${data.bert_label} (${(bertConf * 100).toFixed(1)}%)`; $('ev-bert').className = 'v ' + (data.bert_label === 'positive' ? 'pos' : 'neg'); setBar('bar-bert', bertConf, data.bert_label === 'positive'); $('ev-vader').textContent = data.vader_agrees ? `agrees · ${data.vader_label}` : `disagrees · ${data.vader_label}`; $('ev-vader').className = 'v ' + (data.vader_label === 'positive' ? 'pos' : 'neg'); setBar('bar-vader', vaderConf, data.vader_label === 'positive'); $('ev-lr').textContent = data.lr_agrees ? `agrees · ${data.lr_label}` : `disagrees · ${data.lr_label}`; $('ev-lr').className = 'v ' + (data.lr_label === 'positive' ? 'pos' : 'neg'); setBar('bar-lr', lrConf, data.lr_label === 'positive'); $('ev-entropy').textContent = entropy.toFixed(3); $('ev-entropy').className = 'v'; const entropyBar = $('bar-entropy'); if (entropyBar) { entropyBar.style.width = `${Math.round(entropy * 100)}%`; } resultBlock.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function setBar(id, value, isPositive) { const bar = $(id); if (!bar) return; bar.style.width = `${Math.round(value * 100)}%`; bar.style.background = isPositive ? 'var(--green)' : 'var(--red)'; } // ---------------- Share URL ---------------- function updateShareUrl(text) { const url = new URL(window.location.href); url.searchParams.set('q', text); window.history.replaceState({}, '', url.toString()); } // ---------------- Session history ---------------- function addToHistory(data) { sessionHistory.unshift(data); if (sessionHistory.length > MAX_HISTORY) sessionHistory = sessionHistory.slice(0, MAX_HISTORY); renderHistory(); } function renderHistory() { const panel = $('history-panel'); const list = $('history-list'); if (!panel || !list) return; panel.hidden = sessionHistory.length === 0; list.innerHTML = ''; sessionHistory.forEach((entry, i) => { const item = document.createElement('div'); item.className = 'history-item'; item.innerHTML = `
${escapeHtml(entry.text)} ${(entry.failure_probability * 100).toFixed(0)}% `; item.addEventListener('click', () => { textInput.value = entry.text; renderResult(entry); resultBlock.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); list.appendChild(item); }); } function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ---------------- PR chart ---------------- function renderPRChart() { const dataEl = $('metrics-data'); const chartEl = $('pr-chart'); if (!dataEl || !chartEl) return; let metrics; try { metrics = JSON.parse(dataEl.textContent); } catch (e) { return; } if (!metrics || !metrics.pr_curve || metrics.pr_curve.length === 0) return; const width = 400; const height = 200; const padding = { top: 10, right: 16, bottom: 32, left: 48 }; const plotW = width - padding.left - padding.right; const plotH = height - padding.top - padding.bottom; function xPos(recall) { return padding.left + recall * plotW; } function yPos(precision) { return padding.top + (1 - precision) * plotH; } const sorted = [...metrics.pr_curve].sort((a, b) => a.recall - b.recall); const pathD = sorted .map((p, i) => `${i === 0 ? 'M' : 'L'} ${xPos(p.recall).toFixed(1)} ${yPos(p.precision).toFixed(1)}`) .join(' '); // Read current theme colors so chart looks right in both light and dark mode const rootStyle = getComputedStyle(document.documentElement); const colorBorder = getComputedStyle(document.body).getPropertyValue('--border').trim() || '#1a1a1a'; const colorMuted = getComputedStyle(document.body).getPropertyValue('--text-muted').trim() || '#6b6b6b'; const colorFaint = getComputedStyle(document.body).getPropertyValue('--text-faint').trim() || '#4a4a4a'; const colorBg = getComputedStyle(document.body).getPropertyValue('--bg').trim() || '#0a0a0a'; let svg = ''; [0, 0.25, 0.5, 0.75, 1].forEach((t) => { const y = yPos(t); svg += `