Ship Herman chat, Hermes KPI explorer, and intelligence analytics.

Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
Aissa
2026-08-01 17:50:45 +00:00
parent 62239f0a4e
commit 5e43b6b246
28 changed files with 2686 additions and 1149 deletions
+183 -296
View File
@@ -1,346 +1,233 @@
/**
* Hermes Neo Command Center
* Hermes technical KPI dashboard + bucket explorer popup
*/
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' },
];
function hermesKpiDashboard() {
const SOURCE_LABELS = {
telegram: 'Telegram',
api_server: 'Cockpit API',
tui: 'TUI',
cron: 'Cron',
subagent: 'Subagent',
};
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,
}));
loading: false,
kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] },
chartTools: null,
chartSources: null,
_timer: null,
modal: {
open: false,
loading: false,
previewLoading: false,
key: null,
title: '',
tab: 'activity',
toolFilter: null,
detail: { events: [], files: [], parts: {}, count: 0 },
explorer: { path: '', parent: null, entries: [] },
selectedEvent: null,
preview: null,
},
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 ov() { return this.kpis.overview || {}; },
fmt(n) {
const v = Number(n || 0);
return v.toLocaleString('nl-NL');
},
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(); }
fmtBytes(n) {
if (n == null || n === '') return '';
const v = Number(n);
if (v < 1024) return v + ' B';
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
return (v / (1024 * 1024)).toFixed(1) + ' MB';
},
formatTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch { return iso; }
} catch (e) { return String(iso).slice(0, 19); }
},
paStatusLabel() {
const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' };
return m[this.paLive.status] || this.paLive.status || 'Idle';
partsLine(parts) {
if (!parts) return '';
return Object.keys(parts)
.filter((k) => parts[k] > 0)
.map((k) => parts[k] + ' ' + k.replace(/_/g, ' '))
.join(' · ');
},
slotStatusLabel(s) {
const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' };
return m[s] || s || 'Idle';
sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; },
async init() {
await this.refresh();
this._timer = setInterval(() => this.refresh(true), 15000);
},
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() {
async refresh(quiet) {
if (!quiet) this.loading = true;
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); }
const data = await Cockpit.api('/hermes/kpis');
this.kpis = data || this.kpis;
this.$nextTick(() => this.renderCharts());
} catch (e) {
if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error');
this.kpis.online = false;
}
this.loading = false;
},
async refreshUsers() {
try {
const data = await Cockpit.api('/hermes/users');
this.users = data.users || [];
} catch (e) { console.warn('users', e); }
async openBucket(b) {
this.modal.open = true;
this.modal.key = b.key;
this.modal.title = b.label;
this.modal.tab = b.key === 'files' ? 'touched' : 'activity';
this.modal.toolFilter = null;
this.modal.selectedEvent = null;
this.modal.preview = null;
this.modal.detail = { events: [], files: [], parts: b.parts || {}, count: b.count || 0 };
await this.loadBucketDetail();
if (b.key === 'files') {
// preload roots quietly
this.loadExplorer('').catch(() => {});
}
},
async refreshConversations() {
try {
const data = await Cockpit.api('/hermes/conversations');
this.conversations = data.items || [];
} catch (e) { console.warn('conversations', e); }
closeModal() {
this.modal.open = false;
this.modal.preview = null;
this.modal.selectedEvent = null;
},
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 setToolFilter(tool) {
this.modal.toolFilter = tool;
await this.loadBucketDetail();
},
async refreshPaLive(quiet) {
async loadBucketDetail() {
if (!this.modal.key) return;
this.modal.loading = true;
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;
const q = new URLSearchParams({ limit: '80' });
if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter);
const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString());
this.modal.detail = data || this.modal.detail;
if (!(this.modal.detail.events || []).length) {
this.modal.selectedEvent = null;
}
} catch (e) {
if (!quiet) console.warn('pa live', e);
if (window.Cockpit) Cockpit.toast(e.message || 'Detail laden mislukt', 'error');
}
this.modal.loading = false;
},
selectEvent(ev) {
this.modal.selectedEvent = ev;
this.modal.preview = null;
if (ev.paths && ev.paths.length === 1 && this.modal.key === 'files') {
this.openFile(ev.paths[0]);
}
},
async loadGraph() {
openExplorerTab() {
this.modal.tab = 'explorer';
if (!(this.modal.explorer.entries || []).length) {
this.loadExplorer(this.modal.explorer.path || '');
}
},
async loadExplorer(path) {
this.modal.loading = true;
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);
const q = path ? ('?path=' + encodeURIComponent(path)) : '';
const data = await Cockpit.api('/hermes/explorer' + q);
this.modal.explorer = data || { path: '', parent: null, entries: [] };
} catch (e) {
Cockpit.toast('Graph laden mislukt: ' + e.message, 'error');
if (window.Cockpit) Cockpit.toast(e.message || 'Verkenner laden mislukt', 'error');
}
this.modal.loading = false;
},
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 },
});
explorerUp() {
if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent);
else this.loadExplorer('');
},
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;
async openFile(path) {
if (!path) return;
this.modal.previewLoading = true;
this.modal.selectedEvent = null;
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 }),
const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path));
this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() };
if (this.modal.key === 'files' && this.modal.tab === 'activity') {
// keep tab; preview still shows
}
} catch (e) {
this.modal.preview = { path, name: path.split('/').pop(), error: e.message || 'Laden mislukt', content: null };
}
this.modal.previewLoading = false;
},
renderCharts() {
if (typeof Chart === 'undefined') return;
const tools = (this.kpis.tools_top || []).slice(0, 8);
const sources = this.kpis.sources || [];
const toolsCtx = document.getElementById('hm-chart-tools');
if (toolsCtx) {
if (this.chartTools) this.chartTools.destroy();
this.chartTools = new Chart(toolsCtx, {
type: 'bar',
data: {
labels: tools.map((t) => t.name),
datasets: [{
label: 'Calls',
data: tools.map((t) => t.count),
backgroundColor: 'rgba(14, 165, 233, 0.55)',
borderColor: 'rgba(56, 189, 248, 0.9)',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
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;
const srcCtx = document.getElementById('hm-chart-sources');
if (srcCtx) {
if (this.chartSources) this.chartSources.destroy();
this.chartSources = new Chart(srcCtx, {
type: 'doughnut',
data: {
labels: sources.map((s) => this.sourceLabel(s.source)),
datasets: [{
data: sources.map((s) => s.tools || 0),
backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } },
},
},
});
}
},
};
}
function hermesControl() { return hermesKpiDashboard(); }