Files
Aissa f925042a93 Stop War Room A2A loops and unlock full chat history with dates.
Detect repeating topics and pause A2A, load older messages beyond the old ~120 cap, and show clear date/time stamps on every bubble.
2026-08-16 23:23:48 +00:00

932 lines
54 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<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=8" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=42" />
<script src="/static/js/agent-avatars.js?v=5"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" :class="{ 'tab-warroom-active': tab==='warroom' }" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header" x-show="tab!=='warroom'" x-cloak>
<h1>Agents · Soul Registry</h1>
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<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>
<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>
<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>
<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'">
<div class="vtabs-nav-header"><strong>Agents</strong></div>
<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>
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
<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>
</button>
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<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>
</button>
<button class="vtab-btn" :class="{active: tab==='terminals'}" @click="setTab('terminals', $el)" data-tab="terminals">
<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>
</button>
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
<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>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
</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>
<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, '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>
<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>
<span x-text="statusLabel(s)"></span>
</span>
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
<p class="agent-role-desc" x-text="s.responsibilities || '—'"></p>
<div class="agent-meta-row">
<button type="button" class="agent-events-link"
@click.stop="goTerminal(s.agent_key)"
title="Open live terminal">Terminal →</button>
<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>
</div>
</article>
</template>
</div>
<div class="agent-soul-editor" x-show="selected" x-cloak>
<div class="agent-editor-header">
<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>
<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>
<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>
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
<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>
</template>
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
</div>
</section>
<section x-show="tab==='mesh'" x-cloak
x-effect="if (tab === 'mesh') { $nextTick(() => mountMesh()) }">
<div class="panel">
<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>
<div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1200 860"></svg>
</div>
<div class="mesh-legend">
<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>
</div>
</div>
</section>
<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>
<section x-show="tab==='warroom'" x-cloak>
<div x-data="warRoomHub()" x-init="init()" class="wr-hub-root">
<div class="panel" style="padding:0.75rem">
<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>
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<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>
</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}"
@click="selectAgent(s.agent_key)"
@dblclick.prevent="openDm(s.agent_key)"
title="Klik = @tag in War Room · dubbelklik = DM">
<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">
<div class="wr-head-title">
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
<small class="wr-msg-count muted" x-show="messages.length"
x-text="(screenCleared ? '0' : visibleMessages().length) + ' geladen' + (historyTotal ? (' · ' + historyTotal + ' totaal') : '')"></small>
</div>
<div class="wr-head-actions">
<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>
</div>
</div>
<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()">
<div class="wr-history-bar" x-show="!screenCleared && (hasMoreHistory || loadingHistory)" x-cloak>
<button type="button" class="btn btn-sm" @click="loadOlderMessages()" :disabled="loadingHistory">
<span x-show="!loadingHistory">↑ Oudere berichten laden</span>
<span x-show="loadingHistory">Laden…</span>
</button>
<small class="muted" x-show="historyTotal" x-text="'Totaal in archief: ' + historyTotal"></small>
</div>
<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>
<div class="wr-bubble"
: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}">
<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">
<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="formatMsgStamp(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>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<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>
</div>
</div>
</div>
</template>
<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>
</div>
<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()">
<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>
<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>
<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"
@input="onDraftInput()" @keydown="onKey($event)" @keydown.enter.prevent="onEnter($event)"
placeholder="Typ hier… bv. @herman of @retail"
></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend() || _sendLock">Stuur</button>
</div>
<!-- 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()">
<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>
</template>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="agent-events-backdrop" :class="{ 'is-open': eventsOpen }" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<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>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=42"></script>
<script>
function agentsSoulHub() {
return {
tab: '{{ active_tab }}',
tabIndicatorTop: 0,
tabIndicatorH: 40,
meshLoaded: false,
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false,
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
terminalOpen: false, terminalAgentKey: null, terminalAgentName: '', terminalLines: [], _terminalEs: null,
meshCommMode: 'pyramid',
meshLegendPeer: true,
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.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' || this.isBusy(s)) return 'busy';
if (st === 'error' || st === 'rejected') return 'error';
if ((s.events_6h||0) > 0 || s.activity === 'active') return 'active';
if ((s.event_count||0) === 0) return 'offline';
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';
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(() => {
if (this.tab === 'warroom') {
this.loadPendingCount();
return;
}
this.load(true);
this.loadPendingCount();
}, 20000);
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn);
const self = this;
if (window.AgentsMesh) {
AgentsMesh.bindModePicker({
onNodeClick: function (node) {
self.focusTerminal(node.agent_key, node.display_name);
},
});
}
if (window.AgentsTerminals && this.tab !== 'warroom') AgentsTerminals.startLiveStream();
if (this.tab === 'mesh') this.mountMesh();
if (this.tab === 'terminals') this.startTerminals();
});
},
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) {
const prev = this.tab;
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();
if (next === 'terminals') this.startTerminals();
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);
});
}
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; }
},
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();
}
},
mountMesh() {
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();
}
});
},
async load(quiet) {
const r = await fetch('/api/agents/souls').then(x => x.json());
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;
}
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
if (this.tab === 'warroom') return; // no chart / global avatar repaint on War Room
this.$nextTick(() => {
(this.souls || []).forEach((s) => {
const el = document.querySelector('.agent-grid [data-agent-avatar="' + s.agent_key + '"], .hm-neo [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');
if (!quiet) this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
},
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'
);
},
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;
}
};
}
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();
},
};
}
</script>
{% endblock %}