Files
foodlinkk-command-center/cockpit/templates/agents.html
T

925 lines
54 KiB
HTML
Raw Normal View History

{% 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>
<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" />
<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=40" />
<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>
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>
<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>
2026-06-09 10:41:13 +00:00
<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>
2026-06-09 10:41:13 +00:00
<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>
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>
</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)">
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>
<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>
<div class="agent-meta-row">
<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>
</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>
<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>
<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>
</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>
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>
<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) + ' / ' + messages.length + ' berichten'"></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()">
<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="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>
</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>
2026-06-09 10:41:13 +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>
</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>
<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=40"></script>
<script>
function agentsSoulHub() {
return {
tab: '{{ active_tab }}',
tabIndicatorTop: 0,
tabIndicatorH: 40,
meshLoaded: false,
2026-06-09 10:41:13 +00:00
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false,
2026-06-09 10:41:13 +00:00
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; },
2026-06-09 10:41:13 +00:00
statusClass(s) {
const st = (s.current_status || '').toLowerCase();
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';
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';
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
},
async init() {
this.syncMeshMode();
await this.load();
2026-06-09 10:41:13 +00:00
await this.loadPendingCount();
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));
}
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);
});
}
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; }
},
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);
});
});
2026-06-09 10:41:13 +00:00
this.renderViz(window.__vizMode || 'neo-bars');
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'
);
},
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();
},
};
}
</script>
{% endblock %}