feat: implement 7 JARVIS UI enhancements

#1  Voice waveform: Web Audio API drives wave-bar heights in real time
#2  Ambient dim mode: panels fade to 12% after 90s idle
#6  Streaming AI replies: Groq tokens via SSE; frontend ReadableStream
#7  Quick-note capture: N key / "note: text" saves to kb_facts instantly
#8  Cancel in-flight request: AbortController + CANCEL button
#9  Accent color themes: Stark Blue / Widow Red / Hulk Green, localStorage
#10 Browser push notifications: critical alerts when tab is backgrounded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:39:45 +00:00
parent 58070c7f06
commit 6195f9bd3b
5 changed files with 368 additions and 29 deletions
+195 -28
View File
@@ -196,6 +196,17 @@ function showApp(name, greeting, silent = false) {
setTimeout(checkSuggestions, 15000);
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
setInterval(pollAlertsProactive, 60000); // poll every 60s
setInterval(() => {
const layout = document.getElementById('mainLayout');
if (!layout) return;
if (Date.now() - lastActivity > 90000) layout.classList.add('ambient-dim-active');
else layout.classList.remove('ambient-dim-active');
}, 5000);
setTimeout(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}, 9000);
// Guardian Mode — badge refresh + proactive chat
setTimeout(() => {
_refreshGuardianBadge();
@@ -1101,7 +1112,7 @@ function showThinking() {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg jarvis';
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div>';
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div><button class="thinking-cancel" onclick="cancelRequest()">✕ CANCEL</button>';
div.id = 'thinking-bubble';
log.appendChild(div);
log.scrollTop = log.scrollHeight;
@@ -1144,26 +1155,18 @@ async function sendMessage() {
const text = input.value.trim();
if (!text) return;
// Local commands — no API round-trip
var t2 = text.toLowerCase();
// Sleep command
if (SLEEP_CMDS.test(t2)) {
input.value = '';
addMessage('user', text);
enterSleepMode();
return;
}
if (SLEEP_CMDS.test(t2)) { input.value=''; addMessage('user',text); enterSleepMode(); return; }
if (NM_OPEN_RE.test(t2)) {
input.value=''; addMessage('user',text);
addMessage('jarvis','Launching network topology display.');
speak('Launching network topology display.');
openNetMap(); return;
speak('Launching network topology display.'); openNetMap(); return;
}
if (NM_CLOSE_RE.test(t2)) {
input.value=''; addMessage('user',text);
var isOpen=document.getElementById('netMapOverlay')&&document.getElementById('netMapOverlay').classList.contains('nm-open');
var isOpen=document.getElementById('netMapOverlay')?.classList.contains('nm-open');
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
else addMessage('jarvis','Network map is not currently active.');
return;
@@ -1171,32 +1174,108 @@ async function sendMessage() {
input.value = '';
addMessage('user', text);
showThinking();
_abortController = new AbortController();
try {
const payload = {message:text, session_id:sessionId};
if (selectedContext) {
payload.context = selectedContext;
clearContext();
}
const data = await api('chat', 'POST', payload);
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
const payload = {message:text, session_id:sessionId, stream:true};
if (selectedContext) { payload.context = selectedContext; clearContext(); }
if (data.reply) {
addMessage('jarvis', data.reply, data.source || null);
speak(data.reply);
const resp = await fetch('/api.php?action=chat', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Session-Token':sessionToken},
body: JSON.stringify(payload),
signal: _abortController.signal,
credentials: 'include',
});
_abortController = null;
if (resp.status === 401) { logout(); return; }
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('text/event-stream')) {
// ── Streaming path (Groq LLM with token-by-token delivery) ──────
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
let msgEl = null, accum = '';
const reader = resp.body.getReader();
const dec = new TextDecoder();
let lineBuf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
lineBuf += dec.decode(value, {stream:true});
const lines = lineBuf.split('\n');
lineBuf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; }
if (ev.type === 'token') {
accum += ev.token;
if (!msgEl) msgEl = _addStreamingMsg(accum);
else _updateStreamingMsg(msgEl, accum);
} else if (ev.type === 'complete') {
const finalText = ev.reply || accum;
if (msgEl) _finalizeStreamingMsg(msgEl, finalText, ev.source);
else addMessage('jarvis', finalText, ev.source);
speak(finalText);
if (ev.open_network_map) openNetMap();
if (ev.ui_action === 'focus_mode' && panelsVisible) togglePanels(true);
if (ev.ui_action === 'show_panels' && !panelsVisible) togglePanels(true);
if (ev.arc_job) onArcJobStarted(ev.arc_job, ev.source||'');
}
}
}
} else {
// ── Regular JSON path (intent/KB — near-instant) ────────────────
const data = await resp.json();
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
if (data.reply) { addMessage('jarvis', data.reply, data.source||null); speak(data.reply); }
if (data.open_network_map) openNetMap();
if (data.ui_action === 'focus_mode' && panelsVisible) togglePanels(true);
if (data.ui_action === 'show_panels' && !panelsVisible) togglePanels(true);
if (data.arc_job) onArcJobStarted(data.arc_job, data.source||'');
}
if (data.open_network_map) { openNetMap(); }
if (data.ui_action === 'focus_mode') { if (panelsVisible) togglePanels(true); }
if (data.ui_action === 'show_panels') { if (!panelsVisible) togglePanels(true); }
if (data.arc_job) { onArcJobStarted(data.arc_job, data.source || ''); }
} catch(e) {
_abortController = null;
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
if (e.name === 'AbortError') addMessage('jarvis', 'Request cancelled, Sir.');
else addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
}
}
function _addStreamingMsg(text) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg jarvis streaming';
div.id = 'streaming-bubble';
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
return div;
}
function _updateStreamingMsg(el, text) {
if (!el) return;
el.textContent = text;
const log = document.getElementById('chatLog');
if (log) log.scrollTop = log.scrollHeight;
}
function _finalizeStreamingMsg(el, text, source) {
if (!el) return;
el.id = ''; el.classList.remove('streaming');
el.textContent = text;
if (source) {
const s = document.createElement('div');
s.className = 'msg-source'; s.textContent = source;
el.appendChild(s);
}
}
function cancelRequest() {
if (_abortController) { _abortController.abort(); _abortController = null; }
}
// ── VOICE RECOGNITION ─────────────────────────────────────────────────
function initVoice() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -1371,6 +1450,7 @@ function startListening() {
return;
}
isListening = true;
_startWaveform();
_scheduleRecStart(50);
}
@@ -1380,9 +1460,42 @@ function stopListening() {
voiceMuted = false;
updateMicBtn();
clearTimeout(_recTimer);
_stopWaveform();
try { recognition.abort(); } catch(_) {}
}
// ── VOICE WAVEFORM (Web Audio API) ──────────────────────────────────────────
async function _startWaveform() {
if (_waveAudioCtx) return;
try {
_waveStream = await navigator.mediaDevices.getUserMedia({audio:true, video:false});
_waveAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
_waveAnalyser = _waveAudioCtx.createAnalyser();
_waveAnalyser.fftSize = 32;
_waveAudioCtx.createMediaStreamSource(_waveStream).connect(_waveAnalyser);
const bars = document.querySelectorAll('#waveform .wave-bar');
bars.forEach(b => b.classList.add('live'));
const buf = new Uint8Array(_waveAnalyser.frequencyBinCount);
(function drawWave() {
_waveRafId = requestAnimationFrame(drawWave);
_waveAnalyser.getByteFrequencyData(buf);
bars.forEach((bar, i) => {
const v = (buf[i % buf.length] || 0) / 255;
bar.style.height = (4 + Math.round(v * 20)) + 'px';
});
})();
} catch(_) { /* mic permission denied — CSS animation continues */ }
}
function _stopWaveform() {
if (_waveRafId) { cancelAnimationFrame(_waveRafId); _waveRafId = null; }
if (_waveStream) { _waveStream.getTracks().forEach(t => t.stop()); _waveStream = null; }
if (_waveAudioCtx) { _waveAudioCtx.close().catch(()=>{}); _waveAudioCtx = null; }
_waveAnalyser = null;
document.querySelectorAll('#waveform .wave-bar').forEach(b => {
b.classList.remove('live'); b.style.height = '';
});
}
// ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
function loadVoices() {
const set = () => {
@@ -1405,6 +1518,11 @@ function loadVoices() {
}
let _ttsAudio = null;
let _abortController = null;
let _waveAudioCtx = null;
let _waveAnalyser = null;
let _waveStream = null;
let _waveRafId = null;
async function speak(text) {
if (!text) return;
@@ -1547,6 +1665,53 @@ async function triggerMorningBriefing() {
} catch(e) {}
}
// ── ACCENT COLOR THEMES ───────────────────────────────────────────────────────
const _THEMES = {
'stark-blue': {'--cyan':'#00d4ff','--cyan2':'#00a8cc','--cyan3':'rgba(0,212,255,0.15)'},
'widow-red': {'--cyan':'#ff3366','--cyan2':'#cc1a44','--cyan3':'rgba(255,51,102,0.15)'},
'hulk-green': {'--cyan':'#39ff14','--cyan2':'#27b30d','--cyan3':'rgba(57,255,20,0.15)'},
};
function applyTheme(name) {
const t = _THEMES[name]; if (!t) return;
const root = document.documentElement;
Object.entries(t).forEach(([k,v]) => root.style.setProperty(k, v));
localStorage.setItem('jarvis_theme', name);
document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === name));
}
// Apply saved theme on load
(function() {
const saved = localStorage.getItem('jarvis_theme');
if (saved && saved !== 'stark-blue') setTimeout(() => applyTheme(saved), 50);
})();
// ── QUICK-NOTE CAPTURE ────────────────────────────────────────────────────────
function openQuickNote() {
const bar = document.getElementById('quickNoteBar');
if (!bar) return;
bar.classList.add('open');
setTimeout(() => document.getElementById('quickNoteInput')?.focus(), 50);
}
function closeQuickNote() {
const bar = document.getElementById('quickNoteBar');
if (bar) bar.classList.remove('open');
const inp = document.getElementById('quickNoteInput');
if (inp) inp.value = '';
}
async function saveQuickNote() {
const inp = document.getElementById('quickNoteInput');
if (!inp || !inp.value.trim()) { closeQuickNote(); return; }
const note = inp.value.trim();
closeQuickNote();
try {
await api('chat', 'POST', {message: 'note: ' + note, session_id: sessionId});
addMessage('jarvis', 'Note saved to Memory Core, Sir: "' + note + '"');
} catch(_) {}
}
function handleNoteKey(e) {
if (e.key === 'Enter') { e.preventDefault(); saveQuickNote(); }
else if (e.key === 'Escape') { e.stopPropagation(); closeQuickNote(); }
}
// ── KEYBOARD SHORTCUTS ───────────────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) {
const tag = (document.activeElement?.tagName || '').toLowerCase();
@@ -1558,11 +1723,13 @@ document.addEventListener('keydown', function(e) {
if (el && (el.style.display === 'flex' || el.style.display === 'block')) el.style.display = 'none';
});
if (document.getElementById('netMapOverlay')?.classList.contains('nm-open')) closeNetMap();
if (document.getElementById('quickNoteBar')?.classList.contains('open')) closeQuickNote();
return;
}
if (inInput) return;
if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; }
if (e.key === 'm' || e.key === 'M') { toggleVoice(); return; }
if (e.key === 'n' || e.key === 'N') { openQuickNote(); return; }
if (e.key === ' ') { e.preventDefault(); document.getElementById('textInput')?.focus(); return; }
const tabMap = {'1':'ha','2':'alerts','3':'news','4':'agents'};
if (tabMap[e.key]) {