mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Add JARVIS improvements: mobile UI, sparklines, suggestions, multi-step commands, Arc Reactor health, tier badges
- Mobile UI: 3-button bottom nav with panel switcher - Chat history search: search modal with keyword query - News filtering: category filter with localStorage persistence - Proactive reminders: planner/appointment alerts at login and every 5 min - Proactive alerts: polls every 60s, speaks new critical/warning alerts - Agent sparklines: 2h CPU+MEM sparkline on each online agent card - Tier source badge: KB/GROQ/CLAUDE/OLLAMA pill shown after each reply - VM suggestions: 24h resource analysis via voice command - HA scene control: fuzzy-match scene activation via voice - Jellyfin control: pause/stop/next/previous via voice and KB - Pattern suggestions: usage_patterns table + proactive chips every 30 min - Multi-step commands: compound "X and Y" command parsing (Tier 0.5) - Arc Reactor health: warning=amber/1.2s, critical=red/0.6s pulse encoding - Cross-session history: last 6 turns loaded from prior session - Restart agent: voice command to restart any JARVIS agent - New endpoints: history.php, metrics.php, suggestions.php, jellyfin.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,4 @@ logs/
|
|||||||
mail.*/
|
mail.*/
|
||||||
logs/
|
logs/
|
||||||
backup/
|
backup/
|
||||||
|
arc-reactor-secrets.json
|
||||||
|
|||||||
@@ -111,6 +111,26 @@ function refresh_agent_alerts(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// NordVPN (nordlynx interface)
|
||||||
|
$nordvpn = $d['nordvpn'] ?? null;
|
||||||
|
if ($nordvpn !== null && !($nordvpn['active'] ?? true)) {
|
||||||
|
$key = 'agent:' . $id . ':nordvpn_down';
|
||||||
|
upsert_alert($key, 'critical', 'VPN Down: ' . $hn,
|
||||||
|
'nordlynx interface is down on ' . $hn . '. Downloads may be unprotected or blocked.');
|
||||||
|
$still_active[$key] = true;
|
||||||
|
$pending = JarvisDB::query(
|
||||||
|
"SELECT id FROM agent_commands WHERE agent_id=? AND command_type='restart_service'
|
||||||
|
AND status IN ('pending','delivered') AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)
|
||||||
|
AND JSON_EXTRACT(command_data,'$.service')=?",
|
||||||
|
[$id, 'nordvpnd']
|
||||||
|
);
|
||||||
|
if (empty($pending)) {
|
||||||
|
JarvisDB::query(
|
||||||
|
"INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)",
|
||||||
|
[$id, 'restart_service', json_encode(['service' => 'nordvpnd']), 'pending']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Site health alerts from kb_facts ──────────────────────────────────────
|
// ── Site health alerts from kb_facts ──────────────────────────────────────
|
||||||
|
|||||||
@@ -1632,6 +1632,77 @@ if (!$reply) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
$compoundParts = preg_split('/\s+(?:and|then|also)\s+/i', $message, 3);
|
||||||
|
if (count($compoundParts) >= 2) {
|
||||||
|
$multiReplies = [];
|
||||||
|
$multiActionIntents = [];
|
||||||
|
foreach ($compoundParts as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if (!$part) continue;
|
||||||
|
$mMatch = KBEngine::match($part);
|
||||||
|
if ($mMatch && ($mMatch['action'] ?? '') === 'action') {
|
||||||
|
$multiActionIntents[] = ['match' => $mMatch, 'part' => $part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($multiActionIntents) >= 2) {
|
||||||
|
foreach ($multiActionIntents as $ma) {
|
||||||
|
$mIntent = $ma['match']['intent'];
|
||||||
|
$mPart = $ma['part'];
|
||||||
|
$mReply = null;
|
||||||
|
|
||||||
|
if ($mIntent === 'ha_scene') {
|
||||||
|
$haStates = @json_decode(@file_get_contents(HA_URL.'/api/states', false,
|
||||||
|
stream_context_create(['http'=>['timeout'=>4,'header'=>'Authorization: Bearer '.HA_TOKEN]])), true) ?? [];
|
||||||
|
$haScenes = array_filter($haStates, fn($s) => str_starts_with($s['entity_id']??'','scene.'));
|
||||||
|
$mLow = strtolower($mPart); $best=null; $bestS=0;
|
||||||
|
foreach ($haScenes as $s) {
|
||||||
|
$name=strtolower($s['attributes']['friendly_name']??'');
|
||||||
|
$id=strtolower(str_replace(['scene.','_'],['',''],$s['entity_id']));
|
||||||
|
$score=0; foreach(array_filter(explode(' ',"$name $id")) as $tok)
|
||||||
|
if(strlen($tok)>2&&str_contains($mLow,$tok))$score++;
|
||||||
|
if($name&&str_contains($mLow,$name))$score+=5;
|
||||||
|
if($score>$bestS){$bestS=$score;$best=$s;}
|
||||||
|
}
|
||||||
|
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."
|
||||||
|
Content-Type: application/json",
|
||||||
|
'content'=>json_encode(['entity_id'=>$best['entity_id']])]]));
|
||||||
|
$mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated';
|
||||||
|
}
|
||||||
|
} elseif (in_array($mIntent, ['jellyfin_pause','jellyfin_stop','jellyfin_next','jellyfin_previous'])) {
|
||||||
|
$jCmdMap = ['jellyfin_pause'=>'TogglePause','jellyfin_stop'=>'Stop','jellyfin_next'=>'NextTrack','jellyfin_previous'=>'PreviousTrack'];
|
||||||
|
$jCmd = $jCmdMap[$mIntent];
|
||||||
|
$jSessions = @json_decode(@file_get_contents(JELLYFIN_URL.'/Sessions?api_key='.JELLYFIN_API_KEY,false,
|
||||||
|
stream_context_create(['http'=>['timeout'=>4]])),true)??[];
|
||||||
|
foreach ($jSessions as $js) { if(isset($js['NowPlayingItem'])){
|
||||||
|
@file_get_contents(JELLYFIN_URL.'/Sessions/'.rawurlencode($js['Id']).'/Command/'.$jCmd.'?api_key='.JELLYFIN_API_KEY,
|
||||||
|
false,stream_context_create(['http'=>['method'=>'POST','timeout'=>4,'content'=>'{}','header'=>'Content-Type: application/json']]));
|
||||||
|
$mReply = 'Jellyfin ' . strtolower(str_replace('Track','',$jCmd)); break;
|
||||||
|
}}
|
||||||
|
} elseif ($mIntent === 'focus_mode') { $uiAction = 'focus_mode'; $mReply = 'focus mode on'; }
|
||||||
|
elseif ($mIntent === 'show_panels') { $uiAction = 'show_panels'; $mReply = 'panels shown'; }
|
||||||
|
elseif ($mIntent === 'network_scan') {
|
||||||
|
$devCount = JarvisDB::query("SELECT COUNT(*) as c FROM network_devices WHERE last_seen > DATE_SUB(NOW(),INTERVAL 15 MINUTE)")[0]['c'] ?? 0;
|
||||||
|
$mReply = "network scan queued ({$devCount} devices online)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mReply) $multiReplies[] = $mReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($multiReplies) >= 2) {
|
||||||
|
$reply = "Done, {$userAddr}. " . implode(', and ', $multiReplies) . '.';
|
||||||
|
$source = 'intent:multi_step';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
|
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
|
||||||
if (!$reply) {
|
if (!$reply) {
|
||||||
$matched = KBEngine::match($message);
|
$matched = KBEngine::match($message);
|
||||||
@@ -2206,6 +2277,17 @@ JarvisDB::insert(
|
|||||||
);
|
);
|
||||||
KBEngine::learnFromConversation($message, $reply);
|
KBEngine::learnFromConversation($message, $reply);
|
||||||
|
|
||||||
|
// Track usage pattern for action intents
|
||||||
|
if ($source && str_starts_with($source, 'intent:')) {
|
||||||
|
$intentKey = str_replace('intent:', '', $source);
|
||||||
|
JarvisDB::query(
|
||||||
|
"INSERT INTO usage_patterns (intent_name, hour, dow, hit_count)
|
||||||
|
VALUES (?, HOUR(NOW()), DAYOFWEEK(NOW())-1, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE hit_count=hit_count+1, last_seen=NOW()",
|
||||||
|
[$intentKey]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Memory Core — async extraction for LLM responses (don't extract from intent/KB/fallback)
|
// Memory Core — async extraction for LLM responses (don't extract from intent/KB/fallback)
|
||||||
if ($reply && !in_array(explode(':', $source)[0], ['intent', 'kb', 'fallback', 'memory', 'arc'])) {
|
if ($reply && !in_array(explode(':', $source)[0], ['intent', 'kb', 'fallback', 'memory', 'arc'])) {
|
||||||
memoryExtractAsync($message, $reply, $sessionId);
|
memoryExtractAsync($message, $reply, $sessionId);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// Proactive suggestions endpoint — returns time-based command suggestions
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../../includes/auth.php';
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
AuthMiddleware::requireAuth();
|
||||||
|
|
||||||
|
$hour = (int)date('G');
|
||||||
|
$dow = (int)date('w'); // 0=Sun, 6=Sat
|
||||||
|
|
||||||
|
// Find intents used 3+ times at this hour and day-of-week
|
||||||
|
$rows = JarvisDB::query(
|
||||||
|
"SELECT intent_name, hit_count FROM usage_patterns
|
||||||
|
WHERE hour=? AND dow=? AND hit_count >= 3
|
||||||
|
ORDER BY hit_count DESC LIMIT 3",
|
||||||
|
[$hour, $dow]
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
// Map intents to friendly suggestion prompts
|
||||||
|
$intentPrompts = [
|
||||||
|
'network_scan' => 'Run a network scan?',
|
||||||
|
'jellyfin_now_playing' => 'Check what\'s playing on Jellyfin?',
|
||||||
|
'jellyfin_library' => 'Check the Jellyfin library?',
|
||||||
|
'ha_scene' => 'Activate a home scene?',
|
||||||
|
'planner:briefing' => 'Get your daily briefing?',
|
||||||
|
'vm_suggestions' => 'Check VM resource usage?',
|
||||||
|
'jellyfin_pause' => 'Pause Jellyfin?',
|
||||||
|
'focus_mode' => 'Switch to focus mode?',
|
||||||
|
'show_panels' => 'Show all panels?',
|
||||||
|
];
|
||||||
|
|
||||||
|
$suggestions = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$intent = $r['intent_name'];
|
||||||
|
if (isset($intentPrompts[$intent])) {
|
||||||
|
$suggestions[] = [
|
||||||
|
'intent' => $intent,
|
||||||
|
'prompt' => $intentPrompts[$intent],
|
||||||
|
'hit_count' => (int)$r['hit_count'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['suggestions' => $suggestions, 'hour' => $hour, 'dow' => $dow]);
|
||||||
@@ -74,6 +74,7 @@ $endpoints = [
|
|||||||
'jellyfin' => 'jellyfin.php',
|
'jellyfin' => 'jellyfin.php',
|
||||||
'history' => 'history.php',
|
'history' => 'history.php',
|
||||||
'metrics' => 'metrics.php',
|
'metrics' => 'metrics.php',
|
||||||
|
'suggestions' => 'suggestions.php',
|
||||||
'arc' => 'arc.php',
|
'arc' => 'arc.php',
|
||||||
'directives' => 'directives.php',
|
'directives' => 'directives.php',
|
||||||
'memory' => 'memory.php',
|
'memory' => 'memory.php',
|
||||||
|
|||||||
+73
-1
@@ -771,6 +771,23 @@ body::after{
|
|||||||
.thinking-dot:nth-child(3){animation-delay:0.3s}
|
.thinking-dot:nth-child(3){animation-delay:0.3s}
|
||||||
@keyframes thinkBounce{0%,100%{transform:translateY(0);opacity:0.5}50%{transform:translateY(-6px);opacity:1}}
|
@keyframes thinkBounce{0%,100%{transform:translateY(0);opacity:0.5}50%{transform:translateY(-6px);opacity:1}}
|
||||||
|
|
||||||
|
/* ── ARC REACTOR HEALTH STATES ──────────────────────────────────── */
|
||||||
|
#arcReactor.health-warning .arc-ring.r3{border-color:rgba(245,166,35,0.8);box-shadow:0 0 8px #f5a623}
|
||||||
|
#arcReactor.health-warning .arc-ring.r5{border-color:rgba(245,166,35,0.6)}
|
||||||
|
#arcReactor.health-warning .arc-ring.r7{border-color:rgba(245,166,35,0.5)}
|
||||||
|
#arcReactor.health-warning .arc-core{
|
||||||
|
background:radial-gradient(circle,#fff 0%,#f5a623 35%,#c97b00 65%,transparent 100%);
|
||||||
|
box-shadow:0 0 15px #f5a623,0 0 30px #f5a623,0 0 60px rgba(245,166,35,0.4);
|
||||||
|
animation:corePulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#arcReactor.health-critical .arc-ring.r3{border-color:rgba(255,34,68,0.9);box-shadow:0 0 10px var(--red)}
|
||||||
|
#arcReactor.health-critical .arc-ring.r5{border-color:rgba(255,34,68,0.7)}
|
||||||
|
#arcReactor.health-critical .arc-ring.r7{border-color:rgba(255,34,68,0.6)}
|
||||||
|
#arcReactor.health-critical .arc-core{
|
||||||
|
background:radial-gradient(circle,#fff 0%,var(--red) 35%,#8b0000 65%,transparent 100%);
|
||||||
|
box-shadow:0 0 15px var(--red),0 0 30px var(--red),0 0 60px rgba(255,34,68,0.5);
|
||||||
|
animation:corePulse 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */
|
/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */
|
||||||
@keyframes speakPulse{
|
@keyframes speakPulse{
|
||||||
0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)}
|
0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)}
|
||||||
@@ -1655,6 +1672,21 @@ function setAlertState(hasAlerts) {
|
|||||||
if (vg) vg.classList.toggle('alert-vignette', hasAlerts);
|
if (vg) vg.classList.toggle('alert-vignette', hasAlerts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSystemHealth(level) {
|
||||||
|
// level: 'ok' | 'warning' | 'critical'
|
||||||
|
const reactor = document.getElementById('arcReactor');
|
||||||
|
if (!reactor) return;
|
||||||
|
reactor.classList.remove('health-warning', 'health-critical');
|
||||||
|
if (level === 'warning') reactor.classList.add('health-warning');
|
||||||
|
if (level === 'critical') reactor.classList.add('health-critical');
|
||||||
|
// Also update topbar logo dot
|
||||||
|
const dot = document.querySelector('.tb-logo-dot');
|
||||||
|
if (dot) {
|
||||||
|
dot.style.background = level === 'critical' ? 'var(--red)' : level === 'warning' ? '#f5a623' : 'var(--cyan)';
|
||||||
|
dot.style.boxShadow = level === 'critical' ? '0 0 8px var(--red)' : level === 'warning' ? '0 0 8px #f5a623' : '0 0 8px var(--cyan)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── FACE TRACKING — reactor follows face position ─────────────────────
|
// ── FACE TRACKING — reactor follows face position ─────────────────────
|
||||||
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
|
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
|
||||||
let _faceCurrX = 0, _faceCurrY = 0;
|
let _faceCurrX = 0, _faceCurrY = 0;
|
||||||
@@ -2595,7 +2627,9 @@ function showApp(name, greeting, silent = false) {
|
|||||||
initMobile();
|
initMobile();
|
||||||
setTimeout(checkPlannerReminder, 3000);
|
setTimeout(checkPlannerReminder, 3000);
|
||||||
setInterval(checkUpcomingAppts, 300000);
|
setInterval(checkUpcomingAppts, 300000);
|
||||||
setTimeout(pollAlertsProactive, 8000); // baseline on load
|
setTimeout(pollAlertsProactive, 8000);
|
||||||
|
setTimeout(checkSuggestions, 15000);
|
||||||
|
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
|
||||||
setInterval(pollAlertsProactive, 60000); // poll every 60s
|
setInterval(pollAlertsProactive, 60000); // poll every 60s
|
||||||
// Guardian Mode — badge refresh + proactive chat
|
// Guardian Mode — badge refresh + proactive chat
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -3278,12 +3312,15 @@ async function loadAlerts() {
|
|||||||
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--green);text-align:center;margin-top:20px">✓ NO ACTIVE ALERTS</div>';
|
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--green);text-align:center;margin-top:20px">✓ NO ACTIVE ALERTS</div>';
|
||||||
tb.textContent='NO ALERTS'; tb.className='text-green';
|
tb.textContent='NO ALERTS'; tb.className='text-green';
|
||||||
setAlertState(false);
|
setAlertState(false);
|
||||||
|
setSystemHealth('ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
|
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
|
||||||
tb.className='text-red';
|
tb.className='text-red';
|
||||||
setAlertState(true);
|
setAlertState(true);
|
||||||
|
const hasCritical = alerts.some(a => a.severity === 'critical');
|
||||||
|
setSystemHealth(hasCritical ? 'critical' : 'warning');
|
||||||
|
|
||||||
el.innerHTML = alerts.map(a => {
|
el.innerHTML = alerts.map(a => {
|
||||||
const ctxKey = 'alert_' + a.id;
|
const ctxKey = 'alert_' + a.id;
|
||||||
@@ -5252,6 +5289,41 @@ document.getElementById('searchModal')?.addEventListener('click', e => {
|
|||||||
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
|
||||||
|
const _shownSuggestions = new Set();
|
||||||
|
async function checkSuggestions() {
|
||||||
|
const d = await api('suggestions').catch(() => null);
|
||||||
|
if (!d || !d.suggestions || !d.suggestions.length) return;
|
||||||
|
for (const s of d.suggestions) {
|
||||||
|
const key = s.intent + ':' + d.hour + ':' + d.dow;
|
||||||
|
if (_shownSuggestions.has(key)) continue;
|
||||||
|
_shownSuggestions.add(key);
|
||||||
|
// Show as a soft suggestion chip in chat
|
||||||
|
const log = document.getElementById('chatLog');
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
|
||||||
|
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
|
||||||
|
log.appendChild(chip);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
break; // show max one suggestion at a time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuggestion(intent, btn) {
|
||||||
|
btn.closest('div').remove();
|
||||||
|
const prompts = {
|
||||||
|
'network_scan': 'run a network scan',
|
||||||
|
'jellyfin_now_playing': 'what is playing on Jellyfin',
|
||||||
|
'ha_scene': 'what scenes are available',
|
||||||
|
'planner:briefing': 'daily briefing',
|
||||||
|
'vm_suggestions': 'VM resource suggestions',
|
||||||
|
'focus_mode': 'focus mode',
|
||||||
|
};
|
||||||
|
const msg = prompts[intent] || intent.replace(/_/g,' ');
|
||||||
|
document.getElementById('textInput').value = msg;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
||||||
function mobSwitch(which) {
|
function mobSwitch(which) {
|
||||||
if (window.innerWidth > 900) return;
|
if (window.innerWidth > 900) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user