diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php
index 7d2f878..3d1b5b2 100644
--- a/api/endpoints/chat.php
+++ b/api/endpoints/chat.php
@@ -1102,6 +1102,93 @@ if (!$reply) {
'Automatic scan via PVE1 runs every 3 minutes.');
$source = 'intent:network_scan';
break;
+
+ case 'alerts_show':
+ $activeAlerts = JarvisDB::query(
+ "SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10"
+ );
+ if (!$activeAlerts) {
+ $reply = "No active alerts, {$userAddr}. All systems appear nominal.";
+ } else {
+ $lines = array_map(fn($a) => "[{$a['severity']}] {$a['title']}: {$a['message']}", $activeAlerts);
+ $reply = count($activeAlerts) . " active alert" . (count($activeAlerts)>1?'s':'') . ", {$userAddr}: " . implode('; ', $lines) . '.';
+ }
+ $source = 'intent:alerts_show';
+ break;
+
+ case 'alerts_count':
+ $alertCount = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0");
+ $cnt = (int)($alertCount['cnt'] ?? 0);
+ $reply = $cnt > 0
+ ? "There are currently {$cnt} unresolved alert" . ($cnt>1?'s':'') . ", {$userAddr}. Say 'show alerts' for details."
+ : "No active alerts at this time, {$userAddr}. All systems nominal.";
+ $source = 'intent:alerts_count';
+ break;
+
+ case 'alerts_clear':
+ $cleared = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0");
+ JarvisDB::execute("UPDATE alerts SET resolved=1 WHERE resolved=0");
+ $cnt = (int)($cleared['cnt'] ?? 0);
+ $reply = "Resolved {$cnt} alert" . ($cnt!==1?'s':'') . ", {$userAddr}. Alert panel cleared.";
+ $source = 'intent:alerts_clear';
+ break;
+
+ case 'agents_offline':
+ $offline = JarvisDB::query(
+ "SELECT hostname, ip_address, agent_type FROM registered_agents WHERE status='offline' ORDER BY last_seen DESC LIMIT 10"
+ );
+ if (!$offline) {
+ $reply = "All registered agents are currently online, {$userAddr}.";
+ } else {
+ $names = array_map(fn($a) => $a['hostname'] . ' (' . $a['ip_address'] . ')', $offline);
+ $reply = count($offline) . " agent" . (count($offline)>1?'s are':' is') . " offline, {$userAddr}: " . implode(', ', $names) . '.';
+ }
+ $source = 'intent:agents_offline';
+ break;
+
+ case 'agents_all':
+ $allAgents = JarvisDB::query(
+ "SELECT hostname, ip_address, status, agent_type FROM registered_agents ORDER BY FIELD(status,'online','offline','unknown'), hostname ASC"
+ );
+ if (!$allAgents) {
+ $reply = "No registered agents found, {$userAddr}.";
+ } else {
+ $onlineList = array_filter($allAgents, fn($a) => $a['status'] === 'online');
+ $offlineList = array_filter($allAgents, fn($a) => $a['status'] !== 'online');
+ $reply = count($allAgents) . " registered agents — " . count($onlineList) . " online, " . count($offlineList) . " offline, {$userAddr}.";
+ if ($onlineList) $reply .= ' Online: ' . implode(', ', array_map(fn($a) => $a['hostname'], $onlineList)) . '.';
+ if ($offlineList) $reply .= ' Offline: ' . implode(', ', array_map(fn($a) => $a['hostname'], $offlineList)) . '.';
+ }
+ $source = 'intent:agents_all';
+ break;
+
+ case 'agents_count':
+ $agentStats = JarvisDB::single(
+ "SELECT COUNT(*) total, SUM(status='online') online FROM registered_agents"
+ );
+ $t = (int)($agentStats['total'] ?? 0);
+ $o = (int)($agentStats['online'] ?? 0);
+ $reply = "{$t} agents registered — {$o} online, " . ($t-$o) . " offline, {$userAddr}.";
+ $source = 'intent:agents_count';
+ break;
+
+ case 'deploy_status':
+ $deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log';
+ if (file_exists($deployLog)) {
+ $lines = array_filter(array_map('trim', array_slice(file($deployLog), -20)));
+ $recent = array_slice(array_values($lines), -5);
+ $last = end($recent);
+ $reply = "Last deploy entry, {$userAddr}: " . htmlspecialchars_decode(strip_tags($last)) . '. Say "deploy log" to see the full recent history.';
+ } else {
+ $reply = "Deploy log not found, {$userAddr}. Check /home/jarvis.orbishosting.com/logs/deploy.log on the server.";
+ }
+ $source = 'intent:deploy_status';
+ break;
+
+ case 'deploy_force':
+ $reply = "Manual deploy is triggered by pushing to the GitHub main branch, {$userAddr}. The webhook at jarvis.orbishosting.com/webhook.php handles it automatically within 60 seconds. To hot-fix without a push, SCP the file directly to the server.";
+ $source = 'intent:deploy_force';
+ break;
}
}
}
diff --git a/public_html/admin/index.php b/public_html/admin/index.php
index 835d0c9..3b13a0d 100644
--- a/public_html/admin/index.php
+++ b/public_html/admin/index.php
@@ -778,9 +778,23 @@ select.filter-sel:focus{border-color:var(--cyan)}
KB INTENTS
+
+
+
+
+
+
@@ -1398,11 +1412,34 @@ function factModal(id=0, category='', key='', value='') {
}
// ── KB INTENTS ────────────────────────────────────────────────────────────────
+let _allIntents = [];
+
async function loadIntents() {
scanShell('intents-tbl', ['NAME','PATTERN','RESPONSE','TYPE','PRI','STATUS','ACTIONS'], null, null);
- const intents = await api('intents_list');
- const cntEl=document.getElementById('intents-count'); if(cntEl) cntEl.textContent=intents.length.toLocaleString()+' INTENTS';
- if (!intents.length) { document.getElementById('intents-tbl').innerHTML='NO INTENTS
'; return; }
+ _allIntents = await api('intents_list');
+ const cntEl=document.getElementById('intents-count'); if(cntEl) cntEl.textContent=_allIntents.length.toLocaleString()+' INTENTS';
+ renderIntents(_allIntents);
+}
+
+function filterIntents(q) {
+ q = (q||'').toLowerCase().trim();
+ const typeFilter = (document.getElementById('intents-filter-type')?.value || '').toLowerCase();
+ const statusFilter = document.getElementById('intents-filter-status')?.value ?? '';
+ let filtered = _allIntents;
+ if (q) filtered = filtered.filter(i =>
+ i.intent_name.toLowerCase().includes(q) ||
+ (i.pattern||'').toLowerCase().includes(q) ||
+ (i.response_template||'').toLowerCase().includes(q)
+ );
+ if (typeFilter) filtered = filtered.filter(i => i.action_type === typeFilter);
+ if (statusFilter !== '') filtered = filtered.filter(i => String(i.active) === statusFilter);
+ const cntEl=document.getElementById('intents-count');
+ if(cntEl) cntEl.textContent = (q||typeFilter||statusFilter!=='' ? filtered.length+'/'+_allIntents.length : _allIntents.length.toLocaleString())+' INTENTS';
+ renderIntents(filtered);
+}
+
+function renderIntents(intents) {
+ if (!intents.length) { document.getElementById('intents-tbl').innerHTML='NO INTENTS MATCH
'; return; }
document.getElementById('intents-tbl').innerHTML = `
| NAME | PATTERN | RESPONSE | TYPE | PRI | STATUS | ACTIONS |
`;
@@ -1439,6 +1476,39 @@ function intentModal(id=0, name='', pattern='', response='', type='response', pr
});
}
+function intentTestModal() {
+ openModal('TEST INTENT PATTERN', `
+
+
+
+
+ Enter a phrase and click TEST or press Enter.
+ `, ()=>closeModal(), 'CLOSE');
+ setTimeout(()=>document.getElementById('t-phrase')?.focus(), 60);
+}
+
+function runIntentTest() {
+ const phrase = (document.getElementById('t-phrase')?.value || '').trim();
+ if (!phrase) return;
+ const resultEl = document.getElementById('t-result');
+ if (!resultEl) return;
+ // Sort by priority desc, id asc (same order as PHP KBEngine::match)
+ const sorted = [..._allIntents].filter(i=>i.active).sort((a,b)=> b.priority-a.priority || a.id-b.id);
+ let matched = null;
+ for (const i of sorted) {
+ try {
+ let pat = i.pattern.replace(/^\(\?i\)/, '');
+ const re = new RegExp(pat, 'i');
+ if (re.test(phrase)) { matched = i; break; }
+ } catch(e) { /* invalid regex, skip */ }
+ }
+ if (matched) {
+ resultEl.innerHTML = '✓ MATCHED: ' + esc(matched.intent_name) + ' (priority ' + matched.priority + ' · ' + matched.action_type + ')
Pattern: ' + esc(matched.pattern) + '
Response: ' + esc((matched.response_template||'[action handler in chat.php]').substring(0,300));
+ } else {
+ resultEl.innerHTML = '✗ NO MATCH — this phrase falls through to Ollama → Groq → Claude.';
+ }
+}
+
// ── SITES ─────────────────────────────────────────────────────────────────────
async function loadSites() {
document.getElementById('sites-content').innerHTML='SCANNING...
';
@@ -1489,16 +1559,18 @@ function userModal(id, display) {
}
// ── MODAL ─────────────────────────────────────────────────────────────────────
-function openModal(title, body, saveCb) {
+function openModal(title, body, saveCb, saveLabel) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').innerHTML = body;
_modalCb = saveCb;
+ const saveBtn = document.getElementById('modalSave');
+ if (saveBtn) saveBtn.textContent = saveLabel || 'SAVE';
document.getElementById('modalBg').classList.add('open');
const first = document.querySelector('#modalBody input, #modalBody textarea, #modalBody select');
if (first) setTimeout(()=>first.focus(), 50);
}
-function closeModal() { document.getElementById('modalBg').classList.remove('open'); _modalCb=null; }
+function closeModal() { document.getElementById('modalBg').classList.remove('open'); _modalCb=null; const sb=document.getElementById('modalSave'); if(sb) sb.textContent='SAVE'; }
function modalSave() { if (_modalCb) _modalCb(); }
document.getElementById('modalBg').addEventListener('click', e => { if (e.target===document.getElementById('modalBg')) closeModal(); });