diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index 6525199..4d85852 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -775,9 +775,35 @@ if (!$reply) { $dp = array_map(fn($d) => $d['title'] . ' (' . round($d['cur'] / max($d['tgt'],1) * 100) . '%)', $active_dirs); $parts[] = count($active_dirs) . ' active directive' . (count($active_dirs) > 1 ? 's' : '') . ': ' . implode(', ', $dp); } + // Time-of-day greeting + $hour = (int)date('G'); // 0-23, America/Chicago set in config + if ($hour >= 5 && $hour < 12) $greet = "Good morning"; + elseif ($hour >= 12 && $hour < 17) $greet = "Good afternoon"; + elseif ($hour >= 17 && $hour < 22) $greet = "Good evening"; + else $greet = "Good evening"; + + // Weather lead + $wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1"); + if ($wRow && !empty($wRow[0]['data'])) { + $wd = json_decode($wRow[0]['data'], true); + $c = $wd['current'] ?? []; + if (!empty($c['temp']) && !empty($c['desc'])) { + $parts = array_merge(["It's {$c['temp']}°F and {$c['desc']} outside"], $parts); + } + } + + // Offline agents + $offline_agents = JarvisDB::query( + "SELECT hostname FROM registered_agents WHERE status='offline' AND last_seen > DATE_SUB(NOW(), INTERVAL 7 DAY)" + ) ?? []; + if ($offline_agents) { + $names = implode(', ', array_column($offline_agents, 'hostname')); + $parts[] = count($offline_agents) . ' agent' . (count($offline_agents) > 1 ? 's' : '') . ' offline: ' . $names; + } + $reply = $parts - ? "Good morning, {$userAddr}. " . implode('. ', $parts) . '.' - : "Good morning, {$userAddr}. Your schedule is clear — no tasks, appointments, or email actions pending today."; + ? "{$greet}, {$userAddr}. " . implode('. ', $parts) . '.' + : "{$greet}, {$userAddr}. Your schedule is clear — all systems nominal, no tasks or appointments pending."; $source = 'planner:briefing'; } @@ -1607,6 +1633,18 @@ if (!$reply) { if ($matched && $matched['action'] === 'action') { switch ($matched['intent']) { + case 'focus_mode': + $uiAction = 'focus_mode'; + $reply = "Focus mode activated, {$userAddr}. Side panels hidden."; + $source = 'intent:focus_mode'; + break; + + case 'show_panels': + $uiAction = 'show_panels'; + $reply = "Full view restored, {$userAddr}. All panels visible."; + $source = 'intent:show_panels'; + break; + case 'network_scan': $online = JarvisDB::single( "SELECT COUNT(*) cnt FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)" @@ -1632,6 +1670,36 @@ if (!$reply) { $source = 'intent:network_scan'; break; + case 'jellyfin_now_playing': + $jSessions = @json_decode(@file_get_contents( + JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false, + stream_context_create(['http' => ['timeout' => 4]]) + ), true) ?? []; + $active = array_filter($jSessions, fn($s) => isset($s['NowPlayingItem'])); + if (!$active) { + $reply = "Nothing is currently playing on Jellyfin, {$userAddr}."; + } else { + $lines = []; + foreach ($active as $s) { + $np = $s['NowPlayingItem']; + $title = $np['SeriesName'] ? $np['SeriesName'] . ' — ' . $np['Name'] : $np['Name']; + $paused = $s['PlayState']['IsPaused'] ? ' (paused)' : ''; + $lines[] = "{$s['UserName']} is watching {$title}{$paused} on {$s['DeviceName']}"; + } + $reply = implode('. ', $lines) . '.'; + } + $source = 'intent:jellyfin'; + break; + + case 'jellyfin_library': + $jMovies = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true); + $jSeries = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Series&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true); + $movies = $jMovies['TotalRecordCount'] ?? 0; + $series = $jSeries['TotalRecordCount'] ?? 0; + $reply = "Jellyfin library: {$movies} movies and {$series} TV series, {$userAddr}."; + $source = 'intent:jellyfin'; + break; + case 'alerts_show': $activeAlerts = JarvisDB::query( "SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10" @@ -1952,6 +2020,7 @@ if ($reply && !in_array(explode(':', $source)[0], ['intent', 'kb', 'fallback', ' memoryExtractAsync($message, $reply, $sessionId); } +$uiAction = $uiAction ?? null; echo json_encode([ 'reply' => $reply, 'source' => $source, @@ -1959,4 +2028,5 @@ echo json_encode([ 'timestamp' => date('c'), 'arc_job' => $arcJobId, 'open_network_map' => ($source === 'intent:network_scan'), + 'ui_action' => $uiAction, ]); diff --git a/api/endpoints/jellyfin.php b/api/endpoints/jellyfin.php new file mode 100644 index 0000000..dfd8a4a --- /dev/null +++ b/api/endpoints/jellyfin.php @@ -0,0 +1,81 @@ + ['timeout' => 5, 'ignore_errors' => true]]); + $raw = @file_get_contents($url, false, $ctx); + return $raw ? (json_decode($raw, true) ?? []) : []; +} + +switch ($action) { + case 'sessions': + $sessions = jf_get('/Sessions'); + $active = array_filter($sessions, fn($s) => isset($s['NowPlayingItem'])); + $out = []; + foreach ($active as $s) { + $np = $s['NowPlayingItem']; + $pos = $s['PlayState']['PositionTicks'] ?? 0; + $dur = $np['RunTimeTicks'] ?? 0; + $out[] = [ + 'session_id' => $s['Id'], + 'user' => $s['UserName'] ?? 'Unknown', + 'device' => $s['DeviceName'] ?? '', + 'client' => $s['Client'] ?? '', + 'title' => $np['Name'] ?? '', + 'type' => $np['Type'] ?? '', + 'series' => $np['SeriesName'] ?? null, + 'year' => $np['ProductionYear'] ?? null, + 'paused' => $s['PlayState']['IsPaused'] ?? false, + 'position_pct'=> $dur > 0 ? round($pos / $dur * 100) : 0, + ]; + } + echo json_encode(['sessions' => array_values($out), 'total_active' => count($out)]); + break; + + case 'library': + $movies = jf_get('/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0'); + $series = jf_get('/Items?IncludeItemTypes=Series&Recursive=true&Limit=0'); + $episodes= jf_get('/Items?IncludeItemTypes=Episode&Recursive=true&Limit=0'); + echo json_encode([ + 'movies' => $movies['TotalRecordCount'] ?? 0, + 'series' => $series['TotalRecordCount'] ?? 0, + 'episodes' => $episodes['TotalRecordCount'] ?? 0, + ]); + break; + + case 'search': + $q = trim($_GET['q'] ?? ''); + if (!$q) { echo json_encode(['results' => []]); break; } + $data = jf_get('/Search/Hints?SearchTerm=' . urlencode($q) . '&Limit=8&IncludeItemTypes=Movie,Series,Episode'); + $hints = $data['SearchHints'] ?? []; + $results = array_map(fn($h) => [ + 'id' => $h['ItemId'], + 'name' => $h['Name'], + 'type' => $h['Type'], + 'year' => $h['ProductionYear'] ?? null, + 'series'=> $h['Series'] ?? null, + ], $hints); + echo json_encode(['results' => $results]); + break; + + case 'recent': + $data = jf_get('/Items/Latest?Limit=8&IncludeItemTypes=Movie,Episode&Fields=Overview'); + $out = array_map(fn($i) => [ + 'name' => $i['Name'], + 'type' => $i['Type'], + 'series' => $i['SeriesName'] ?? null, + 'year' => $i['ProductionYear'] ?? null, + ], is_array($data) ? $data : []); + echo json_encode(['recent' => $out]); + break; + + default: + echo json_encode(['error' => 'Unknown action']); +} diff --git a/public_html/api.php b/public_html/api.php index 0f80723..577c36b 100644 --- a/public_html/api.php +++ b/public_html/api.php @@ -71,6 +71,7 @@ $endpoints = [ 'sites' => 'sites.php', 'agent' => 'agent.php', 'planner' => 'planner.php', + 'jellyfin' => 'jellyfin.php', 'arc' => 'arc.php', 'directives' => 'directives.php', 'memory' => 'memory.php', diff --git a/public_html/index.html b/public_html/index.html index ec211cc..57d59ec 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -3381,25 +3381,6 @@ async function sendMessage() { else addMessage('jarvis','Network map is not currently active.'); return; } - // Local panel-toggle voice commands (handled without API call) - const t = text.toLowerCase(); - if (/\b(focus\s*mode|hide\s*(panels?|stats?|statistics)|full\s*screen\s*jarvis)\b/.test(t)) { - input.value = ''; - addMessage('user', text); - if (panelsVisible) togglePanels(true); - addMessage('jarvis', 'Focus mode activated, Sir. Side panels hidden.'); - speak('Focus mode activated, Sir. Side panels hidden.'); - return; - } - if (/\b(show\s*(panels?|stats?|statistics|full\s*view)|full\s*(view|mode)|restore\s*panels?)\b/.test(t)) { - input.value = ''; - addMessage('user', text); - if (!panelsVisible) togglePanels(true); - addMessage('jarvis', 'Full view restored, Sir. All panels visible.'); - speak('Full view restored, Sir. All panels visible.'); - return; - } - input.value = ''; addMessage('user', text); showThinking(); @@ -3419,6 +3400,8 @@ async function sendMessage() { speak(data.reply); } 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) { const bubble = document.getElementById('thinking-bubble');