From 6195f9bd3b5c10536e3e06fd99283aa81ad4a5cc Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 17 Jun 2026 11:39:45 +0000 Subject: [PATCH] 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 --- api/endpoints/chat.php | 122 +++++++++++- public_html/assets/css/jarvis.css | 40 ++++ public_html/assets/js/jarvis-app.js | 223 +++++++++++++++++++--- public_html/assets/js/jarvis-protocols.js | 6 + public_html/index.html | 6 + 5 files changed, 368 insertions(+), 29 deletions(-) diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index a13724d..b95fd65 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -8,6 +8,7 @@ if ($method !== 'POST') { $message = trim($data['message'] ?? ''); $sessionId = $data['session_id'] ?? session_id(); $panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.) +$stream = !empty($data['stream']); if (!$message) { echo json_encode(['error' => 'Message required']); exit; @@ -1632,6 +1633,18 @@ if (!$reply) { } } +// ── Tier 0.25: Quick-note capture ──────────────────────────────────────────── +if (!$reply && preg_match('/^note:\s*(.+)/iu', $message, $nm)) { + $noteText = trim($nm[1]); + $ts = date('Y-m-d H:i'); + JarvisDB::query( + "INSERT INTO kb_facts (category, fact, source, confidence) VALUES (?,?,?,?)", + ['notes', "[{$ts}] {$noteText}", 'user-note', 1.0] + ); + $reply = "Note saved to Memory Core, {$userAddr}: \"{$noteText}\""; + $source = 'intent:quick_note'; +} + // ── Tier 0.5: Multi-step command detection ────────────────────────────────── // Detect "do X and Y" or "X then Y" compound commands (only when no reply yet) if (!$reply) { @@ -1670,7 +1683,7 @@ if (!$reply) { if ($best && $bestS >= 1) { @file_get_contents(HA_URL.'/api/services/scene/turn_on', false, stream_context_create(['http'=>['method'=>'POST','timeout'=>4, - 'header'=>"Authorization: Bearer ".HA_TOKEN." + 'header'=>"Authorization: Bearer ".HA_TOKEN." Content-Type: application/json", 'content'=>json_encode(['entity_id'=>$best['entity_id']])]])); $mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated'; @@ -1817,6 +1830,44 @@ Content-Type: application/json", break; } + case 'restart_agent': + // Extract target hostname from message + $msgLow = strtolower($message); + $agentMap = [ + 'homebridge' => 'homebridge_b57cbaea', + 'jellyfin' => 'jellyfin_7e386833', + 'networkbackup' => 'networkbackup_NetworkB', + 'network backup'=> 'networkbackup_NetworkB', + 'novacpx' => 'novacpx_e3b07264', + 'nova' => 'novacpx_e3b07264', + 'mediastack' => 'MediaStack_2c00b1b8', + 'media stack' => 'MediaStack_2c00b1b8', + 'homeassistant' => 'homeassistant_ha', + 'home assistant'=> 'homeassistant_ha', + ]; + $targetAgentId = null; + $targetName = null; + foreach ($agentMap as $keyword => $agentId) { + if (str_contains($msgLow, $keyword)) { + $targetAgentId = $agentId; + $targetName = ucfirst($keyword); + break; + } + } + if ($targetAgentId) { + JarvisDB::insert( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status, created_at) + VALUES (?, 'restart_service', ?, 'pending', NOW())", + [$targetAgentId, json_encode(['service' => 'jarvis-agent'])] + ); + $reply = "Restart command sent to the {$targetName} agent, {$userAddr}. It should come back online within 15 seconds."; + } else { + // Fall back to listing restartable agents + $reply = "Which agent should I restart, {$userAddr}? I can restart: HomeAssistant, Homebridge, Jellyfin, MediaStack, NetworkBackup, or NovaCPX."; + } + $source = 'intent:restart_agent'; + break; + case 'restart_agent': // Extract target hostname from message $msgLow = strtolower($message); @@ -2132,6 +2183,75 @@ if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) { $userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message; $groqMessages[] = ['role' => 'user', 'content' => $userMsg]; + if ($stream) { + // ── Streaming SSE path ────────────────────────────────────────── + while (ob_get_level()) ob_end_clean(); + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('X-Accel-Buffering: no'); + header('Connection: keep-alive'); + ob_implicit_flush(true); + + $streamedReply = ''; + $ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $groqModel, + 'messages' => $groqMessages, + 'max_tokens' => 400, + 'temperature' => 0.7, + 'stream' => true, + ]), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . GROQ_API_KEY, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => GROQ_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_WRITEFUNCTION => function($ch, $rawData) use (&$streamedReply) { + $lines = explode("\n", $rawData); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line === 'data: [DONE]') continue; + if (!str_starts_with($line, 'data: ')) continue; + $ev = json_decode(substr($line, 6), true); + $tok = $ev['choices'][0]['delta']['content'] ?? null; + if ($tok !== null && $tok !== '') { + echo 'data: ' . json_encode(['type' => 'token', 'token' => $tok]) . "\n\n"; + flush(); + $streamedReply .= $tok; + } + } + return strlen($rawData); + }, + ]); + curl_exec($ch); + curl_close($ch); + + $reply = $streamedReply ? trim($streamedReply) : "Groq AI is temporarily unavailable, {$userAddr}."; + $source = $streamedReply ? 'groq:' . $groqModel : 'fallback'; + + JarvisDB::insert( + 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', + [$sessionId, 'assistant', $reply] + ); + KBEngine::learnFromConversation($message, $reply); + + echo 'data: ' . json_encode([ + 'type' => 'complete', + 'reply' => $reply, + 'source' => $source, + 'session_id' => $sessionId, + 'ui_action' => $uiAction ?? null, + 'arc_job' => null, + 'open_network_map' => false, + ]) . "\n\n"; + flush(); + exit; + } + $ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, diff --git a/public_html/assets/css/jarvis.css b/public_html/assets/css/jarvis.css index a20e9a5..9fcea8f 100644 --- a/public_html/assets/css/jarvis.css +++ b/public_html/assets/css/jarvis.css @@ -648,6 +648,46 @@ body::after{ box-shadow:0 0 4px var(--red); } @keyframes waveBounce{from{height:4px}to{height:24px}} +.wave-bar.live{animation:none!important;transition:height 0.06s} + +/* ── AMBIENT DIM MODE ─────────────────────────────────────────────────── */ +.ambient-dim-active .panel,.ambient-dim-active #bottomBar{ + opacity:0.12;transition:opacity 2s ease;pointer-events:none} +.ambient-dim-active .panel:hover,.ambient-dim-active #bottomBar:hover{ + opacity:1;pointer-events:auto;transition:opacity 0.3s ease} + +/* ── THEME BUTTONS ────────────────────────────────────────────────────── */ +.theme-btn{ + background:none;border:1px solid rgba(0,212,255,0.25);border-radius:50%; + width:14px;height:14px;cursor:pointer;padding:0;font-size:0.6rem;line-height:1; + display:flex;align-items:center;justify-content:center;color:var(--cyan); + transition:all 0.2s;flex-shrink:0} +.theme-btn.active{border-color:currentColor;box-shadow:0 0 6px currentColor} +.theme-btn:hover{opacity:0.8;transform:scale(1.2)} + +/* ── CANCEL BUTTON (in thinking bubble) ──────────────────────────────── */ +.thinking-cancel{ + background:none;border:1px solid rgba(255,34,68,0.4);color:rgba(255,34,68,0.8); + font-family:var(--font-mono);font-size:0.55rem;letter-spacing:1px; + padding:2px 8px;border-radius:2px;cursor:pointer;margin-top:6px;display:block} +.thinking-cancel:hover{background:rgba(255,34,68,0.1)} + +/* ── QUICK NOTE BAR ──────────────────────────────────────────────────── */ +#quickNoteBar{ + position:fixed;bottom:90px;left:50%;transform:translateX(-50%); + width:500px;max-width:90vw;background:rgba(0,8,16,0.95); + border:1px solid var(--cyan);border-radius:3px;padding:8px 14px; + display:none;z-index:1100;align-items:center;gap:8px} +#quickNoteBar.open{display:flex} +#quickNoteInput{ + flex:1;background:none;border:none;color:var(--cyan); + font-family:var(--font-mono);font-size:0.75rem;outline:none;letter-spacing:0.5px} +#quickNoteInput::placeholder{color:rgba(0,212,255,0.4)} + +/* ── STREAMING MESSAGE ───────────────────────────────────────────────── */ +.msg.jarvis.streaming::after{ + content:'▋';animation:blink 0.7s step-end infinite;color:var(--cyan);margin-left:2px} +@keyframes blink{0%,100%{opacity:1}50%{opacity:0}} /* ── RIGHT PANEL ─────────────────────────────────────────────────── */ #rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto} diff --git a/public_html/assets/js/jarvis-app.js b/public_html/assets/js/jarvis-app.js index f6f3092..78aeba1 100644 --- a/public_html/assets/js/jarvis-app.js +++ b/public_html/assets/js/jarvis-app.js @@ -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.innerHTML = '
'; 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]) { diff --git a/public_html/assets/js/jarvis-protocols.js b/public_html/assets/js/jarvis-protocols.js index 72099d1..f381aa7 100644 --- a/public_html/assets/js/jarvis-protocols.js +++ b/public_html/assets/js/jarvis-protocols.js @@ -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() diff --git a/public_html/index.html b/public_html/index.html index 5428d3f..c9e5f2b 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -67,6 +67,11 @@ +
+ + + +
@@ -276,6 +281,7 @@ +
✎ NOTE