Activate agent mesh with honest live status and working animations.
Souls now aggregate aliased events (e.g. halal_engine), KPIs reflect 6h activity, active agents animate, and morning scheduler/scripts target cockpit :8610.
This commit is contained in:
@@ -9,9 +9,15 @@ AGENT_NAME_MAP: dict[str, str] = {
|
||||
"rss_feeds": "marketing",
|
||||
"wholesale_scraper": "sourcing",
|
||||
"halal_registry": "halal",
|
||||
"halal_engine": "halal",
|
||||
"halal_reco": "halal",
|
||||
"branch_scraper": "sourcing",
|
||||
"hermes": "herman",
|
||||
"herman_delegate": "herman",
|
||||
"ops": "sysops",
|
||||
"sysop": "sysops",
|
||||
"web_builder": "webbuilder",
|
||||
"packaging_agent": "packaging",
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +26,15 @@ def normalize_agent_key(name: str | None) -> str:
|
||||
if not key:
|
||||
return ""
|
||||
return AGENT_NAME_MAP.get(key, key)
|
||||
|
||||
|
||||
def aliases_for_key(canonical: str) -> list[str]:
|
||||
"""All raw agent_name values that map to this soul key (including the key itself)."""
|
||||
key = (canonical or "").strip().lower()
|
||||
if not key:
|
||||
return []
|
||||
names = {key}
|
||||
for raw, canon in AGENT_NAME_MAP.items():
|
||||
if canon == key:
|
||||
names.add(raw)
|
||||
return sorted(names)
|
||||
|
||||
@@ -1,49 +1,165 @@
|
||||
"""Agent soul profiles and Herman permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.agent_names import aliases_for_key, normalize_agent_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] += 1
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
bucket["current_status"] = r.get("status")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age_ok = True
|
||||
try:
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24):
|
||||
bucket["events_24h"] += 1
|
||||
if str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
except Exception:
|
||||
age_ok = False
|
||||
_ = age_ok
|
||||
# Also count older totals beyond 30d window lightly
|
||||
try:
|
||||
totals = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, COUNT(*)::int AS c
|
||||
FROM agent_events
|
||||
GROUP BY agent_name
|
||||
"""
|
||||
)
|
||||
total_by: dict[str, int] = {}
|
||||
for r in totals:
|
||||
k = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not k:
|
||||
continue
|
||||
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
|
||||
for k, c in total_by.items():
|
||||
bucket = by_key.setdefault(
|
||||
k,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] = c
|
||||
except Exception:
|
||||
pass
|
||||
return by_key
|
||||
|
||||
|
||||
def list_souls() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""SELECT s.*,
|
||||
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
|
||||
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
|
||||
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_task,
|
||||
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_status,
|
||||
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_event_type
|
||||
FROM agent_souls s ORDER BY s.display_name"""
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name")
|
||||
stats = _aggregate_event_stats()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
soul = dict(r)
|
||||
key = str(soul.get("agent_key") or "").lower()
|
||||
st = stats.get(key, {})
|
||||
events_6h = int(st.get("events_6h") or 0)
|
||||
errors_24h = int(st.get("errors_24h") or 0)
|
||||
event_count = int(st.get("event_count") or 0)
|
||||
health = _node_health(events_6h, errors_24h, event_count)
|
||||
lat = st.get("last_event_at")
|
||||
if lat is not None and hasattr(lat, "isoformat"):
|
||||
lat = lat.isoformat()
|
||||
soul["event_count"] = event_count
|
||||
soul["events_6h"] = events_6h
|
||||
soul["events_24h"] = int(st.get("events_24h") or 0)
|
||||
soul["errors_24h"] = errors_24h
|
||||
soul["health"] = health
|
||||
soul["last_event_at"] = lat
|
||||
soul["current_task"] = st.get("last_event_title") or soul.get("current_task")
|
||||
soul["current_status"] = st.get("current_status")
|
||||
# Working = recent activity (not just DB is_active flag)
|
||||
soul["working"] = events_6h > 0
|
||||
soul["activity"] = (
|
||||
"busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5)
|
||||
else ("active" if events_6h > 0 else ("idle" if event_count > 0 else "offline"))
|
||||
)
|
||||
out.append(soul)
|
||||
return out
|
||||
|
||||
|
||||
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one(
|
||||
"""SELECT s.*,
|
||||
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count
|
||||
FROM agent_souls s WHERE agent_key = %s""",
|
||||
(agent_key.lower(),),
|
||||
)
|
||||
row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),))
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
key = agent_key.lower()
|
||||
stats = _aggregate_event_stats().get(key, {})
|
||||
out["event_count"] = int(stats.get("event_count") or 0)
|
||||
out["events_6h"] = int(stats.get("events_6h") or 0)
|
||||
out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"])
|
||||
out["recent_events"] = list_agent_events(agent_key, limit=15)
|
||||
return out
|
||||
|
||||
|
||||
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
names = aliases_for_key(agent_key)
|
||||
if not names:
|
||||
return []
|
||||
placeholders = ",".join(["%s"] * len(names))
|
||||
rows = fetch_all(
|
||||
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
||||
f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
WHERE LOWER(agent_name) IN ({placeholders})
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s""",
|
||||
(agent_key.lower(), limit),
|
||||
(*[n.lower() for n in names], limit),
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
|
||||
@@ -208,3 +208,96 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
|
||||
|
||||
/* Live working animations */
|
||||
.agent-soul-card {
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(12, 18, 28, 0.85);
|
||||
}
|
||||
.agent-soul-card.is-working {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12);
|
||||
animation: agent-card-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy {
|
||||
border-color: rgba(251, 191, 36, 0.65);
|
||||
box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12);
|
||||
animation: agent-card-pulse-busy 1.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap,
|
||||
.agent-soul-card.is-busy .agent-char-wrap {
|
||||
animation: agent-avatar-bob 1.1s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap::after,
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%);
|
||||
animation: agent-glow-sweep 2.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%);
|
||||
}
|
||||
.agent-activity-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 12px;
|
||||
margin-left: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.agent-activity-eq span {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #38bdf8;
|
||||
animation: agent-eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; }
|
||||
.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; }
|
||||
.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; }
|
||||
.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; }
|
||||
.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; }
|
||||
|
||||
.agent-status-pill.active {
|
||||
border-color: rgba(34,197,94,0.45);
|
||||
color: #86efac;
|
||||
background: rgba(34,197,94,0.1);
|
||||
}
|
||||
.agent-status-pill.busy {
|
||||
border-color: rgba(245,158,11,0.5);
|
||||
color: #fcd34d;
|
||||
background: rgba(245,158,11,0.12);
|
||||
animation: agent-pill-blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-status-pill.idle .dot { background: #64748b; }
|
||||
.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; }
|
||||
.agent-status-pill.offline { color: #64748b; }
|
||||
|
||||
@keyframes agent-card-pulse {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
@keyframes agent-card-pulse-busy {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-3px) scale(1.01); }
|
||||
}
|
||||
@keyframes agent-avatar-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
@keyframes agent-glow-sweep {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
@keyframes agent-eq {
|
||||
0%, 100% { transform: scaleY(0.55); opacity: 0.7; }
|
||||
50% { transform: scaleY(1.25); opacity: 1; }
|
||||
}
|
||||
@keyframes agent-pill-blink {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||||
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
|
||||
<script src="/static/js/agent-avatars.js?v=3"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -46,18 +46,21 @@
|
||||
<div class="hm-neo-kpi-row" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner">⚡</div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Aan het werk</div><div class="hm-neo-kpi-sub" x-text="idleCount + ' idle · ' + offlineCount + ' offline'">live status</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">Co-CEO · takenverdeler</div></div></div><div class="hm-neo-kpi-value" style="font-size:1.1rem">Co-CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="agents-neo-grid">
|
||||
<template x-for="s in souls" :key="s.agent_key">
|
||||
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
|
||||
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key, 'is-working': isWorking(s) && !isBusy(s), 'is-busy': isBusy(s) }" @click="select(s.agent_key)">
|
||||
<div class="agent-char-wrap" :class="s.agent_key"
|
||||
:data-agent-avatar="s.agent_key"
|
||||
:data-agent-color="s.avatar_color || '#1e3a5f'"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<strong>
|
||||
<span x-text="s.display_name"></span>
|
||||
<span class="agent-activity-eq" x-show="isWorking(s) || isBusy(s)" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
|
||||
</strong>
|
||||
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
|
||||
<span class="agent-status-pill" :class="statusClass(s)">
|
||||
<span class="dot"></span>
|
||||
@@ -317,12 +320,20 @@ function agentsSoulHub() {
|
||||
meshLegendExec: true,
|
||||
focusTerminalKey: null, focusTerminalName: '', expandedTerminal: null,
|
||||
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
|
||||
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
|
||||
get activeCount() { return this.souls.filter(s => (s.events_6h||0) > 0).length; },
|
||||
get idleCount() { return this.souls.filter(s => (s.events_6h||0) === 0 && (s.event_count||0) > 0).length; },
|
||||
get offlineCount() { return this.souls.filter(s => (s.event_count||0) === 0).length; },
|
||||
isBusy(s) {
|
||||
const st = (s.current_status || '').toLowerCase();
|
||||
return st === 'needs_approval' || st === 'pending' || (s.activity === 'busy') || ((s.events_6h||0) >= 5);
|
||||
},
|
||||
isWorking(s) { return (s.events_6h||0) > 0 || s.working === true; },
|
||||
statusClass(s) {
|
||||
const st = (s.current_status || '').toLowerCase();
|
||||
if (st === 'needs_approval' || st === 'pending') return 'busy';
|
||||
if (st === 'needs_approval' || st === 'pending' || this.isBusy(s)) return 'busy';
|
||||
if (st === 'error' || st === 'rejected') return 'error';
|
||||
if (st === 'completed' || (s.events_6h > 0)) return 'active';
|
||||
if ((s.events_6h||0) > 0 || s.activity === 'active') return 'active';
|
||||
if ((s.event_count||0) === 0) return 'offline';
|
||||
return 'idle';
|
||||
},
|
||||
formatEventTime(ts) {
|
||||
@@ -363,15 +374,17 @@ function agentsSoulHub() {
|
||||
const st = (s.current_status || '').toLowerCase();
|
||||
if (st === 'needs_approval') return 'Wacht op goedkeuring';
|
||||
if (st === 'pending') return 'In wachtrij';
|
||||
if (st === 'completed') return 'Klaar';
|
||||
if (st === 'error') return 'Fout';
|
||||
if (s.current_task) return 'Bezig';
|
||||
return 'Standby';
|
||||
if (this.isBusy(s)) return 'Bezig · live';
|
||||
if ((s.events_6h||0) > 0) return 'Actief · ' + (s.events_6h||0) + '/6u';
|
||||
if ((s.event_count||0) === 0) return 'Offline';
|
||||
return 'Idle';
|
||||
},
|
||||
async init() {
|
||||
this.syncMeshMode();
|
||||
await this.load();
|
||||
await this.loadPendingCount();
|
||||
this._poll = setInterval(() => { this.load(true); this.loadPendingCount(); }, 20000);
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('agents', (mode) => this.renderViz(mode));
|
||||
}
|
||||
@@ -496,10 +509,16 @@ function agentsSoulHub() {
|
||||
}
|
||||
});
|
||||
},
|
||||
async load() {
|
||||
async load(quiet) {
|
||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
||||
this.souls = r.items || [];
|
||||
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
|
||||
this.$nextTick(() => {
|
||||
(this.souls || []).forEach((s) => {
|
||||
const el = document.querySelector('[data-agent-avatar="' + s.agent_key + '"]');
|
||||
if (el && window.AgentAvatars) AgentAvatars.paint(el, s.agent_key, s.avatar_color);
|
||||
});
|
||||
});
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user