feat: tier source badge + VM resource suggestions

- Tier badge (#9): addMessage() gains source param; sourceBadge() maps source string to KB/GROQ/CLAUDE/OLLAMA pill rendered after typing finishes; sendMessage() passes data.source through; CSS badges styled with domain colors
- VM suggestions (#10): vm_suggestions intent queries 24h avg CPU+MEM per agent, flags hosts with >80% avg CPU, <5% CPU on 2GB+ RAM, >85% avg MEM, or <25% MEM on 2GB+ RAM; 8 KB intents; say "VM resource suggestions" or "optimize VMs"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 02:38:24 +00:00
parent b024e51f3d
commit bde8909490
2 changed files with 76 additions and 3 deletions
+49 -1
View File
@@ -1647,6 +1647,54 @@ if (!$reply) {
if ($matched && $matched['action'] === 'action') {
switch ($matched['intent']) {
case 'vm_suggestions': {
$rows = JarvisDB::query(
"SELECT a.hostname, a.agent_type,
ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.cpu_percent') AS DECIMAL(5,1))),1) as avg_cpu,
ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.memory.percent') AS DECIMAL(5,1))),1) as avg_mem,
ROUND(MAX(CAST(JSON_EXTRACT(m.metric_data,'$.memory.total_mb') AS UNSIGNED))/1024,1) as ram_gb,
COUNT(*) as samples
FROM agent_metrics m
JOIN registered_agents a ON a.agent_id = m.agent_id
WHERE m.recorded_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND a.agent_type IN ('linux','proxmox')
GROUP BY a.hostname, a.agent_type
HAVING samples > 20
ORDER BY avg_mem DESC"
) ?? [];
$suggestions = [];
foreach ($rows as $r) {
$cpu = (float)($r['avg_cpu'] ?? 0);
$mem = (float)($r['avg_mem'] ?? 0);
$ram = (float)($r['ram_gb'] ?? 0);
$host = $r['hostname'];
if (!$ram) continue;
// High CPU
if ($cpu >= 80)
$suggestions[] = "{$host} is averaging {$cpu}% CPU — consider increasing vCPU allocation or investigating load.";
// Low CPU + reasonable RAM (suggest checking allocation)
elseif ($cpu < 5 && $ram >= 2)
$suggestions[] = "{$host} averages only {$cpu}% CPU — vCPU allocation may be generous.";
// High MEM
if ($mem >= 85)
$suggestions[] = "{$host} is averaging {$mem}% memory use ({$ram}GB allocated) — consider increasing RAM.";
// Low MEM
elseif ($mem < 25 && $ram >= 2)
$suggestions[] = "{$host} uses only {$mem}% of its {$ram}GB RAM on average — allocation could be reduced.";
}
if (!$suggestions) {
$reply = "All VM resources look well-balanced, {$userAddr}. No over or under-allocation detected across the past 24 hours.";
} else {
$reply = "Resource analysis for the past 24 hours, {$userAddr}: " . implode(' ', $suggestions);
}
$source = 'intent:vm_suggestions';
break;
}
case 'ha_scene': {
// Fetch all HA scenes and fuzzy-match against the message
$haScenes = @json_decode(@file_get_contents(
@@ -1681,7 +1729,7 @@ if (!$reply) {
false,
stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer " . HA_TOKEN . "
'header' => "Authorization: Bearer " . HA_TOKEN . "
Content-Type: application/json",
'content' => json_encode(['entity_id' => $best['entity_id']]),
'timeout' => 5,
+27 -2
View File
@@ -586,6 +586,15 @@ body::after{
.device-item.ctx-active{background:rgba(0,212,255,0.1);border-color:rgba(0,212,255,0.4)}
.alert-item{cursor:pointer}
.alert-item.ctx-active{border-color:var(--cyan) !important;box-shadow:0 0 8px rgba(0,212,255,0.15)}
.tier-badge{
display:inline-block;font-family:var(--font-display);font-size:0.42rem;
letter-spacing:1.5px;padding:1px 5px;border-radius:2px;margin-top:4px;
opacity:0.7;border:1px solid;vertical-align:middle;
}
.tier-badge.kb {color:#00d4ff;border-color:rgba(0,212,255,0.4);background:rgba(0,212,255,0.06)}
.tier-badge.groq {color:#f5a623;border-color:rgba(245,166,35,0.4);background:rgba(245,166,35,0.06)}
.tier-badge.claude{color:#b57cf5;border-color:rgba(181,124,245,0.4);background:rgba(181,124,245,0.06)}
.tier-badge.ollama{color:#7ef55a;border-color:rgba(126,245,90,0.4);background:rgba(126,245,90,0.06)}
.news-item{cursor:pointer;transition:background 0.15s}
.news-item:hover{background:rgba(0,212,255,0.04)}
.news-item.ctx-active{background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.4)}
@@ -3439,7 +3448,21 @@ function switchTab(name) {
}
// ── CHAT ──────────────────────────────────────────────────────────────
function addMessage(role, text) {
function sourceBadge(source) {
if (!source) return '';
let cls, label;
if (/^intent:|^planner:|^kb:/.test(source)) { cls = 'kb'; label = 'KB'; }
else if (/^groq:/.test(source)) { cls = 'groq'; label = 'GROQ'; }
else if (source === 'claude' || /^claude/.test(source)) { cls = 'claude'; label = 'CLAUDE'; }
else if (/^ollama/.test(source)) { cls = 'ollama'; label = 'LOCAL AI'; }
else return '';
const s = document.createElement('div');
s.style.cssText = 'margin-top:4px;text-align:right';
s.innerHTML = `<span class="tier-badge ${cls}">${label}</span>`;
return s;
}
function addMessage(role, text, source=null) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg ' + role;
@@ -3459,6 +3482,8 @@ function addMessage(role, text) {
setTimeout(type, msPerChar + (text[i-1] === '.' || text[i-1] === ',' ? msPerChar * 4 : 0));
} else {
cursor.remove();
const badge = sourceBadge(source);
if (badge) div.appendChild(badge);
}
};
setTimeout(type, 0);
@@ -3556,7 +3581,7 @@ async function sendMessage() {
if (bubble) bubble.remove();
if (data.reply) {
addMessage('jarvis', data.reply);
addMessage('jarvis', data.reply, data.source || null);
speak(data.reply);
}
if (data.open_network_map) { openNetMap(); }