/** * Hermes Neo Command Center */ function hermesControl() { const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck']; /** Canonical team — never duplicate role as name */ const ROSTER = [ { chat_id: 8859782446, role: 'CEO', name: 'Aïssa' }, { chat_id: 789036463, role: 'CTO', name: 'Mo' }, ]; return { tab: 'feed', live: true, stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 }, conversations: [], messages: [], users: [], agentEvents: [], selectedChatId: null, graphChatFilter: '', searchQuery: '', searchResults: [], searchRan: false, graphNetwork: null, pollTimer: null, paPollTimer: null, paPolling: false, paLive: { status: 'idle', query: '', slots: [] }, paScreenshotTs: Date.now(), hermesOnline: false, hermesStatus: {}, actionBusy: false, actionMsg: '', chartDirection: null, chartAgents: null, get teamRoster() { const byId = {}; (this.users || []).forEach(u => { byId[u.chat_id] = u; }); (this.conversations || []).forEach(c => { if (!byId[c.chat_id]) byId[c.chat_id] = c; else { byId[c.chat_id].message_count = c.message_count || byId[c.chat_id].message_count; } }); return ROSTER.map(r => ({ ...r, online: !!(byId[r.chat_id]?.online), pa_mode: !!(byId[r.chat_id]?.pa_mode), message_count: byId[r.chat_id]?.message_count || 0, })); }, get paLiveUser() { const cid = this.paLive.chat_id; if (!cid) return ''; const m = ROSTER.find(r => r.chat_id === cid); return m ? `${m.role} — ${m.name}` : this.paLive.user_name || ''; }, get paSlots() { const slots = this.paLive.slots || []; if (slots.length >= 4) return slots; const byLabel = {}; slots.forEach(s => { byLabel[s.label] = s; }); return PA_SITES.map(label => byLabel[label] || { label, status: 'idle', has_screenshot: false, }); }, rosterPerson(chatId) { return ROSTER.find(r => r.chat_id === Number(chatId)) || null; }, msgLabel(msg) { const p = this.rosterPerson(msg.chat_id); if (p) return `${p.role} (${p.name})`; if (msg.user_role && msg.user_name) return `${msg.user_role} (${msg.user_name})`; return msg.user_role || msg.chat_id || '?'; }, async init() { const hash = (window.location.hash || '').replace('#', ''); if (['feed', 'pa', 'graph', 'search', 'control'].includes(hash)) this.tab = hash; await this.refreshStats(); await this.refreshConversations(); await this.refreshUsers(); await this.refreshFeed(); await this.refreshPaLive(); setTimeout(() => this.initCharts(), 200); this.pollTimer = setInterval(() => { if (this.tab === 'feed' && this.live) this.refreshFeed(true); }, 5000); this.paPollTimer = setInterval(() => { if (this.tab === 'pa' || this.paLive.status === 'running') this.refreshPaLive(true); }, 2000); window.addEventListener('hashchange', () => { const h = (window.location.hash || '').replace('#', ''); if (['feed', 'pa', 'graph', 'search', 'control'].includes(h)) this.tab = h; }); }, initCharts() { if (typeof Chart === 'undefined') return; const neoColors = ['#00e5ff', '#ff2d95', '#b8ff3c', '#ff9f43', '#a855f7', '#ffd700']; Chart.defaults.color = '#64748b'; Chart.defaults.borderColor = 'rgba(0,229,255,0.08)'; const dirEl = document.getElementById('hm-chart-direction'); if (dirEl && !this.chartDirection) { this.chartDirection = new Chart(dirEl, { type: 'doughnut', data: { labels: ['Inbound', 'Outbound', 'Vectors'], datasets: [{ data: [1, 1, 1], backgroundColor: ['#00e5ff', '#a855f7', '#b8ff3c'], borderWidth: 0, hoverOffset: 8, }], }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'right', labels: { boxWidth: 12, padding: 10, color: '#94a3b8' } }, }, }, }); } const agEl = document.getElementById('hm-chart-agents'); if (agEl && !this.chartAgents) { this.chartAgents = new Chart(agEl, { type: 'bar', data: { labels: ['herman', 'browser', 'marketing'], datasets: [{ label: 'events', data: [0, 0, 0], backgroundColor: 'rgba(0, 229, 255, 0.55)', borderColor: '#00e5ff', borderWidth: 1, borderRadius: 4, }], }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { stepSize: 1 } }, x: { grid: { display: false } }, }, plugins: { legend: { display: false } }, }, }); } this.updateCharts(); }, updateCharts() { const s = this.stats; if (this.chartDirection) { this.chartDirection.data.datasets[0].data = [ Math.max(s.inbound || 0, 0), Math.max(s.outbound || 0, 0), Math.max(s.embeddings || 0, 0), ]; this.chartDirection.update('none'); } if (this.chartAgents) { const counts = {}; (this.agentEvents || []).forEach(ev => { const n = (ev.agent_name || 'other').toLowerCase(); counts[n] = (counts[n] || 0) + 1; }); const labels = Object.keys(counts).slice(0, 8); if (!labels.length) labels.push('herman'); this.chartAgents.data.labels = labels; this.chartAgents.data.datasets[0].data = labels.map(l => counts[l] || 0); this.chartAgents.update('none'); } }, setTab(t) { this.tab = t; history.replaceState(null, '', t === 'feed' ? '/hermes' : '/hermes#' + t); if (t === 'graph') setTimeout(() => this.loadGraph(), 100); if (t === 'control') { this.refreshStats(); this.refreshUsers(); } if (t === 'pa') { this.paPolling = true; this.refreshPaLive(); } }, formatTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); } catch { return iso; } }, paStatusLabel() { const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' }; return m[this.paLive.status] || this.paLive.status || 'Idle'; }, slotStatusLabel(s) { const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' }; return m[s] || s || 'Idle'; }, slotScreenshotUrl(label) { if (!label) return ''; return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`; }, async selectChat(chatId) { this.selectedChatId = chatId; await this.refreshFeed(); }, async refreshStats() { try { const data = await Cockpit.api('/hermes/stats'); this.stats = data.stats || this.stats; this.agentEvents = data.agent_events || []; this.updateCharts(); } catch (e) { console.warn('stats', e); } }, async refreshUsers() { try { const data = await Cockpit.api('/hermes/users'); this.users = data.users || []; } catch (e) { console.warn('users', e); } }, async refreshConversations() { try { const data = await Cockpit.api('/hermes/conversations'); this.conversations = data.items || []; } catch (e) { console.warn('conversations', e); } }, async refreshFeed(quiet) { try { let path = '/hermes/feed?limit=80'; if (this.selectedChatId) path += '&chat_id=' + this.selectedChatId; const data = await Cockpit.api(path); this.messages = data.items || []; if (!quiet) await this.refreshStats(); } catch (e) { console.warn('feed', e); } }, async refreshPaLive(quiet) { try { const data = await Cockpit.api('/hermes/pa/live'); this.paLive = data; this.paScreenshotTs = Date.now(); if (data.hermes) { this.hermesOnline = data.hermes.online !== false; this.hermesStatus = data.hermes; } } catch (e) { if (!quiet) console.warn('pa live', e); } }, async loadGraph() { try { let data; if (this.graphChatFilter) { data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80'); } else { data = await Cockpit.api('/hermes/graph?limit=100'); } this.renderGraph(data); } catch (e) { Cockpit.toast('Graph laden mislukt: ' + e.message, 'error'); } }, renderGraph(data) { const el = document.getElementById('hermes-graph'); if (!el || typeof vis === 'undefined') return; const nodes = new vis.DataSet((data.nodes || []).map(n => ({ id: n.id, label: this.truncate(n.label || n.id, 40), color: n.direction === 'in' ? { background: '#164e63', border: '#00e5ff' } : { background: '#4c1d95', border: '#a855f7' }, font: { color: '#e8edf5', size: 11 }, }))); const edges = new vis.DataSet((data.edges || []).map(e => ({ id: 'e' + e.id, from: e.from, to: e.to, label: e.type || '', arrows: 'to', color: { color: '#ff9f43' }, })).filter(e => e.to)); if (this.graphNetwork) this.graphNetwork.destroy(); this.graphNetwork = new vis.Network(el, { nodes, edges }, { physics: { stabilization: true }, interaction: { hover: true }, }); }, truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n) + '…' : s; }, async runSearch() { if (!this.searchQuery || this.searchQuery.length < 2) { Cockpit.toast('Minimaal 2 tekens', 'warn'); return; } this.searchRan = true; try { const data = await Cockpit.api('/hermes/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }), }); this.searchResults = data.results || []; } catch (e) { Cockpit.toast('Zoeken mislukt: ' + e.message, 'error'); } }, async runAction(action) { this.actionBusy = true; this.actionMsg = ''; try { const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' }); this.actionMsg = data.output || '✅ Actie uitgevoerd'; Cockpit.toast(this.actionMsg.slice(0, 80), 'success'); await this.refreshStats(); } catch (e) { this.actionMsg = '❌ ' + e.message; Cockpit.toast(this.actionMsg, 'error'); } finally { this.actionBusy = false; } }, }; }