Files
jarvis/api/endpoints/chat.php
T
myron dc55e6c45b Initial commit: JARVIS AI dashboard v2.3
- 4-tier chat: HA control → Ollama → Groq → Claude
- Push-based agent system with heartbeat/metrics
- Network monitoring, alerts, Proxmox, Home Assistant
- Windows + Linux agent installers
- Stats cache cron, facts collector, KB engine
2026-05-25 13:22:57 +00:00

718 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
if ($method !== 'POST') {
echo json_encode(['error' => 'POST only']); exit;
}
$message = trim($data['message'] ?? '');
$sessionId = $data['session_id'] ?? session_id();
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
if (!$message) {
echo json_encode(['error' => 'Message required']); exit;
}
// Build context string from selected panel item
$ctxSnippet = '';
if ($panelCtx && is_array($panelCtx)) {
$type = $panelCtx['type'] ?? '';
switch ($type) {
case 'vm':
$ctxSnippet = sprintf(
'[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['vmid'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['cpu'] ?? '?',
$panelCtx['mem_mb'] ?? '?',
$panelCtx['maxmem_mb'] ?? '?',
$panelCtx['type_label'] ?? 'qemu'
);
break;
case 'network':
$ctxSnippet = sprintf(
'[Selected Device: %s — IP: %s, Status: %s%s]',
$panelCtx['name'] ?? '?',
$panelCtx['ip'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : ''
);
break;
case 'alert':
$ctxSnippet = sprintf(
'[Selected Alert: %s — Severity: %s, Message: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['severity'] ?? '?',
$panelCtx['message'] ?? '?'
);
break;
case 'news':
$ctxSnippet = sprintf(
'[Selected News Story: "%s" — Source: %s, Published: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['source'] ?? '?',
$panelCtx['pub'] ?? 'unknown'
);
break;
case 'ha':
$ctxSnippet = sprintf(
'[Selected Home Device: %s (%s) — Current State: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['entity_id'] ?? '?',
$panelCtx['state'] ?? '?'
);
break;
}
}
// Save user message
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'user', $message]
);
// Conversation history
$history = JarvisDB::query(
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10',
[$sessionId]
);
$history = array_reverse($history);
$reply = null;
$source = 'unknown';
// ── Load user address preference ──────────────────────────────────────────
$prefRows = JarvisDB::query(
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
);
$prefs = [];
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
$userName = $prefs['user_name'] ?? 'Myron';
$userTitle = $prefs['user_title'] ?? 'Mr. Blair';
// Address to use in responses
$userAddr = $userTitle;
// ── Tier 0.1: Name preference change ─────────────────────────────────────
if (!$reply) {
$lc = strtolower($message);
// Patterns: "call me X", "refer to me as X", "address me as X", "my name is X",
// "don't call me X", "stop calling me X", "just call me X"
if (preg_match(
'/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
$newName = trim(preg_replace('/\s+/', ' ', $m[1]));
// Strip trailing punctuation
$newName = rtrim($newName, '.!?,;');
if (strlen($newName) >= 2 && strlen($newName) <= 30) {
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$newName]
);
$userTitle = $newName;
$userAddr = $newName;
$reply = "Understood. I'll address you as {$newName} from now on.";
$source = 'intent:name_pref';
}
} elseif (preg_match(
'/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
// "don't call me {$userAddr}" — switch to first name
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$userName]
);
$userTitle = $userName;
$userAddr = $userName;
$reply = "Of course. I'll call you {$userName} going forward.";
$source = 'intent:name_pref';
}
}
// ── Tier 0: Home Assistant Control ───────────────────────────────────────
// Uses entity_map stored by facts_collector to resolve natural language → entity
$haEntityMapRow = JarvisDB::query(
'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? LIMIT 1',
['ha', 'entity_map']
);
$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value']))
? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? [])
: [];
// Scene keywords for one-shot activations (no on/off needed)
$sceneKeywords = [
'good night' => 'scene.good_night',
'goodnight' => 'scene.good_night',
'good morning' => 'scene.good_morning',
'goodmorning' => 'scene.good_morning',
'goodbye' => 'scene.goodbye',
'bye' => 'scene.goodbye',
'kitchen lights on' => 'scene.kitchen_lights_on',
'kitchen on' => 'scene.kitchen_lights_on',
'kitchen lights off' => 'scene.kitchen_lights_off',
'kitchen off' => 'scene.kitchen_lights_off',
'front porch lights' => 'scene.outdoors_front_porch_lights',
'porch scene' => 'scene.outdoors_front_porch_lights',
'office dawn' => 'scene.office_ocean_dawn',
];
$msgLower = strtolower(trim($message));
// Check for scene activation first
$sceneId = null;
foreach ($sceneKeywords as $kw => $sid) {
if (strpos($msgLower, $kw) !== false) {
$sceneId = $sid;
break;
}
}
if ($sceneId) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
$ch = curl_init($haUrl . '/api/services/scene/turn_on');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId));
$reply = "Activating {$sceneName}, {$userAddr}.";
$source = 'ha:scene';
}
}
// Check for device on/off control
if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch)
|| (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) {
$turnOn = (bool) preg_match('/\bon\b/i', $message);
$turnOff = (bool) preg_match('/\boff\b/i', $message);
$haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null);
if ($haService && !empty($haEntityMap)) {
// Find best matching entity
$bestEid = null;
$bestScore = 0;
$bestName = '';
// Special: "all lights" / "everything"
if (preg_match('/(all|everything|every)/i', $message)) {
$bestEid = '__all_lights__';
$bestName = 'All lights';
$bestScore = 1;
}
if (!$bestEid) {
// Build search terms from message (remove control words with word boundaries)
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
// Score: exact substring match = 10, word overlap = words matched
if ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
$score = 10;
} else {
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestEid = $eid;
$bestName = $info['name'];
}
}
}
if ($bestEid && $bestScore > 0) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
if ($bestEid === '__all_lights__') {
// Turn all lights on/off via domain targeting
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
$payload = ['entity_id' => $lightIds];
$svcDomain = 'light';
} else {
$domain = $haEntityMap[$bestEid]['domain'] ?? 'switch';
$payload = ['entity_id' => $bestEid];
$svcDomain = $domain;
}
$ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$action = ($haService === 'turn_on') ? 'activated' : 'deactivated';
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
$reply = "{$label} {$action}, {$userAddr}.";
$source = 'ha:' . $haService;
}
}
}
}
// Status query for HA entities
if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message)
&& !preg_match('/(turn|switch|put)/i', $message)) {
// Status query - find the entity and report its state
$searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
$bestEid = null; $bestScore = 0; $bestName = ''; $bestState = '';
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
if ($score > $bestScore) {
$bestScore = $score; $bestEid = $eid;
$bestName = $info['name']; $bestState = $info['state'];
}
}
if ($bestEid && $bestScore > 0) {
$reply = "The {$bestName} is currently {$bestState}, {$userAddr}.";
$source = 'ha:status';
}
}
// ── Tier 0.5: Network Device Management ──────────────────────────────────
if (!$reply) {
// Flow state stored in kb_facts (session_write_close() is called before this runs)
$flowKey = substr(session_id() ?: 'anon', 0, 32);
// Expire stale chat flows older than 10 minutes
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)");
$flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]);
$devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null;
// Continue an active multi-step add-device flow
if ($devState && isset($devState['step'])) {
switch ($devState['step']) {
case 'waiting_ip':
if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) {
$newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Got it — {$m[1]}. What should I call this device?";
} else {
$reply = "Please give me a valid IP address such as 192.168.1.100.";
}
$source = 'device:flow';
break;
case 'waiting_name':
$cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message));
$newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip.";
$source = 'device:flow';
break;
case 'waiting_type':
$rawType = strtolower(trim($message));
$allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown'];
$devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown');
$devIp = $devState['ip'] ?? '';
$devName = $devState['name'] ?? '';
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
if ($devIp && $devName) {
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status.";
} else {
$reply = "Something went wrong with the device registration. Please try again.";
}
$source = 'device:added';
break;
default:
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
}
}
// Start a new add-device flow or handle inline add
if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) {
$hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM);
$hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM);
$hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM);
if ($hasIp && $hasName) {
$devIp = $ipM[1];
$devName = trim($nameM[1]);
$devType = $hasType ? trim($typeM[1]) : 'unknown';
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring.";
$source = 'device:added';
} elseif ($hasIp) {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]);
$reply = "I found the address {$ipM[1]}. What should I call this device?";
$source = 'device:flow';
} else {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]);
$reply = "I'll add a new device to network monitoring. What's its IP address?";
$source = 'device:flow';
}
}
// Remove / delete device
if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) {
$target = trim($dm[2]);
$isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target);
$rows = $isIp
? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target])
: JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]);
$label = $dev['alias'] ?? $dev['ip'];
$reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring.";
$source = 'device:removed';
}
}
// Update device name or type
if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) {
$target = trim($um[2]);
$newVal = trim($um[3]);
$rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]);
$reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'.";
$source = 'device:updated';
}
}
// List devices
if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) {
$rows = JarvisDB::query(
"SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias"
);
if (count($rows)) {
$items = array_map(fn($r) =>
($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'),
$rows
);
$reply = "Monitored devices: " . implode('; ', $items) . '.';
} else {
$reply = "No named devices in network monitoring yet. Say 'add a device' to get started.";
}
$source = 'device:list';
}
}
// ── Tier 0.8: Weather & News intents ─────────────────────────────────────
if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) {
$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'] ?? [];
$fc = $wd['forecast'] ?? [];
$today = $fc[0] ?? [];
$tomorrow = $fc[1] ?? [];
$reply = sprintf(
'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' .
'Today\'s range: %d%d°F. ' .
'Tomorrow: %s, %d%d°F, %d%% chance of rain.',
$c['icon'] ?? '',
$c['desc'] ?? '',
$c['temp'] ?? 0,
$c['feels'] ?? 0,
$c['humidity'] ?? 0,
$c['wind'] ?? 0,
$today['low'] ?? 0,
$today['high'] ?? 0,
$tomorrow['desc'] ?? '',
$tomorrow['low'] ?? 0,
$tomorrow['high'] ?? 0,
$tomorrow['rain_pct'] ?? 0
);
$source = 'intent:weather';
}
}
if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) {
$nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1");
if ($nRow && !empty($nRow[0]['data'])) {
$nd = json_decode($nRow[0]['data'], true);
$cats = $nd['categories'] ?? [];
$lines = [];
foreach ($cats as $cat => $articles) {
if (!empty($articles)) {
$top3 = array_slice($articles, 0, 3);
foreach ($top3 as $a) {
$lines[] = '[' . $a['source'] . '] ' . $a['title'];
}
}
}
if ($lines) {
$reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.';
} else {
$reply = 'News feed is still loading, Sir. Please try again in a moment.';
}
$source = 'intent:news';
}
}
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
if (!$reply) {
$matched = KBEngine::match($message);
if ($matched && $matched['action'] === 'response') {
$reply = $matched['reply'];
$source = 'intent:' . $matched['intent'];
}
}
// ── Tier 2: Ollama local LLM (fast local fallback) ───────────────────────
if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) {
$ollamaHost = OLLAMA_HOST;
$ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b';
$timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45;
$ollamaMessages = [];
$ollamaMessages[] = ['role' => 'system', 'content' =>
"You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " .
'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.'];
$ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
$promptParts = [];
foreach ($ollamaMessages as $msg) {
$role = ucfirst($msg['role']);
$promptParts[] = "{$role}: {$msg['content']}";
}
$promptParts[] = 'Assistant:';
$promptStr = implode("\n\n", $promptParts);
$payload = [
'model' => $ollamaModel,
'prompt' => $promptStr,
'stream' => false,
'options' => ['temperature' => 0.7, 'num_predict' => 150],
];
$ch = curl_init($ollamaHost . '/api/generate');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['response'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'ollama:' . $ollamaModel;
}
}
// Silently fall through to Groq if Ollama fails or times out
}
// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ─────────────
if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
$needsSearch = (bool) preg_match(
'/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i',
$message
);
$groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL;
$groqMessages = [['role' => 'system', 'content' =>
"You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " .
"(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " .
'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . '.'],
];
foreach (array_slice($history, -6) as $h) {
$groqMessages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
$groqMessages[] = ['role' => 'user', 'content' => $userMsg];
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $groqModel,
'messages' => $groqMessages,
'max_tokens' => 400,
'temperature' => 0.7,
]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['choices'][0]['message']['content'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'groq:' . $groqModel;
}
}
// Silently fall through to Claude if Groq fails
}
// ── Tier 4: Claude API (final fallback) ──────────────────────────────────
if (!$reply) {
// Live context for Claude
$systemContext = '';
try {
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$memPct = $mem['MemTotal'] > 0
? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100)
: '?';
$sec = (int) file_get_contents('/proc/uptime');
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
$load = explode(' ', file_get_contents('/proc/loadavg'));
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
} catch (Exception $e) {}
$alerts = JarvisDB::query(
'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3'
);
if ($alerts) {
$systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n";
}
$kbContext = KBEngine::getContextSummary();
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
Infrastructure:
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
- Proxmox Host: 10.48.200.90 (manages all VMs)
- Home Assistant: 10.48.200.97:8123
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
- Network: 10.48.200.0/24, FortiGate firewall
Live data:
{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . "
Today: " . date('l, F j Y, g:i A T') . "
Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers.";
$messages = [];
foreach ($history as $h) {
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') {
$reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands.";
$source = 'fallback:no-key';
} else {
$payload = [
'model' => CLAUDE_MODEL,
'max_tokens' => CLAUDE_MAX_TOKENS,
'system' => $systemPrompt,
'messages' => $messages,
];
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'x-api-key: ' . CLAUDE_API_KEY,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$decoded = json_decode($resp, true);
$reply = $decoded['content'][0]['text'] ?? null;
$source = 'claude';
} else {
$err = json_decode($resp, true);
$errMsg = $err['error']['message'] ?? '';
if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) {
$reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational.";
} else {
$reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg;
}
$source = 'claude:error';
}
}
}
// ── Final fallback ─────────────────────────────────────────────────────────
if (!$reply) {
$reply = "My systems are processing your request, {$userAddr}. Please try again momentarily.";
$source = 'fallback';
}
// Save reply and learn
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'assistant', $reply]
);
KBEngine::learnFromConversation($message, $reply);
echo json_encode([
'reply' => $reply,
'source' => $source,
'session_id' => $sessionId,
'timestamp' => date('c'),
]);