Fix intent placeholders, add alert/agent/deploy action intents, admin search+tester

- Restore {user_title} in ~200 intents where placeholder was stripped (comma-period, em-dash variants)
- Replace 20 hardcoded Mr. Blair with {user_title} token
- Remove duplicate pve_storage and tech_ssh intents
- Add action intents: alerts_show, alerts_count, alerts_clear, agents_offline, agents_all, agents_count, deploy_status, deploy_force, pbx_status, pbx_extension
- Admin KB Intents: add search/filter bar (name/pattern/response + type/status dropdowns)
- Admin KB Intents: add TEST PATTERN button — tests any phrase client-side against full intent list
This commit is contained in:
2026-06-01 23:23:32 +00:00
parent cf95960e57
commit 02847d5de3
2 changed files with 164 additions and 5 deletions
+87
View File
@@ -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;
}
}
}
+77 -5
View File
@@ -778,9 +778,23 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="page-title">KB INTENTS <span id="intents-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
<div class="actions">
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
<input id="intents-search" type="text" placeholder="Filter by name, pattern, or response…" style="flex:1;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 10px;font-size:0.75rem;border-radius:3px;font-family:inherit" oninput="filterIntents(this.value)">
<select id="intents-filter-type" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
<option value="">ALL TYPES</option>
<option value="response">RESPONSE</option>
<option value="action">ACTION</option>
</select>
<select id="intents-filter-status" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
<option value="">ALL STATUS</option>
<option value="1">ACTIVE</option>
<option value="0">DISABLED</option>
</select>
</div>
<div class="tbl-wrap" id="intents-tbl"><div class="loading">SCANNING...</div></div>
</div>
@@ -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='<div class="empty">NO INTENTS</div>'; 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='<div class="empty">NO INTENTS MATCH</div>'; return; }
document.getElementById('intents-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>PATTERN</th><th>RESPONSE</th><th>TYPE</th><th style="text-align:center">PRI</th><th>STATUS</th><th>ACTIONS</th></tr></thead>
<tbody id="intents-tbl-tbody"></tbody></table>`;
@@ -1439,6 +1476,39 @@ function intentModal(id=0, name='', pattern='', response='', type='response', pr
});
}
function intentTestModal() {
openModal('TEST INTENT PATTERN', `
<div class="form-row" style="display:flex;gap:8px;align-items:center">
<input id="t-phrase" placeholder="say something JARVIS should handle…" style="flex:1" onkeydown="if(event.key==='Enter')runIntentTest()">
<button class="btn btn-sm btn-yellow" onclick="runIntentTest()">TEST</button>
</div>
<div id="t-result" style="margin-top:12px;padding:10px;background:var(--bg2);border:1px solid var(--border);border-radius:3px;min-height:60px;font-size:0.75rem;color:var(--fg2);line-height:1.6">Enter a phrase and click TEST or press Enter.</div>
`, ()=>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 = '<span style="color:var(--green)">✓ MATCHED:</span> <strong>' + esc(matched.intent_name) + '</strong> &nbsp;(priority ' + matched.priority + ' · ' + matched.action_type + ')<br><span style="color:var(--yellow)">Pattern:</span> <code style="color:var(--yellow)">' + esc(matched.pattern) + '</code><br><span style="color:var(--cyan)">Response:</span> ' + esc((matched.response_template||'[action handler in chat.php]').substring(0,300));
} else {
resultEl.innerHTML = '<span style="color:var(--red)">✗ NO MATCH</span> — this phrase falls through to Ollama → Groq → Claude.';
}
}
// ── SITES ─────────────────────────────────────────────────────────────────────
async function loadSites() {
document.getElementById('sites-content').innerHTML='<div class="loading">SCANNING...</div>';
@@ -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(); });