mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
@@ -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]) {
|
||||
|
||||
@@ -476,6 +476,12 @@ async function loadGuardian() {
|
||||
|
||||
_guardianUnread = unread;
|
||||
_updateGuardianBadge(unread, critU);
|
||||
if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('JARVIS ALERT', {
|
||||
body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
}
|
||||
|
||||
const lastScan = status.last_scan
|
||||
? new Date(status.last_scan + 'Z').toLocaleTimeString()
|
||||
|
||||
Reference in New Issue
Block a user