Files
foodlinkk-command-center/cockpit/static/js/hermes-ui.js
T
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

347 lines
11 KiB
JavaScript

/**
* 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;
}
},
};
}