2026-06-09 00:41:27 +00:00
{% extends "base.html" %}
{% block extra_head %}
2026-06-09 10:41:13 +00:00
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" ></ script >
2026-06-09 00:41:27 +00:00
< link rel = "stylesheet" href = "/static/css/hermes.css" />
2026-06-09 10:41:13 +00:00
< link rel = "stylesheet" href = "/static/css/herman-dashboard.css?v=13" />
2026-06-23 10:04:23 +00:00
< link rel = "stylesheet" href = "/static/css/agents-mesh.css?v=8" />
2026-08-16 22:40:32 +00:00
< link rel = "stylesheet" href = "/static/css/agent-characters.css?v=8" />
< link rel = "stylesheet" href = "/static/css/agents-warroom.css?v=40" />
< script src = "/static/js/agent-avatars.js?v=5" ></ script >
2026-06-09 00:41:27 +00:00
{% endblock %}
{% block content %}
2026-08-16 22:40:32 +00:00
< div class = "hm-neo" :class = "{ 'tab-warroom-active': tab==='warroom' }" x-data = "agentsSoulHub()" x-init = "init()" >
2026-08-16 11:01:58 +00:00
< header class = "hm-neo-header page-header" x-show = "tab!=='warroom'" x-cloak >
2026-06-09 00:41:27 +00:00
< h1 > Agents · Soul Registry</ h1 >
< p class = "hm-sub" > Animated team · duidelijke rollen · soul.md · live activiteit</ p >
</ header >
2026-08-16 11:01:58 +00:00
< header class = "hm-neo-header page-header wr-page-header" x-show = "tab==='warroom'" x-cloak >
< h1 > War Room</ h1 >
< p class = "hm-sub" > Teamchat · @mentions · 1:1 DM · pop-out voor tweede scherm</ p >
</ header >
2026-06-09 00:41:27 +00:00
2026-08-16 11:01:58 +00:00
< div id = "viz-toolbar" class = "viz-toolbar" x-show = "tab!=='warroom'" x-cloak ></ div >
< div class = "hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style = "margin-bottom:1rem"
x-show = "tab!=='warroom'" x-cloak >
2026-06-09 10:41:13 +00:00
< div class = "hm-chart-head" >< h4 > Agent activiteit (events)</ h4 >< small > 5 visualisatie-modi</ small ></ div >
< div class = "page-viz-body" >
< div class = "hm-chart-wrap page-viz-canvas" >< canvas id = "chart-agents-viz" ></ canvas ></ div >
< div id = "viz-agents-alt" class = "viz-alt" ></ div >
</ div >
</ div >
2026-06-09 00:41:27 +00:00
< div class = "vtabs-layout" style = "--vt-color:#ffd700" x-ref = "vtabsWrap" >
< nav class = "vtabs-nav" x-ref = "vtabsNav" :style = "'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'" >
2026-06-23 10:04:23 +00:00
< div class = "vtabs-nav-header" >< strong > Agents</ strong ></ div >
2026-08-16 22:40:32 +00:00
< button class = "vtab-btn" :class = "{active: tab==='warroom'}" @ click = "setTab('warroom', $el)" data-tab = "warroom" >
< span class = "vtab-icon vtab-icon-svg" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z" />< path d = "m9 12 2 2 4-4" /></ svg ></ span >< span > War Room</ span >
</ button >
2026-06-09 00:41:27 +00:00
< button class = "vtab-btn" :class = "{active: tab==='souls'}" @ click = "setTab('souls', $el)" >
2026-08-16 22:40:32 +00:00
< span class = "vtab-icon vtab-icon-svg" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 8V4H8" />< rect width = "16" height = "12" x = "4" y = "8" rx = "2" />< path d = "M2 14h2" />< path d = "M20 14h2" />< path d = "M15 13v2" />< path d = "M9 13v2" /></ svg ></ span >< span > Souls</ span >
2026-06-09 00:41:27 +00:00
</ button >
< button class = "vtab-btn" :class = "{active: tab==='mesh'}" @ click = "setTab('mesh', $el)" >
2026-08-16 22:40:32 +00:00
< span class = "vtab-icon vtab-icon-svg" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "12" cy = "5" r = "2" />< circle cx = "5" cy = "19" r = "2" />< circle cx = "19" cy = "19" r = "2" />< path d = "M12 7v4" />< path d = "m6.7 17.4 3.6-2.4" />< path d = "m17.3 17.4-3.6-2.4" />< circle cx = "12" cy = "12" r = "2" /></ svg ></ span >< span > Netwerk</ span >
2026-06-09 00:41:27 +00:00
</ button >
2026-06-23 10:04:23 +00:00
< button class = "vtab-btn" :class = "{active: tab==='terminals'}" @ click = "setTab('terminals', $el)" data-tab = "terminals" >
2026-08-16 22:40:32 +00:00
< span class = "vtab-icon vtab-icon-svg" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< rect width = "20" height = "14" x = "2" y = "3" rx = "2" />< path d = "M8 21h8" />< path d = "M12 17v4" />< path d = "m7 8 3 3-3 3" />< path d = "M13 14h3" /></ svg ></ span >< span > Terminals</ span >
2026-06-23 10:04:23 +00:00
</ button >
2026-06-09 10:41:13 +00:00
< button class = "vtab-btn" :class = "{active: tab==='approvals'}" @ click = "setTab('approvals', $el)" >
2026-08-16 22:40:32 +00:00
< span class = "vtab-icon vtab-icon-svg" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M22 11.08V12a10 10 0 1 1-5.93-9.14" />< path d = "m9 11 3 3L22 4" /></ svg ></ span >< span > Goedkeuringen</ span >
2026-06-09 10:41:13 +00:00
< span class = "vtab-badge" x-show = "pendingCount" x-text = "pendingCount" ></ span >
</ button >
2026-06-09 00:41:27 +00:00
</ nav >
< div class = "vtabs-content" >
< section x-show = "tab==='souls'" x-cloak >
< 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 >
2026-08-01 17:59:32 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
</ div >
< div class = "agents-neo-grid" >
< template x-for = "s in souls" :key = "s.agent_key" >
2026-08-01 17:59:32 +00:00
< 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)" >
2026-06-09 10:41:13 +00:00
< 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 >
2026-08-01 17:59:32 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
< div class = "hm-neo-kpi-sub" x-text = "s.role_title" ></ div >
< span class = "agent-status-pill" :class = "statusClass(s)" >
< span class = "dot" ></ span >
< span x-text = "statusLabel(s)" ></ span >
</ span >
< p class = "agent-current-task" x-text = "s.current_task || 'Wacht op taak…'" ></ p >
2026-06-09 12:56:59 +00:00
< p class = "agent-role-desc" x-text = "s.responsibilities || '—'" ></ p >
2026-06-09 00:41:27 +00:00
< div class = "agent-meta-row" >
2026-06-23 10:04:23 +00:00
< button type = "button" class = "agent-events-link"
@ click . stop = "goTerminal(s.agent_key)"
title = "Open live terminal" > Terminal →</ button >
2026-06-09 10:41:13 +00:00
< button type = "button" class = "agent-events-link"
@ click . stop = "openEvents(s.agent_key, s.display_name)"
x-text = "(s.event_count||0) + ' events'"
:title = "'Bekijk wat ' + s.display_name + ' heeft gedaan'" ></ button >
< span class = "agent-tag" x-text = "s.agent_key" ></ span >
2026-06-09 00:41:27 +00:00
</ div >
</ article >
</ template >
</ div >
< div class = "agent-soul-editor" x-show = "selected" x-cloak >
< div class = "agent-editor-header" >
2026-06-09 10:41:13 +00:00
< div class = "agent-char-wrap large" :class = "selected?.agent_key"
:data-agent-avatar = "selected?.agent_key"
:data-agent-color = "selected?.avatar_color || '#1e3a5f'"
x-effect = "selected && $nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, selected.agent_key, selected.avatar_color))" ></ div >
2026-06-09 00:41:27 +00:00
< div style = "flex:1" >
< h2 >< span x-text = "selected?.display_name" ></ span > — < span x-text = "selected?.role_title" ></ span ></ h2 >
< p style = "margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text = "selected?.responsibilities" ></ p >
</ div >
< div style = "display:flex;gap:0.5rem;flex-shrink:0" >
< button class = "btn btn-pulse-green" @ click = "save()" :disabled = "saving" > Opslaan</ button >
< a href = "/settings?tab=permissions" class = "btn btn-pulse-purple" > Herman rechten →</ a >
</ div >
</ div >
< label > Rol titel< input class = "form-input" x-model = "form.role_title" ></ label >
< label > Functie & werkzaamheden< textarea class = "form-input" rows = "3" x-model = "form.responsibilities" ></ textarea ></ label >
< label > Soul.md (karakter, tone of voice, regels)< textarea x-model = "form.soul_md" ></ textarea ></ label >
2026-06-09 10:41:13 +00:00
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:1rem" >
< h3 style = "font-size:0.75rem;text-transform:uppercase;color:#64748b;margin:0" > Recente activiteit</ h3 >
< button type = "button" class = "agent-events-link" x-show = "selected" @ click = "openEvents(selected.agent_key, selected.display_name)" > Alle events →</ button >
</ div >
2026-06-09 00:41:27 +00:00
< template x-for = "ev in (selected?.recent_events||[])" :key = "ev.id" >
2026-06-09 10:41:13 +00:00
< button type = "button" class = "hub-feed-item agent-event-clickable" @ click = "openEvents(selected.agent_key, selected.display_name, ev.id)" >
< span class = "agent-event-time" x-text = "formatEventTime(ev.created_at)" ></ span >
< span class = "agent-event-title" x-text = "ev.title||ev.event_type" ></ span >
< span class = "badge badge-sm" x-text = "ev.status||'—'" ></ span >
</ button >
2026-06-09 00:41:27 +00:00
</ template >
< p x-show = "!(selected?.recent_events||[]).length" class = "muted" > Nog geen events voor deze agent.</ p >
</ div >
</ section >
2026-06-23 10:04:23 +00:00
< section x-show = "tab==='mesh'" x-cloak
x-effect = "if (tab === 'mesh') { $nextTick(() => mountMesh()) }" >
2026-06-09 00:41:27 +00:00
< div class = "panel" >
2026-06-23 10:04:23 +00:00
< div class = "mesh-framework-head" >
< h2 style = "margin-top:0;" > Agent Communicatie Framework</ h2 >
< p class = "page-tip" style = "margin-top:.25rem;" > Kies hoe je het netwerk wilt zien. Klik een agent → live terminal. Alle terminals onder tab < strong > Terminals</ strong > .</ p >
</ div >
< div class = "mesh-mode-section" id = "mesh-mode-picker" >
< label > Communicatiestijl</ label >
< div class = "mesh-mode-grid" >
< button type = "button" class = "mesh-mode-card" data-mesh-mode = "pyramid" aria-pressed = "false"
onclick = "window.AgentsMesh&&AgentsMesh.pickMode('pyramid')" >
< span class = "mesh-mode-check" aria-hidden = "true" ></ span >
< span class = "mesh-mode-icon" > ◆</ span >
< span class = "mesh-mode-title" > Piramide</ span >
< span class = "mesh-mode-sub" > Herman als hub</ span >
< span class = "mesh-mode-desc" > Rapport ↑ en delegatie ↓. Alleen live handoffs.</ span >
</ button >
< button type = "button" class = "mesh-mode-card" data-mesh-mode = "mesh" aria-pressed = "false"
onclick = "window.AgentsMesh&&AgentsMesh.pickMode('mesh')" >
< span class = "mesh-mode-check" aria-hidden = "true" ></ span >
< span class = "mesh-mode-icon" > ⬡</ span >
< span class = "mesh-mode-title" > Vol netwerk</ span >
< span class = "mesh-mode-sub" > Alle partners</ span >
< span class = "mesh-mode-desc" > Volledige samenwerkingsmatrix zichtbaar.</ span >
</ button >
< button type = "button" class = "mesh-mode-card" data-mesh-mode = "org" aria-pressed = "false"
onclick = "window.AgentsMesh&&AgentsMesh.pickMode('org')" >
< span class = "mesh-mode-check" aria-hidden = "true" ></ span >
< span class = "mesh-mode-icon" > ▤</ span >
< span class = "mesh-mode-title" > Organigram</ span >
< span class = "mesh-mode-sub" > Pure hiërarchie</ span >
< span class = "mesh-mode-desc" > CEO → Herman → agents, geen peer-lijnen.</ span >
</ button >
< button type = "button" class = "mesh-mode-card" data-mesh-mode = "pulse" aria-pressed = "false"
onclick = "window.AgentsMesh&&AgentsMesh.pickMode('pulse')" >
< span class = "mesh-mode-check" aria-hidden = "true" ></ span >
< span class = "mesh-mode-icon" > ◎</ span >
< span class = "mesh-mode-title" > Live stream</ span >
< span class = "mesh-mode-sub" > Alleen actief</ span >
< span class = "mesh-mode-desc" > Geen stille lijnen — alleen wat nu beweegt.</ span >
</ button >
< button type = "button" class = "mesh-mode-card" data-mesh-mode = "clusters" aria-pressed = "false"
onclick = "window.AgentsMesh&&AgentsMesh.pickMode('clusters')" >
< span class = "mesh-mode-check" aria-hidden = "true" ></ span >
< span class = "mesh-mode-icon" > ◉</ span >
< span class = "mesh-mode-title" > Domeinen</ span >
< span class = "mesh-mode-sub" > Per afdeling</ span >
< span class = "mesh-mode-desc" > Groei · Product · Operatie gegroepeerd.</ span >
</ button >
</ div >
</ div >
< div class = "mesh-toolbar" >
< span id = "mesh-mode-badge" class = "mesh-mode-badge" > Piramide</ span >
< button type = "button" class = "btn btn-sm" @ click = "remountMesh()" > ↻ Verversen</ button >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "setTab('terminals', $refs.vtabsNav?.querySelector('[data-tab=terminals]'))" > Alle terminals</ button >
</ div >
2026-06-09 00:41:27 +00:00
< div class = "mesh-wrap" >
2026-06-23 10:04:23 +00:00
< svg id = "agents-mesh-canvas" class = "mesh-svg" viewBox = "0 0 1200 860" ></ svg >
2026-06-09 00:41:27 +00:00
</ div >
< div class = "mesh-legend" >
2026-06-23 10:04:23 +00:00
< span class = "leg-report" > ↑ Rapport</ span >
< span class = "leg-delegate" > ↓ Delegatie</ span >
< span class = "leg-peer" x-show = "meshLegendPeer" > ↔ Handoff</ span >
< span class = "leg-exec" x-show = "meshLegendExec" > ⇅ Executive</ span >
</ div >
< div class = "mesh-terminal-focus" x-show = "focusTerminalKey" x-cloak >
< div class = "agent-term-head" >
< span class = "term-dot" ></ span >
< strong x-text = "'Terminal · ' + (focusTerminalName || '')" ></ strong >
< button type = "button" class = "btn btn-sm" @ click = "clearFocusTerminal()" > Sluiten</ button >
</ div >
< div class = "agent-term-body" :data-term-key = "focusTerminalKey" data-term-focus = "1" ></ div >
</ div >
</ div >
</ section >
< section x-show = "tab==='terminals'" x-cloak >
< div class = "panel" >
< h2 style = "margin-top:0;" > Agent terminals</ h2 >
< p class = "agents-terminals-intro" > Elke agent heeft een eigen terminal. Typ commando's en druk < kbd > Enter</ kbd > — output verschijnt live. Start met < code > help</ code > .</ p >
< div class = "agents-terminals-grid" >
< template x-for = "s in souls" :key = "'term-' + s.agent_key" >
< article class = "agent-term-window"
:class = "{ 'is-live': (s.events_6h||0) > 0 || s.current_task, 'is-expanded': expandedTerminal === s.agent_key }"
@ click = "expandTerminal(s.agent_key)" >
< header class = "agent-term-head" >
< span class = "term-dot" ></ span >
< strong x-text = "s.display_name" ></ strong >
< small x-text = "s.current_task || s.last_event_title || 'Standby'" ></ small >
</ header >
< div class = "agent-term-body" :data-term-key = "s.agent_key" ></ div >
< footer class = "agent-term-foot" @ click . stop >
< label class = "agent-term-cmd" :for = "'term-in-' + s.agent_key" >
< span class = "term-prompt" x-text = "s.agent_key + '>'" ></ span >
< input type = "text"
:id = "'term-in-' + s.agent_key"
class = "agent-term-input"
:data-term-input = "s.agent_key"
:placeholder = "s.agent_key === 'herman' ? 'briefing · vraag aan Herman…' : (s.agent_key === 'webbuilder' ? 'build <opdracht> · projects · status …' : 'help · status · handoff …')"
autocomplete = "off"
spellcheck = "false" />
</ label >
</ footer >
</ article >
</ template >
2026-06-09 00:41:27 +00:00
</ div >
</ div >
</ section >
2026-06-09 10:41:13 +00:00
< section x-show = "tab==='approvals'" x-cloak x-data = "approvalsPanel()" x-init = "load()" >
< div class = "panel" >
< div style = "display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem" >
< div >
< h2 style = "margin:0;" > Approval queue</ h2 >
< p class = "page-tip" > Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.</ p >
</ div >
< button class = "btn btn-sm btn-pulse" @ click = "load()" > ↻ Verversen</ button >
</ div >
< table class = "data-table" >
< thead >< tr >< th > #</ th >< th > Agent</ th >< th > Actie</ th >< th > Titel</ th >< th > Status</ th >< th > Tijd</ th >< th ></ th ></ tr ></ thead >
< tbody >
< template x-for = "r in items" :key = "r.id" >
< tr >
< td x-text = "r.id" ></ td >
< td x-text = "r.agent_key" ></ td >
< td x-text = "r.action_type" ></ td >
< td x-text = "r.title" ></ td >
< td >< span class = "badge" :class = "r.status==='pending'?'badge-warn':'badge-positive'" x-text = "r.status" ></ span ></ td >
< td x-text = "(r.created_at||'').substring(0,16).replace('T',' ')" ></ td >
< td >
< template x-if = "r.status==='pending'" >
< span >
< button class = "btn btn-sm btn-approve" @ click = "approve(r.id)" > ✓</ button >
< button class = "btn btn-sm btn-reject" @ click = "reject(r.id)" > ✗</ button >
</ span >
</ template >
</ td >
</ tr >
</ template >
</ tbody >
</ table >
< p x-show = "!items.length" class = "muted" > Geen approval requests.</ p >
</ div >
</ section >
2026-08-16 11:01:58 +00:00
2026-08-16 15:49:16 +00:00
< section x-show = "tab==='warroom'" x-cloak >
< div x-data = "warRoomHub()" x-init = "init()" class = "wr-hub-root" >
2026-08-16 11:01:58 +00:00
< div class = "panel" style = "padding:0.75rem" >
2026-08-16 22:40:32 +00:00
< div class = "wr-layout" :class = "{'roster-collapsed': rosterCollapsed}" >
< div class = "wr-mobile-bar" >
< button type = "button" class = "btn btn-sm" @ click = "rosterCollapsed = !rosterCollapsed"
x-text = "rosterCollapsed ? '👥 Agents' : '▾ Agents'" ></ button >
< button type = "button" class = "btn btn-sm wr-a2a-btn"
:class = "autoDebate ? 'is-on' : 'is-off'"
@ click = "toggleA2A()"
x-text = "autoDebate ? 'A2A · aan' : 'A2A · uit'" ></ button >
</ div >
2026-08-16 11:01:58 +00:00
< aside class = "wr-roster" >
< h3 > Kanalen</ h3 >
< button type = "button" class = "wr-room-btn" :class = "{active: room && room.kind==='warroom'}" @ click = "openWarroom()" >
2026-08-16 22:40:32 +00:00
< span class = "wr-chan-icon" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z" />< path d = "m9 12 2 2 4-4" /></ svg ></ span >< strong > #war-room</ strong >
2026-08-16 11:01:58 +00:00
</ button >
< h3 style = "margin-top:0.85rem" > Agents</ h3 >
< template x-for = "s in souls" :key = "'wr-'+s.agent_key" >
< button type = "button" class = "wr-agent-btn"
:class = "{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
2026-08-16 15:49:16 +00:00
@ click = "selectAgent(s.agent_key)"
@ dblclick . prevent = "openDm(s.agent_key)"
title = "Klik = @tag in War Room · dubbelklik = DM" >
2026-08-16 11:01:58 +00:00
< div class = "agent-char-wrap"
: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 >
< div class = "wr-agent-meta" >
< strong x-text = "s.display_name" ></ strong >
< small x-text = "'@'+s.agent_key" ></ small >
</ div >
</ button >
</ template >
</ aside >
< div class = "wr-main" >
< div class = "wr-head" >
2026-08-16 22:40:32 +00:00
< div class = "wr-head-title" >
2026-08-16 11:01:58 +00:00
< h2 x-text = "room ? (room.title || ('#'+room.kind)) : 'War Room'" ></ h2 >
< small class = "muted" x-show = "status" x-text = "status" ></ small >
2026-08-16 22:40:32 +00:00
< small class = "wr-msg-count muted" x-show = "messages.length"
x-text = "(screenCleared ? '0' : visibleMessages().length) + ' / ' + messages.length + ' berichten'" ></ small >
2026-08-16 11:01:58 +00:00
</ div >
< div class = "wr-head-actions" >
2026-08-16 22:40:32 +00:00
< button type = "button" class = "btn btn-sm wr-tool-btn" :class = "{active: searchOpen}" @ click = "toggleSearch()" title = "Zoeken in chat" > 🔍 Zoek</ button >
< div class = "wr-tools-wrap" >
< button type = "button" class = "btn btn-sm wr-tool-btn" @ click = "toolsOpen=!toolsOpen" title = "Meer acties" > ⋯</ button >
< div class = "wr-tools-menu" x-show = "toolsOpen" @ click . outside = "toolsOpen=false" x-cloak >
< button type = "button" @ click = "downloadChat('txt')" > ⬇ Download .txt</ button >
< button type = "button" @ click = "downloadChat('md')" > ⬇ Download .md</ button >
< button type = "button" @ click = "downloadChat('json')" > ⬇ Download .json</ button >
< hr />
< button type = "button" @ click = "clearScreen(); toolsOpen=false" > 🧹 Scherm legen</ button >
< button type = "button" x-show = "screenCleared" @ click = "restoreScreen(); toolsOpen=false" > ↩ Scherm herstellen</ button >
< button type = "button" class = "wr-danger" @ click = "clearChatHistory(); toolsOpen=false" > 🗑 Geschiedenis wissen</ button >
</ div >
</ div >
< button type = "button" class = "btn btn-sm wr-a2a-btn"
:class = "autoDebate ? 'is-on' : 'is-off'"
@ click = "toggleA2A()"
:title = "autoDebate ? 'A2A staat aan — klik om uit te zetten' : 'A2A staat uit — klik om aan te zetten'" >
< span x-text = "autoDebate ? 'A2A · aan' : 'A2A · uit'" ></ span >
</ button >
< button type = "button" class = "btn btn-sm wr-ceo-btn" @ click = "openCeoInbox()" title = "Direct aan jou (@aissa)" >
📬 CEO-inbox
< span class = "wr-ceo-badge" x-show = "ceoUnread > 0" x-text = "ceoUnread" ></ span >
</ button >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "openVoiceRoom()" title = "Spraakmeeting met agents" > 🎙️ Voice</ button >
< button type = "button" class = "btn btn-sm" :class = "{'is-recording': voiceRecording}" @ mousedown . prevent = "startVoiceTalk($event)" @ mouseup . prevent = "stopVoiceTalk($event)" @ touchstart . prevent = "startVoiceTalk($event)" @ touchend . prevent = "stopVoiceTalk($event)" :disabled = "voiceBusy || busy || !room" title = "Snel praten" > 🎤</ button >
< button type = "button" class = "btn btn-sm" @ click = "popOut()" title = "Open op tweede scherm" > ↗</ button >
< button type = "button" class = "btn btn-sm" @ click = "room && openRoom(room.id)" :disabled = "busy" title = "Vernieuwen" > ↻</ button >
2026-08-16 11:01:58 +00:00
</ div >
</ div >
2026-08-16 22:40:32 +00:00
< div class = "wr-searchbar" x-show = "searchOpen" x-cloak >
< input type = "search" class = "form-input" x-ref = "wrSearch" x-model = "chatSearch"
placeholder = "Zoek in berichten, @agent, tekst…" />
< button type = "button" class = "btn btn-sm" @ click = "chatSearch=''" x-show = "chatSearch" > Wis</ button >
< button type = "button" class = "btn btn-sm" @ click = "toggleSearch()" > Sluit</ button >
</ div >
< div class = "wr-log-wrap" >< div class = "wr-log" x-ref = "wrLog" @ scroll . passive = "onLogScroll()" >
< p class = "wr-empty" x-show = "screenCleared && !typing" >
Scherm is geleegd.
< button type = "button" class = "btn btn-sm" @ click = "restoreScreen()" > Herstellen</ button >
</ p >
< p class = "wr-empty" x-show = "!screenCleared && !visibleMessages().length && !typing && messages.length" > Geen treffers voor deze zoekopdracht.</ p >
< p class = "wr-empty" x-show = "!screenCleared && !messages.length && !typing" > Nog geen berichten — tag agents met @marketing of deel een bestand.</ p >
< template x-for = "(m, mi) in visibleMessages()" :key = "'msg-'+m.id" >
< div class = "wr-msg-wrap" :data-msg-id = "m.id" >
< div class = "wr-day-sep" x-show = "showDaySep(mi)" >
< span x-text = "formatMsgDate(m)" ></ span >
</ div >
2026-08-16 11:01:58 +00:00
< div class = "wr-bubble"
2026-08-16 22:40:32 +00:00
:class = "{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system', 'is-ceo-direct': isCeoDirect(m), 'is-reply-target': replyHighlightId == m.id}" >
2026-08-16 11:01:58 +00:00
< div class = "wr-avatar" x-show = "m.sender_type!=='system'" >
< template x-if = "m.sender_type==='agent'" >
< div class = "agent-char-wrap"
:data-agent-avatar = "m.agent_key"
data-agent-color = "#1e3a5f"
x-init = "$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))" ></ div >
</ template >
< template x-if = "m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)" >
< img :src = "m.sender_avatar_url || me.avatar_url" alt = "" />
</ template >
< template x-if = "m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)" >
< span class = "wr-avatar-fallback" x-text = "(m.username||me.username||'?').slice(0,2).toUpperCase()" ></ span >
</ template >
</ div >
< div class = "wr-bubble-body" >
< div class = "wr-bubble-meta" >
2026-08-16 22:40:32 +00:00
< span class = "wr-who" x-text = "labelForMessage(m)" ></ span >
< span class = "wr-handle" x-show = "m.agent_key" x-text = "'@'+m.agent_key" ></ span >
< time class = "wr-time" x-text = "formatMsgTime(m)" :title = "formatMsgDate(m) + ' ' + formatMsgTime(m)" ></ time >
< button type = "button" class = "wr-msg-reply" title = "Beantwoorden"
@ click . stop = "setReply(m)" > ↩ Reply</ button >
< button type = "button" class = "wr-msg-del" title = "Bericht verwijderen"
@ click . stop . prevent = "deleteMessage(m)" > 🗑</ button >
</ div >
< div class = "wr-reply-quote" x-show = "replyPreview(m)" x-cloak >
< span class = "wr-reply-quote-who" x-text = "'↩ ' + ((replyPreview(m)||{}).agent_key ? ('@'+(replyPreview(m).agent_key)) : ((replyPreview(m)||{}).username || 'bericht'))" ></ span >
< span class = "wr-reply-quote-text" x-text = "(replyPreview(m)||{}).excerpt" ></ span >
2026-08-16 11:01:58 +00:00
</ div >
< div class = "wr-bubble-text" x-text = "m.content" ></ div >
2026-08-16 15:49:16 +00:00
< div class = "wr-attach-list" x-show = "attachmentsOf(m).length" >
< template x-for = "(a, ai) in attachmentsOf(m)" :key = "m.id+'-a-'+ai" >
< a class = "wr-attach-card" :href = "a.url" target = "_blank" rel = "noopener" >
< img x-show = "isImage(a)" :src = "a.url" :alt = "a.name" />
< span x-text = "a.name" ></ span >
</ a >
</ template >
</ div >
2026-08-16 11:01:58 +00:00
</ div >
</ div >
2026-08-16 22:40:32 +00:00
</ div >
2026-08-16 11:01:58 +00:00
</ template >
2026-08-16 15:49:16 +00:00
< div class = "wr-bubble is-agent is-typing" x-show = "typing" x-cloak >
< div class = "wr-avatar" >
< div class = "agent-char-wrap"
:data-agent-avatar = "typing && typing.agent_key"
x-effect = "if (typing && window.AgentAvatars) AgentAvatars.paint($el, typing.agent_key)" ></ div >
</ div >
< div class = "wr-bubble-body" >
< div class = "wr-bubble-meta" >
< span x-text = "(typing && typing.display_name) || 'Agent'" ></ span >
< span > · typt</ span >
</ div >
< div class = "wr-bubble-text wr-typing-cursor" x-text = "(typing && typing.text) || '…'" ></ div >
</ div >
</ div >
2026-08-16 11:01:58 +00:00
</ div >
2026-08-16 22:40:32 +00:00
< button type = "button" class = "wr-jump-latest" x-show = "showJumpLatest" x-cloak
@ click = "jumpToLatest()" title = "Naar nieuwste berichten" >
↓ Nieuwste
</ button >
</ div >
< div class = "wr-composer" @ click = "focusComposer()" >
2026-08-16 15:49:16 +00:00
< div class = "wr-composer-tools" >
< button type = "button" class = "btn btn-sm" :class = "{'is-recording': voiceRecording}" @ mousedown . prevent = "startVoiceTalk($event)" @ mouseup . prevent = "stopVoiceTalk($event)" @ touchstart . prevent = "startVoiceTalk($event)" @ touchend . prevent = "stopVoiceTalk($event)" :disabled = "voiceBusy || busy" title = "Houd in om te praten" > 🎤</ button >
< button type = "button" class = "btn btn-sm" @ click = "emojiOpen=!emojiOpen" :disabled = "busy" > 😊</ button >
< label class = "btn btn-sm" style = "cursor:pointer;margin:0" >
📎
< input type = "file" hidden multiple @ change = "onPickFiles($event)"
accept = ".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.xlsx,.xls,.doc,.docx,.zip,.json,.md,image/*" />
</ label >
< div class = "wr-emoji-pop" x-show = "emojiOpen" @ click . outside = "emojiOpen=false" x-cloak >
< template x-for = "em in emojis" :key = "em" >
< button type = "button" @ click = "insertEmoji(em)" x-text = "em" ></ button >
</ template >
</ div >
</ div >
< div class = "wr-pending" x-show = "pendingFiles.length" >
< template x-for = "(f, fi) in pendingFiles" :key = "f.url" >
< span class = "wr-pending-chip" >
< span x-text = "f.name" ></ span >
< button type = "button" @ click = "removePending(fi)" title = "Verwijderen" > × </ button >
</ span >
</ template >
</ div >
2026-08-16 22:40:32 +00:00
< div class = "wr-reply-bar" x-show = "replyTo" x-cloak >
< div class = "wr-reply-bar-text" >
< strong x-text = "'↩ Reply aan ' + (replyTo && (replyTo.agent_key ? ('@'+replyTo.agent_key) : (replyTo.label || 'bericht')))" ></ strong >
< span x-text = "replyTo && replyTo.excerpt" ></ span >
</ div >
< button type = "button" class = "btn btn-sm" @ click = "clearReply()" title = "Annuleer reply" > × </ button >
</ div >
2026-08-16 15:49:16 +00:00
< div class = "wr-composer-wrap" >
< div class = "wr-mention-pop" x-show = "mentionOpen && mentionSuggestions().length" x-cloak >
< template x-for = "(s, si) in mentionSuggestions()" :key = "'m-'+s.agent_key" >
< button type = "button" class = "wr-mention-item"
:class = "{active: si===mentionIndex}"
@ mousedown . prevent = "pickMention(s)" >
< strong x-text = "s.display_name" ></ strong >
< small x-text = "'@'+s.agent_key" ></ small >
</ button >
</ template >
</ div >
< textarea class = "form-input" rows = "2" x-ref = "wrInput" x-model = "draft"
2026-08-16 22:40:32 +00:00
@ input = "onDraftInput()" @ keydown = "onKey($event)" @ keydown . enter . prevent = "onEnter($event)"
placeholder = "Typ hier… bv. @herman of @retail"
></ textarea >
2026-08-16 15:49:16 +00:00
</ div >
2026-08-16 22:40:32 +00:00
< button type = "button" class = "btn btn-pulse-green" @ click = "send()" :disabled = "!canSend() || _sendLock" > Stuur</ button >
2026-08-16 11:01:58 +00:00
</ div >
2026-08-16 15:49:16 +00:00
2026-08-16 22:40:32 +00:00
<!-- CEO inbox (@aissa) -->
< div class = "wr-ceo-overlay" x-show = "ceoInboxOpen" x-cloak @ keydown . escape . window = "closeCeoInbox()" >
< div class = "wr-ceo-panel" @ click . stop >
< div class = "wr-ceo-head" >
< div >
< h3 > 📬 CEO-inbox · @aissa</ h3 >
< small class = "muted" > Direct van agents aan jou (@aissa) — ook ongelezen van toen je offline was</ small >
</ div >
< div class = "wr-ceo-head-actions" >
< button type = "button" class = "btn btn-sm" @ click = "markAllCeoRead()" :disabled = "!ceoUnread" > Alles gelezen</ button >
< button type = "button" class = "btn btn-sm" @ click = "loadCeoInbox()" > ↻</ button >
< button type = "button" class = "btn btn-sm" @ click = "closeCeoInbox()" > Sluit</ button >
</ div >
</ div >
< div class = "wr-ceo-list" >
< p class = "wr-empty" x-show = "!(ceoInbox||[]).length" > Nog geen directe berichten. Agents gebruiken @aissa als ze jou echt nodig hebben.</ p >
< template x-for = "item in ceoInbox" :key = "'ceo-'+item.id" >
< article class = "wr-ceo-item" :class = "{ unread: !item.read_at }" >
< div class = "wr-ceo-item-meta" >
< strong x-text = "item.agent_display_name || ('@'+(item.agent_key||'agent'))" ></ strong >
< span x-text = "'@'+(item.agent_key||'')" ></ span >
< time x-text = "formatMsgTime({created_at: item.created_at})" ></ time >
< span class = "wr-ceo-unread-dot" x-show = "!item.read_at" > nieuw</ span >
</ div >
< div class = "wr-ceo-item-body" x-text = "item.content" ></ div >
< div class = "wr-ceo-item-actions" >
< button type = "button" class = "btn btn-sm" x-show = "!item.read_at" @ click = "markCeoRead(item.id)" > Markeer gelezen</ button >
</ div >
</ article >
</ template >
</ div >
</ div >
</ div >
< template x-if = "voiceRoomOpen" >
< div class = "wr-voice-overlay is-open" @ keydown . escape . window = "closeVoiceRoom()" >
2026-08-16 15:49:16 +00:00
< div class = "wr-voice-panel" @ click . stop >
< div class = "wr-voice-head" >
< div >
< h3 > 🎙️ Foodlinkk Voice Room</ h3 >
< small class = "muted" > Jij + agents in één gesprek (spraak)</ small >
</ div >
< div class = "wr-voice-head-actions" >
< label class = "btn btn-sm" style = "display:inline-flex;align-items:center;gap:0.25rem;margin:0;cursor:pointer" >
< input type = "checkbox" x-model = "speakReplies" style = "margin:0" /> 🔊 Voorlezen
</ label >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "closeVoiceRoom()" > Sluiten</ button >
</ div >
</ div >
< div class = "wr-voice-status" x-text = "status || (voiceRecording ? 'Luisteren…' : (voiceSpeakingKey ? (voiceSpeakingKey + ' spreekt…') : 'Houd de microfoon in om te praten'))" ></ div >
< div class = "wr-voice-grid" >
< div class = "wr-voice-card is-human" :class = "{'is-live': voiceRecording}" >
< div class = "wr-voice-avatar wr-voice-avatar-human" x-text = "(me.username||'Jij').slice(0,2).toUpperCase()" ></ div >
< strong x-text = "me.username || 'Jij'" ></ strong >
< small x-show = "voiceRecording" > 🎤 aan het woord</ small >
< small x-show = "!voiceRecording" class = "muted" > mens</ small >
</ div >
< template x-for = "s in voiceAgentSouls()" :key = "'vr-'+s.agent_key" >
< div class = "wr-voice-card is-agent"
:class = "{'is-live': voiceSpeakingKey===s.agent_key, 'is-off': !isVoiceAgent(s.agent_key)}"
@ click = "toggleVoiceAgent(s.agent_key)"
:title = "isVoiceAgent(s.agent_key) ? 'Klik = uit meeting' : 'Klik = in meeting'" >
< div class = "wr-voice-avatar agent-char-wrap"
:data-agent-avatar = "s.agent_key"
x-init = "$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key))" ></ div >
< strong x-text = "s.display_name || s.agent_key" ></ strong >
< small x-show = "voiceSpeakingKey===s.agent_key" > 🔊 spreekt</ small >
< small x-show = "voiceSpeakingKey!==s.agent_key" class = "muted" x-text = "isVoiceAgent(s.agent_key) ? ('@'+s.agent_key) : 'niet in meeting'" ></ small >
</ div >
</ template >
</ div >
< p class = "wr-voice-hint" x-show = "voiceLastHeard" > Laatst gehoord: < span x-text = "voiceLastHeard" ></ span ></ p >
< div class = "wr-voice-ptt" >
< button type = "button" class = "wr-ptt-btn"
:class = "{'is-recording': voiceRecording, 'is-busy': voiceBusy}"
@ mousedown . prevent = "startVoiceTalk($event)" @ mouseup . prevent = "stopVoiceTalk($event)"
@ touchstart . prevent = "startVoiceTalk($event)" @ touchend . prevent = "stopVoiceTalk($event)"
@ mouseleave = "voiceRecording && stopVoiceTalk($event)"
:disabled = "voiceBusy || busy" >
< span x-show = "!voiceRecording && !voiceBusy" > 🎤 Ingedrukt houden</ span >
< span x-show = "voiceRecording" > ● Luisteren… laat los</ span >
< span x-show = "voiceBusy && !voiceRecording" > ⏳ Bezig…</ span >
</ button >
</ div >
< div class = "wr-voice-foot" >
< span class = "muted" > Klik agents om ze in/uit de meeting te zetten. Standaard praat @herman; 🔊 leest antwoorden voor.</ span >
< a class = "btn btn-sm" href = "https://meet.ffmuc.net/FoodlinkkWR1" :href = "callHref()" target = "_blank" rel = "noopener" > Alleen-mensen video ↗</ a >
</ div >
</ div >
</ div >
2026-08-16 22:40:32 +00:00
</ template >
2026-08-16 15:49:16 +00:00
2026-08-16 11:01:58 +00:00
</ div >
</ div >
</ div >
2026-08-16 15:49:16 +00:00
</ div >
2026-08-16 11:01:58 +00:00
</ section >
2026-06-09 00:41:27 +00:00
</ div >
</ div >
2026-06-09 10:41:13 +00:00
2026-08-16 22:40:32 +00:00
< div class = "agent-events-backdrop" :class = "{ 'is-open': eventsOpen }" x-show = "eventsOpen" x-cloak @ click = "closeEvents()" ></ div >
2026-06-09 10:41:13 +00:00
< aside class = "agent-events-drawer" :class = "{ open: eventsOpen }" x-cloak >
< header class = "agent-events-head" >
< div >
< h3 x-text = "eventsAgentName || 'Agent'" ></ h3 >
< p class = "muted" x-text = "eventsList.length + ' activiteiten gelogd'" ></ p >
</ div >
< button type = "button" class = "btn btn-sm" @ click = "closeEvents()" > ✕</ button >
</ header >
< div class = "agent-events-body" x-show = "!eventsLoading" >
< template x-for = "ev in eventsList" :key = "ev.id" >
< article class = "agent-event-card" :class = "{ expanded: selectedEvent?.id === ev.id }" @ click = "toggleEventDetail(ev)" >
< div class = "agent-event-card-top" >
< time x-text = "formatEventTime(ev.created_at)" ></ time >
< span class = "badge badge-sm" :class = "eventStatusClass(ev.status)" x-text = "ev.status || 'completed'" ></ span >
</ div >
< strong x-text = "ev.title || ev.event_type" ></ strong >
< div class = "agent-event-meta" >
< span x-text = "ev.event_type" ></ span >
< span x-show = "ev.channel" x-text = "'· ' + ev.channel" ></ span >
</ div >
< div class = "agent-event-detail" x-show = "selectedEvent?.id === ev.id" x-cloak >
< p x-show = "ev.body" x-text = "ev.body" ></ p >
< pre x-show = "!ev.body && ev.metadata && Object.keys(ev.metadata||{}).length" x-text = "JSON.stringify(ev.metadata, null, 2)" ></ pre >
< p x-show = "!ev.body && !(ev.metadata && Object.keys(ev.metadata||{}).length)" class = "muted" > Geen extra details voor dit event.</ p >
</ div >
</ article >
</ template >
< p x-show = "!eventsList.length && !eventsLoading" class = "muted" style = "padding:1rem" > Nog geen events voor deze agent.</ p >
</ div >
< p x-show = "eventsLoading" class = "muted" style = "padding:1.5rem" > Events laden…</ p >
</ aside >
2026-06-09 00:41:27 +00:00
</ div >
{% endblock %}
{% block scripts %}
2026-06-09 10:41:13 +00:00
< script src = "/static/js/dashboard-layout.js?v=2" ></ script >
< script src = "/static/js/page-viz.js?v=1" ></ script >
2026-06-23 10:04:23 +00:00
< script src = "/static/js/agents-mesh.js?v=11" ></ script >
< script src = "/static/js/agents-terminals.js?v=6" ></ script >
2026-08-16 22:40:32 +00:00
< script src = "/static/js/agents-warroom.js?v=40" ></ script >
2026-06-09 00:41:27 +00:00
< script >
function agentsSoulHub () {
return {
tab : '{{ active_tab }}' ,
tabIndicatorTop : 0 ,
tabIndicatorH : 40 ,
meshLoaded : false ,
2026-06-09 10:41:13 +00:00
pendingCount : 0 ,
2026-06-09 00:41:27 +00:00
souls : [], selected : null , form : {}, saving : false ,
2026-06-09 10:41:13 +00:00
eventsOpen : false , eventsAgentKey : null , eventsAgentName : '' , eventsList : [], eventsLoading : false , selectedEvent : null ,
2026-06-23 10:04:23 +00:00
terminalOpen : false , terminalAgentKey : null , terminalAgentName : '' , terminalLines : [], _terminalEs : null ,
meshCommMode : 'pyramid' ,
meshLegendPeer : true ,
meshLegendExec : true ,
focusTerminalKey : null , focusTerminalName : '' , expandedTerminal : null ,
2026-06-09 00:41:27 +00:00
get totalEvents () { return this . souls . reduce (( a , s ) => a + ( s . event_count || 0 ), 0 ); },
2026-08-01 17:59:32 +00:00
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 ; },
2026-06-09 10:41:13 +00:00
statusClass ( s ) {
const st = ( s . current_status || '' ). toLowerCase ();
2026-08-01 17:59:32 +00:00
if ( st === 'needs_approval' || st === 'pending' || this . isBusy ( s )) return 'busy' ;
2026-06-09 10:41:13 +00:00
if ( st === 'error' || st === 'rejected' ) return 'error' ;
2026-08-01 17:59:32 +00:00
if (( s . events_6h || 0 ) > 0 || s . activity === 'active' ) return 'active' ;
if (( s . event_count || 0 ) === 0 ) return 'offline' ;
2026-06-09 10:41:13 +00:00
return 'idle' ;
},
formatEventTime ( ts ) {
return ( ts || '' ). substring ( 0 , 16 ). replace ( 'T' , ' ' );
},
eventStatusClass ( status ) {
const s = ( status || '' ). toLowerCase ();
if ( s === 'pending' || s === 'needs_approval' ) return 'badge-warn' ;
if ( s === 'error' || s === 'rejected' ) return 'badge-negative' ;
if ( s === 'approved' || s === 'completed' ) return 'badge-positive' ;
return '' ;
},
async openEvents ( key , name , focusId ) {
if ( ! key ) return ;
this . eventsAgentKey = key ;
this . eventsAgentName = name || key ;
this . eventsOpen = true ;
this . eventsLoading = true ;
this . selectedEvent = null ;
try {
const r = await fetch ( '/api/agents/souls/' + encodeURIComponent ( key ) + '/events?limit=50' ). then ( x => x . json ());
this . eventsList = r . items || [];
if ( focusId ) {
const hit = this . eventsList . find ( e => e . id === focusId );
if ( hit ) this . selectedEvent = hit ;
}
} catch ( e ) { this . eventsList = []; }
this . eventsLoading = false ;
},
closeEvents () {
this . eventsOpen = false ;
this . selectedEvent = null ;
},
toggleEventDetail ( ev ) {
this . selectedEvent = this . selectedEvent ? . id === ev . id ? null : ev ;
},
statusLabel ( s ) {
const st = ( s . current_status || '' ). toLowerCase ();
if ( st === 'needs_approval' ) return 'Wacht op goedkeuring' ;
if ( st === 'pending' ) return 'In wachtrij' ;
if ( st === 'error' ) return 'Fout' ;
2026-08-01 17:59:32 +00:00
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' ;
2026-06-09 10:41:13 +00:00
},
2026-06-09 00:41:27 +00:00
async init () {
2026-06-23 10:04:23 +00:00
this . syncMeshMode ();
2026-06-09 00:41:27 +00:00
await this . load ();
2026-06-09 10:41:13 +00:00
await this . loadPendingCount ();
2026-08-16 11:01:58 +00:00
this . _poll = setInterval (() => {
if ( this . tab === 'warroom' ) {
this . loadPendingCount ();
return ;
}
this . load ( true );
this . loadPendingCount ();
}, 20000 );
2026-06-09 10:41:13 +00:00
if ( window . PageViz ) {
window . PageViz . init ( 'agents' , ( mode ) => this . renderViz ( mode ));
}
2026-06-09 00:41:27 +00:00
this . $nextTick (() => {
const activeBtn = this . $refs . vtabsNav ? . querySelector ( '.vtab-btn.active' );
if ( activeBtn ) this . updateTabIndicator ( activeBtn );
2026-06-23 10:04:23 +00:00
const self = this ;
if ( window . AgentsMesh ) {
AgentsMesh . bindModePicker ({
onNodeClick : function ( node ) {
self . focusTerminal ( node . agent_key , node . display_name );
},
});
}
2026-08-16 11:01:58 +00:00
if ( window . AgentsTerminals && this . tab !== 'warroom' ) AgentsTerminals . startLiveStream ();
2026-06-09 00:41:27 +00:00
if ( this . tab === 'mesh' ) this . mountMesh ();
2026-06-23 10:04:23 +00:00
if ( this . tab === 'terminals' ) this . startTerminals ();
2026-06-09 00:41:27 +00:00
});
},
updateTabIndicator ( el ) {
if ( ! el || ! this . $refs . vtabsNav ) return ;
const navRect = this . $refs . vtabsNav . getBoundingClientRect ();
const btnRect = el . getBoundingClientRect ();
this . tabIndicatorTop = Math . max ( 0 , btnRect . top - navRect . top );
this . tabIndicatorH = btnRect . height || 40 ;
},
setTab ( next , el ) {
2026-08-16 11:01:58 +00:00
const prev = this . tab ;
2026-06-09 00:41:27 +00:00
this . tab = next ;
this . updateTabIndicator ( el );
const url = new URL ( window . location . href );
url . searchParams . set ( 'tab' , next );
window . history . replaceState ({}, '' , url . toString ());
if ( next === 'mesh' ) this . mountMesh ();
2026-06-23 10:04:23 +00:00
if ( next === 'terminals' ) this . startTerminals ();
2026-08-16 11:01:58 +00:00
if ( next === 'warroom' ) {
if ( window . AgentsTerminals && AgentsTerminals . stopLiveStream ) AgentsTerminals . stopLiveStream ();
} else if ( prev === 'warroom' ) {
if ( window . AgentsTerminals && AgentsTerminals . startLiveStream ) AgentsTerminals . startLiveStream ();
this . $nextTick (() => {
this . renderViz ( window . __vizMode || 'neo-bars' );
window . AgentAvatars && AgentAvatars . paintAll ( this . $root );
});
}
2026-06-09 10:41:13 +00:00
if ( next === 'approvals' ) this . loadPendingCount ();
},
async loadPendingCount () {
try {
const r = await fetch ( '/api/agents/approvals?status=pending' ). then ( x => x . json ());
this . pendingCount = ( r . items || []). length ;
} catch ( e ) { this . pendingCount = 0 ; }
2026-06-09 00:41:27 +00:00
},
2026-06-23 10:04:23 +00:00
syncMeshMode () {
if ( ! window . AgentsMesh ) return ;
const self = this ;
this . meshCommMode = AgentsMesh . getSavedMode ();
this . updateMeshLegend ();
AgentsMesh . setModeChangeCallback ( function ( id ) {
self . meshCommMode = id ;
self . updateMeshLegend ();
});
},
updateMeshLegend () {
const m = ( window . AgentsMesh && AgentsMesh . COMM_MODES ) ? AgentsMesh . COMM_MODES [ this . meshCommMode ] : null ;
this . meshLegendPeer = m ? ( m . showLivePeers || m . showStaticPeers ) : true ;
this . meshLegendExec = m ? m . showExecutive !== false : true ;
},
setMeshMode ( id ) {
if ( window . AgentsMesh ) AgentsMesh . pickMode ( id );
this . meshCommMode = id ;
this . updateMeshLegend ();
},
remountMesh () {
if ( window . AgentsMesh && AgentsMesh . refresh ) {
const self = this ;
AgentsMesh . refresh ( 'agents-mesh-canvas' , {
commMode : this . meshCommMode ,
onNodeClick : function ( node ) {
self . focusTerminal ( node . agent_key , node . display_name );
},
});
} else {
this . mountMesh ();
}
},
2026-06-09 00:41:27 +00:00
mountMesh () {
2026-06-23 10:04:23 +00:00
if ( ! window . AgentsMesh ) return ;
const self = this ;
const opts = {
commMode : this . meshCommMode ,
onNodeClick : function ( node ) {
self . focusTerminal ( node . agent_key , node . display_name );
},
};
window . AgentsMesh . mount ( 'agents-mesh-canvas' , opts ). catch ( function ( e ) {
if ( window . Cockpit ) Cockpit . toast ( 'Mesh: ' + ( e . message || 'laden mislukt' ), 'error' );
});
if ( this . focusTerminalKey && window . AgentsTerminals ) {
this . $nextTick ( function () { AgentsTerminals . mountAll (); });
}
},
focusTerminal ( key , name ) {
this . focusTerminalKey = key ;
this . focusTerminalName = name || key ;
this . expandedTerminal = key ;
this . $nextTick ( function () {
if ( window . AgentsTerminals ) AgentsTerminals . mountAll ();
});
},
clearFocusTerminal () {
this . focusTerminalKey = null ;
this . focusTerminalName = '' ;
},
goTerminal ( key ) {
const soul = this . souls . find ( function ( s ) { return s . agent_key === key ; });
const btn = this . $refs . vtabsNav ? . querySelector ( '[data-tab=terminals]' );
this . setTab ( 'terminals' , btn );
this . expandedTerminal = key ;
this . $nextTick ( function () {
const el = document . querySelector ( '[data-term-key="' + key + '"]' );
if ( el ) el . closest ( '.agent-term-window' ) ? . scrollIntoView ({ behavior : 'smooth' , block : 'center' });
});
},
expandTerminal ( key ) {
this . expandedTerminal = this . expandedTerminal === key ? null : key ;
},
startTerminals () {
this . $nextTick ( function () {
if ( window . AgentsTerminals ) {
AgentsTerminals . mountAll ();
AgentsTerminals . bindInputs ();
}
});
2026-06-09 00:41:27 +00:00
},
2026-08-01 17:59:32 +00:00
async load ( quiet ) {
2026-06-09 00:41:27 +00:00
const r = await fetch ( '/api/agents/souls' ). then ( x => x . json ());
2026-08-16 11:01:58 +00:00
const items = r . items || [];
// Avoid Alpine thrash: only replace souls when something meaningful changed
const sig = items . map ( s => [ s . agent_key , s . event_count , s . events_6h , s . current_status , s . activity , s . current_task ]. join ( ':' )). join ( '|' );
if ( sig !== this . _soulsSig ) {
this . _soulsSig = sig ;
this . souls = items ;
}
2026-06-09 00:41:27 +00:00
if ( this . souls . length && ! this . selected ) this . select ( this . souls [ 0 ]. agent_key );
2026-08-16 11:01:58 +00:00
if ( this . tab === 'warroom' ) return ; // no chart / global avatar repaint on War Room
2026-08-01 17:59:32 +00:00
this . $nextTick (() => {
( this . souls || []). forEach (( s ) => {
2026-08-16 11:01:58 +00:00
const el = document . querySelector ( '.agent-grid [data-agent-avatar="' + s . agent_key + '"], .hm-neo [data-agent-avatar="' + s . agent_key + '"]' );
2026-08-01 17:59:32 +00:00
if ( el && window . AgentAvatars ) AgentAvatars . paint ( el , s . agent_key , s . avatar_color );
});
});
2026-06-09 10:41:13 +00:00
this . renderViz ( window . __vizMode || 'neo-bars' );
2026-08-16 11:01:58 +00:00
if ( ! quiet ) this . $nextTick (() => window . AgentAvatars && AgentAvatars . paintAll ( this . $root ));
2026-06-09 10:41:13 +00:00
},
renderViz ( mode ) {
if ( ! window . VizEngine ) return ;
const rows = ( this . souls || []). map ( s => ({
label : s . agent_key ,
value : s . event_count || 0 ,
raw : s . event_count || 0 ,
})). sort (( a , b ) => b . raw - a . raw ). slice ( 0 , 12 );
if ( ! rows . length ) rows . push ({ label : 'geen' , value : 0 , raw : 0 });
VizEngine . renderFromRows ( mode ,
document . getElementById ( 'chart-agents-viz' ),
document . getElementById ( 'viz-agents-alt' ),
'agents-page' , 'Events per agent' , rows , 'hm-eq-lime'
);
2026-06-09 00:41:27 +00:00
},
async select ( key ) {
const r = await fetch ( '/api/agents/souls/' + key ). then ( x => x . json ());
this . selected = r . soul ;
this . form = { role_title : r . soul . role_title , responsibilities : r . soul . responsibilities , soul_md : r . soul . soul_md };
},
async save () {
if ( ! this . selected ) return ;
this . saving = true ;
try {
await Cockpit . api ( '/api/agents/souls/' + this . selected . agent_key , { method : 'PUT' , body : JSON . stringify ( this . form ) });
Cockpit . toast ( 'Soul opgeslagen' , 'success' );
await this . load ();
await this . select ( this . selected . agent_key );
} catch ( e ) { Cockpit . toast ( e . message , 'error' ); }
this . saving = false ;
}
};
}
2026-06-09 10:41:13 +00:00
function approvalsPanel () {
return {
items : [],
async load () {
try {
const r = await fetch ( '/api/agents/approvals?limit=50' ). then ( x => x . json ());
this . items = r . items || [];
} catch ( e ) { this . items = []; }
},
async approve ( id ) {
await Cockpit . api ( '/api/agents/approvals/' + id + '/approve' , { method : 'POST' , body : '{}' });
Cockpit . toast ( 'Goedgekeurd — agent mag query uitvoeren' , 'success' );
await this . load ();
},
async reject ( id ) {
const reason = prompt ( 'Reden afwijzing (optioneel):' ) || '' ;
await Cockpit . api ( '/api/agents/approvals/' + id + '/reject' , { method : 'POST' , body : JSON . stringify ({ reason }) });
Cockpit . toast ( 'Afgewezen' , 'info' );
await this . load ();
},
};
}
2026-06-09 00:41:27 +00:00
</ script >
{% endblock %}