5d60d33db1
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
347 lines
11 KiB
JavaScript
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;
|
|
}
|
|
},
|
|
};
|
|
}
|