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:
+202
-172
@@ -1,199 +1,229 @@
|
||||
window.AnalyticsCharts = (function () {
|
||||
var charts = {};
|
||||
var colors = {
|
||||
gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)',
|
||||
green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)',
|
||||
purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)',
|
||||
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0',
|
||||
};
|
||||
var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray];
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(charts).forEach(function (k) {
|
||||
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function barChart(id, labels, values, label, horizontal) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }],
|
||||
},
|
||||
options: {
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } },
|
||||
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(id, labels, values) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] },
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } },
|
||||
});
|
||||
}
|
||||
|
||||
function lineChart(id, labels, values) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderKpis(container, kpis) {
|
||||
if (!container || !kpis) return;
|
||||
var items = [
|
||||
['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active],
|
||||
['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')],
|
||||
['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers],
|
||||
['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items],
|
||||
['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns],
|
||||
['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals],
|
||||
['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs],
|
||||
];
|
||||
container.innerHTML = items.map(function (it) {
|
||||
return '<div class="kpi-card"><span>' + it[0] + '</span><strong>' + it[1] + '</strong></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderTables(data) {
|
||||
var dealsEl = document.getElementById('analytics-deals-table');
|
||||
var eventsEl = document.getElementById('analytics-events-table');
|
||||
if (dealsEl) {
|
||||
dealsEl.innerHTML = (data.recent_deals || []).map(function (d) {
|
||||
return '<tr><td>' + (d.title || '—') + '</td><td>' + (d.stage || '') + '</td><td>€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="3">Geen deals</td></tr>';
|
||||
}
|
||||
if (eventsEl) {
|
||||
eventsEl.innerHTML = (data.recent_events || []).map(function (e) {
|
||||
var t = (e.created_at || '').substring(0, 16).replace('T', ' ');
|
||||
return '<tr><td>' + (e.agent_name || '') + '</td><td>' + (e.title || e.event_type || '') + '</td><td>' + t + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="3">Geen events</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function render(data, mode) {
|
||||
if (!data) return;
|
||||
mode = mode || window.__vizMode || 'neo-bars';
|
||||
window.__analyticsLastData = data;
|
||||
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
|
||||
var useRing = mode === 'neo-rings';
|
||||
var bar = function (id, labels, values, label, horizontal) {
|
||||
if (useRing) doughnut(id, labels, values);
|
||||
else barChart(id, labels, values, label, horizontal);
|
||||
};
|
||||
bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
|
||||
(data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR');
|
||||
bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
|
||||
(data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten');
|
||||
bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
|
||||
(data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
|
||||
if (useRing) {
|
||||
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
} else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
|
||||
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
|
||||
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
|
||||
(data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true);
|
||||
if (mode === 'neo-table') {
|
||||
lineChart('chart-timeline', [], []);
|
||||
} else {
|
||||
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
|
||||
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
}
|
||||
doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }),
|
||||
(data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH');
|
||||
doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }),
|
||||
(data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
|
||||
(data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true);
|
||||
bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
|
||||
(data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true);
|
||||
bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
|
||||
(data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo');
|
||||
doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }),
|
||||
(data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
renderTables(data);
|
||||
}
|
||||
|
||||
document.addEventListener('foodlinkk:vizmode', function (ev) {
|
||||
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
|
||||
});
|
||||
|
||||
return { render: render, destroyAll: destroyAll };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Foodlinkk Intelligence Analytics hub
|
||||
*/
|
||||
function analyticsHub() {
|
||||
const charts = {};
|
||||
|
||||
function destroyCharts() {
|
||||
Object.keys(charts).forEach((k) => {
|
||||
try { charts[k].destroy(); } catch (e) {}
|
||||
delete charts[k];
|
||||
});
|
||||
}
|
||||
|
||||
function euro(n) {
|
||||
const v = Math.round(Number(n || 0));
|
||||
return '€' + v.toLocaleString('nl-NL');
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
showFilters: false,
|
||||
updatedAt: '—',
|
||||
liveLabel: 'Live',
|
||||
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
|
||||
meta: {},
|
||||
data: {},
|
||||
kpis: {},
|
||||
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
|
||||
findings: [],
|
||||
goals: {},
|
||||
signals: [],
|
||||
_pollStop: null,
|
||||
init() {
|
||||
var initial = window.ANALYTICS_INITIAL || {};
|
||||
this.meta = initial.filter_meta || {};
|
||||
AnalyticsCharts.render(initial);
|
||||
this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' ');
|
||||
this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000);
|
||||
|
||||
fmt(n) {
|
||||
return Number(n || 0).toLocaleString('nl-NL');
|
||||
},
|
||||
|
||||
formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return String(iso).slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
},
|
||||
|
||||
shortGoal(t) {
|
||||
const s = String(t || '').replace(/\s+/g, ' ').trim();
|
||||
return s.length > 90 ? s.slice(0, 87) + '…' : s;
|
||||
},
|
||||
|
||||
ringDash(score) {
|
||||
const s = Math.max(0, Math.min(100, Number(score || 0)));
|
||||
const c = 2 * Math.PI * 52;
|
||||
return ((s / 100) * c).toFixed(1);
|
||||
},
|
||||
|
||||
applyData(payload) {
|
||||
this.data = payload || {};
|
||||
this.kpis = this.data.kpis || {};
|
||||
this.pulse = this.data.pulse || { wins: [], opportunities: [], attention: [], score: 0 };
|
||||
this.findings = this.data.hermes_findings || [];
|
||||
this.goals = this.data.goals || {};
|
||||
this.meta = this.data.filter_meta || this.meta;
|
||||
this.updatedAt = (this.data.generated_at || '').substring(0, 19).replace('T', ' ') || '—';
|
||||
this.signals = [
|
||||
{ key: 'margin', label: 'Marge / mnd', value: euro(this.kpis.margin_month), hint: (this.kpis.active_projects || 0) + ' deals', tone: 'good' },
|
||||
{ key: 'partners', label: 'CRM actief', value: this.fmt(this.kpis.crm_partnerships), hint: (this.pulse.cover_pct || 0) + '% dekking', tone: 'good' },
|
||||
{ key: 'gap', label: 'White space', value: this.fmt(this.pulse.white_space), hint: 'winkels zonder partnership', tone: 'warn' },
|
||||
{ key: 'stores', label: 'Supermarkten', value: this.fmt(this.kpis.supermarkets), hint: this.fmt(this.kpis.wholesalers) + ' groothandels' },
|
||||
{ key: 'rss', label: 'Markt signalen', value: this.fmt(this.kpis.rss_items), hint: this.fmt(this.kpis.promo_campaigns) + ' promo’s' },
|
||||
{ key: 'agents', label: 'Agent events', value: this.fmt(this.kpis.agent_events), hint: this.fmt(this.kpis.pending_approvals) + ' approvals', tone: this.kpis.pending_approvals ? 'warn' : '' },
|
||||
];
|
||||
this.$nextTick(() => this.renderCharts());
|
||||
},
|
||||
|
||||
init() {
|
||||
this.applyData(window.ANALYTICS_INITIAL || {});
|
||||
if (window.CockpitLive && CockpitLive.startPolling) {
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refresh(false), 45000);
|
||||
} else {
|
||||
this._pollStop = setInterval(() => this.refresh(false), 45000);
|
||||
}
|
||||
},
|
||||
|
||||
params() {
|
||||
var p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(function (e) {
|
||||
if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]);
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(([k, v]) => {
|
||||
if (v !== null && v !== '' && v !== undefined) p.set(k, v);
|
||||
});
|
||||
return p.toString();
|
||||
},
|
||||
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); });
|
||||
this.meta = data.filter_meta || this.meta;
|
||||
AnalyticsCharts.render(data);
|
||||
this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' ');
|
||||
if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success');
|
||||
const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json());
|
||||
this.applyData(data);
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (toast !== false) Cockpit.toast(e.message, 'error');
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
destroyCharts();
|
||||
const grid = 'rgba(148,163,184,0.12)';
|
||||
const tick = '#94a3b8';
|
||||
const d = this.data;
|
||||
|
||||
const mk = (id, cfg) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
charts[id] = new Chart(el, cfg);
|
||||
};
|
||||
|
||||
mk('ax-chart-timeline', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: (d.events_timeline || []).map((r) => String(r.day || '').substring(5)),
|
||||
datasets: [{
|
||||
data: (d.events_timeline || []).map((r) => Number(r.cnt || 0)),
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56,189,248,0.15)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick, maxTicksLimit: 10 }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-partners', {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: (d.partnership_breakdown || []).map((r) => r.status),
|
||||
datasets: [{
|
||||
data: (d.partnership_breakdown || []).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: ['#64748b', '#b8ff3c', '#fbbf24', '#38bdf8', '#fb7185'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { color: tick, boxWidth: 10, font: { size: 10 } } } },
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-opps', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.top_opportunities || []).slice(0, 8).map((r) => (r.chain || '') + ' ' + (r.city || '')),
|
||||
datasets: [{
|
||||
data: (d.top_opportunities || []).slice(0, 8).map((r) => Math.round(Number(r.halal_opportunity_score || 0))),
|
||||
backgroundColor: 'rgba(251,191,36,0.55)',
|
||||
borderColor: '#fbbf24',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-chains', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => r.chain),
|
||||
datasets: [{
|
||||
data: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: 'rgba(56,189,248,0.45)',
|
||||
borderColor: '#38bdf8',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-prov', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.supermarkets_by_province || []).slice(0, 8).map((r) => r.province),
|
||||
datasets: [{
|
||||
data: (d.supermarkets_by_province || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: 'rgba(184,255,60,0.4)',
|
||||
borderColor: '#b8ff3c',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick, maxRotation: 45 }, grid: { display: false } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,87 @@ window.HermanAssistant = (function () {
|
||||
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
var STORAGE_OPEN = 'herman_assistant_open';
|
||||
var STORAGE_CHAT = 'herman_assistant_chat_v1';
|
||||
var STORAGE_PREFS = 'herman_assistant_prefs_v1';
|
||||
var STORAGE_FAB = 'herman_assistant_fab_pos_v1';
|
||||
var MAX_STORED_TURNS = 40;
|
||||
var _cachedVoice = null;
|
||||
|
||||
function loadPrefs() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_PREFS);
|
||||
if (!raw) return {};
|
||||
var data = JSON.parse(raw);
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function pickMaartenVoice() {
|
||||
if (!window.speechSynthesis) return null;
|
||||
if (_cachedVoice) return _cachedVoice;
|
||||
var voices = window.speechSynthesis.getVoices() || [];
|
||||
if (!voices.length) return null;
|
||||
var prefer = [
|
||||
/maarten/i,
|
||||
/microsoft.*nederlands/i,
|
||||
/google.*nederlands/i,
|
||||
/nl[-_]NL/i,
|
||||
/dutch/i,
|
||||
];
|
||||
for (var p = 0; p < prefer.length; p++) {
|
||||
for (var i = 0; i < voices.length; i++) {
|
||||
var v = voices[i];
|
||||
var hay = ((v.name || '') + ' ' + (v.lang || '') + ' ' + (v.voiceURI || ''));
|
||||
if (prefer[p].test(hay)) {
|
||||
_cachedVoice = v;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var j = 0; j < voices.length; j++) {
|
||||
if (/^nl/i.test(voices[j].lang || '')) {
|
||||
_cachedVoice = voices[j];
|
||||
return voices[j];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
||||
window.speechSynthesis.addEventListener('voiceschanged', function () {
|
||||
_cachedVoice = null;
|
||||
pickMaartenVoice();
|
||||
});
|
||||
}
|
||||
|
||||
function loadStoredChat(sessionPrefix) {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_CHAT);
|
||||
if (!raw) return null;
|
||||
var data = JSON.parse(raw);
|
||||
if (!data || !Array.isArray(data.turns)) return null;
|
||||
if (sessionPrefix && data.sessionPrefix && data.sessionPrefix !== sessionPrefix) return null;
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredChat(state) {
|
||||
try {
|
||||
var turns = (state.turns || []).slice(-MAX_STORED_TURNS);
|
||||
localStorage.setItem(STORAGE_CHAT, JSON.stringify({
|
||||
sessionPrefix: state.sessionPrefix,
|
||||
sessionId: state.sessionId,
|
||||
turns: turns,
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (e) { /* empty */ }
|
||||
}
|
||||
|
||||
function baseState(opts) {
|
||||
opts = opts || {};
|
||||
return {
|
||||
@@ -30,12 +111,20 @@ window.HermanAssistant = (function () {
|
||||
hidden: false,
|
||||
status: 'idle',
|
||||
statusDetail: '',
|
||||
connOk: null,
|
||||
connLabel: 'Herman',
|
||||
livePreview: '',
|
||||
liveInterim: false,
|
||||
recording: false,
|
||||
busy: false,
|
||||
speakReply: true,
|
||||
speakReply: false,
|
||||
handsFreeMode: false,
|
||||
httpsAudioPrompt: false,
|
||||
fabPos: null,
|
||||
fabDragging: false,
|
||||
_fabMoved: false,
|
||||
_fabPointerId: null,
|
||||
_fabOrigin: null,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
turns: [],
|
||||
manualText: '',
|
||||
@@ -65,7 +154,209 @@ window.HermanAssistant = (function () {
|
||||
|
||||
toggleOpen: function () {
|
||||
this.open = !this.open;
|
||||
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
|
||||
try { localStorage.setItem(STORAGE_OPEN, this.open ? '1' : '0'); } catch (e) { /* empty */ }
|
||||
if (this.open) this.scrollLog();
|
||||
},
|
||||
|
||||
loadFabPos: function () {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_FAB);
|
||||
if (!raw) return;
|
||||
var p = JSON.parse(raw);
|
||||
if (p && typeof p.x === 'number' && typeof p.y === 'number') this.fabPos = p;
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
saveFabPos: function () {
|
||||
try {
|
||||
if (!this.fabPos) localStorage.removeItem(STORAGE_FAB);
|
||||
else localStorage.setItem(STORAGE_FAB, JSON.stringify(this.fabPos));
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
resetFabPos: function () {
|
||||
this.fabPos = null;
|
||||
this.saveFabPos();
|
||||
},
|
||||
|
||||
fabStyle: function () {
|
||||
if (!this.fabPos) return {};
|
||||
return {
|
||||
left: this.fabPos.x + 'px',
|
||||
top: this.fabPos.y + 'px',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
transform: this.fabDragging ? 'none' : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
panelStyle: function () {
|
||||
if (!this.fabPos) return {};
|
||||
var vw = window.innerWidth || 800;
|
||||
var vh = window.innerHeight || 600;
|
||||
var panelW = Math.min(420, vw - 24);
|
||||
var panelH = Math.min(620, vh - 96);
|
||||
var fabX = this.fabPos.x;
|
||||
var fabY = this.fabPos.y;
|
||||
var fabW = 130;
|
||||
var fabH = 52;
|
||||
var left = Math.min(Math.max(8, fabX + fabW / 2 - panelW / 2), vw - panelW - 8);
|
||||
var top = fabY - panelH - 12;
|
||||
if (top < 8) top = fabY + fabH + 12;
|
||||
if (top + panelH > vh - 8) top = Math.max(8, vh - panelH - 8);
|
||||
return {
|
||||
left: left + 'px',
|
||||
top: top + 'px',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
};
|
||||
},
|
||||
|
||||
_clampFab: function (x, y) {
|
||||
var vw = window.innerWidth || 800;
|
||||
var vh = window.innerHeight || 600;
|
||||
var w = 56;
|
||||
var h = 52;
|
||||
return {
|
||||
x: Math.min(Math.max(8, x), Math.max(8, vw - w - 8)),
|
||||
y: Math.min(Math.max(8, y), Math.max(8, vh - h - 8)),
|
||||
};
|
||||
},
|
||||
|
||||
onFabPointerDown: function (e) {
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
var point = e.touches && e.touches[0] ? e.touches[0] : e;
|
||||
var el = e.currentTarget;
|
||||
var rect = el.getBoundingClientRect();
|
||||
this._fabMoved = false;
|
||||
this.fabDragging = true;
|
||||
this._fabOrigin = {
|
||||
px: point.clientX,
|
||||
py: point.clientY,
|
||||
left: this.fabPos ? this.fabPos.x : rect.left,
|
||||
top: this.fabPos ? this.fabPos.y : rect.top,
|
||||
};
|
||||
if (el.setPointerCapture && e.pointerId != null) {
|
||||
try { el.setPointerCapture(e.pointerId); } catch (err) { /* empty */ }
|
||||
}
|
||||
},
|
||||
|
||||
onFabPointerMove: function (e) {
|
||||
if (!this.fabDragging || !this._fabOrigin) return;
|
||||
var point = e.touches && e.touches[0] ? e.touches[0] : e;
|
||||
var dx = point.clientX - this._fabOrigin.px;
|
||||
var dy = point.clientY - this._fabOrigin.py;
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) this._fabMoved = true;
|
||||
if (!this._fabMoved) return;
|
||||
e.preventDefault();
|
||||
this.fabPos = this._clampFab(this._fabOrigin.left + dx, this._fabOrigin.top + dy);
|
||||
},
|
||||
|
||||
onFabPointerUp: function (e) {
|
||||
if (!this.fabDragging) return;
|
||||
var moved = this._fabMoved;
|
||||
this.fabDragging = false;
|
||||
this._fabOrigin = null;
|
||||
if (moved) {
|
||||
this.saveFabPos();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.toggleOpen();
|
||||
},
|
||||
|
||||
onFabClick: function (e) {
|
||||
// click handled in pointerup; prevent double-toggle
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
||||
onFabDblClick: function (e) {
|
||||
e.preventDefault();
|
||||
this.resetFabPos();
|
||||
Cockpit.toast('Herman-knop teruggezet', 'info');
|
||||
},
|
||||
|
||||
|
||||
persistChat: function () {
|
||||
saveStoredChat(this);
|
||||
},
|
||||
|
||||
persistPrefs: function () {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFS, JSON.stringify({
|
||||
speakReply: !!this.speakReply,
|
||||
handsFreeMode: !!this.handsFreeMode,
|
||||
}));
|
||||
} catch (e) { /* empty */ }
|
||||
if (!this.speakReply && window.speechSynthesis) {
|
||||
try { window.speechSynthesis.cancel(); } catch (e2) { /* empty */ }
|
||||
}
|
||||
},
|
||||
|
||||
applyPrefs: function () {
|
||||
var prefs = loadPrefs();
|
||||
if (typeof prefs.speakReply === 'boolean') this.speakReply = prefs.speakReply;
|
||||
if (typeof prefs.handsFreeMode === 'boolean') this.handsFreeMode = prefs.handsFreeMode;
|
||||
},
|
||||
|
||||
onHandsFreeChange: function () {
|
||||
this.persistPrefs();
|
||||
if (this.handsFreeMode && !this.micSecureOk()) {
|
||||
this.offerHttpsAudio();
|
||||
return;
|
||||
}
|
||||
if (this.handsFreeMode && !this.recording && !this.busy) {
|
||||
this.statusDetail = 'Hands-free — luisteren…';
|
||||
this.startListen();
|
||||
} else if (!this.handsFreeMode && this.recording) {
|
||||
this.stopListen();
|
||||
this.statusDetail = '';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
offerHttpsAudio: function () {
|
||||
this.httpsAudioPrompt = true;
|
||||
this.status = 'idle';
|
||||
this.statusDetail = '';
|
||||
if (this.handsFreeMode) {
|
||||
this.handsFreeMode = false;
|
||||
this.persistPrefs();
|
||||
}
|
||||
},
|
||||
|
||||
goHttpsAudio: function () {
|
||||
var url = this.httpsChatUrl();
|
||||
this.httpsAudioPrompt = false;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
micSecureOk: function () {
|
||||
return !!(window.isSecureContext || location.hostname === 'localhost' || location.hostname === '127.0.0.1');
|
||||
},
|
||||
|
||||
httpsChatUrl: function () {
|
||||
var host = location.hostname || '10.4.7.18';
|
||||
var path = location.pathname || '/';
|
||||
var q = location.search || '';
|
||||
return 'https://' + host + ':8643' + path + q;
|
||||
},
|
||||
|
||||
clearChat: function () {
|
||||
this.turns = [];
|
||||
this.manualText = '';
|
||||
this.pendingAction = null;
|
||||
this.sessionId = newSessionId(this.sessionPrefix);
|
||||
this.statusDetail = '';
|
||||
this.status = 'idle';
|
||||
this.persistChat();
|
||||
},
|
||||
|
||||
markConn: function (ok, label) {
|
||||
this.connOk = ok;
|
||||
this.connLabel = label || (ok ? 'Verbonden met Herman' : 'Herman niet bereikbaar');
|
||||
},
|
||||
|
||||
scrollLog: function () {
|
||||
@@ -78,6 +369,7 @@ window.HermanAssistant = (function () {
|
||||
|
||||
addTurn: function (role, text, meta) {
|
||||
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
|
||||
this.persistChat();
|
||||
this.scrollLog();
|
||||
},
|
||||
|
||||
@@ -86,8 +378,10 @@ window.HermanAssistant = (function () {
|
||||
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
|
||||
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
|
||||
if (h.reply) {
|
||||
var label = (h.agent_label || 'Herman').replace(/\s*[·•].*$/, '').trim() || 'Herman';
|
||||
if (/hermes/i.test(label)) label = 'Herman';
|
||||
this.addTurn('agent', h.reply, {
|
||||
agent_label: h.agent_label,
|
||||
agent_label: label,
|
||||
delegated: h.delegated_agents,
|
||||
routing_reason: h.routing_reason,
|
||||
});
|
||||
@@ -111,6 +405,8 @@ window.HermanAssistant = (function () {
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
var voice = pickMaartenVoice();
|
||||
if (voice) u.voice = voice;
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
@@ -208,7 +504,12 @@ window.HermanAssistant = (function () {
|
||||
|
||||
async startListen() {
|
||||
if (this.recording || this.busy) return;
|
||||
if (!this.micSecureOk()) {
|
||||
this.offerHttpsAudio();
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
this.httpsAudioPrompt = false;
|
||||
this.livePreview = '';
|
||||
this.status = 'listening';
|
||||
this.statusDetail = 'Luisteren…';
|
||||
@@ -240,8 +541,12 @@ window.HermanAssistant = (function () {
|
||||
}
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast('Microfoon: ' + e.message, 'error');
|
||||
var msg = e.message || String(e);
|
||||
if (!this.micSecureOk()) {
|
||||
msg = 'Mic alleen via HTTPS — gebruik de knop hierboven.';
|
||||
}
|
||||
this.statusDetail = msg;
|
||||
Cockpit.toast('Microfoon: ' + msg, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -291,6 +596,14 @@ window.HermanAssistant = (function () {
|
||||
this.status = 'idle';
|
||||
this.statusDetail = '';
|
||||
} catch (e) {
|
||||
var preview = (this.livePreview || '').trim();
|
||||
if (preview) {
|
||||
this.manualText = preview;
|
||||
this.busy = false;
|
||||
this.statusDetail = 'Browser-spraak gebruikt (Whisper offline)';
|
||||
Cockpit.toast('Whisper offline — tekst via browser-spraak', 'info');
|
||||
return this.sendText();
|
||||
}
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
@@ -301,19 +614,26 @@ window.HermanAssistant = (function () {
|
||||
async sendText(confirmActionId) {
|
||||
var text = (this.manualText || '').trim();
|
||||
if (!text) return;
|
||||
this.addTurn('user', text, { source: 'text' });
|
||||
this.manualText = '';
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Herman denkt…';
|
||||
try {
|
||||
var body = { message: text, channel: this.channel, session_id: this.sessionId };
|
||||
if (confirmActionId) body.confirm_action_id = confirmActionId;
|
||||
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
|
||||
this.addTurn('user', text, { source: 'text' });
|
||||
this.manualText = '';
|
||||
this.handleHermanResponse(r);
|
||||
this.status = 'idle';
|
||||
var reply = (r && r.reply) || '';
|
||||
var failed = /niet bereikbaar|niet beschikbaar|offline/i.test(reply);
|
||||
this.markConn(!failed, failed ? 'Herman niet bereikbaar' : 'Verbonden met Herman');
|
||||
this.status = failed ? 'error' : 'idle';
|
||||
this.statusDetail = failed ? reply.slice(0, 120) : '';
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
this.markConn(false, 'Herman offline');
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
@@ -331,10 +651,21 @@ window.HermanAssistant = (function () {
|
||||
s.initWidget = function () {
|
||||
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
|
||||
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
|
||||
this.applyPrefs();
|
||||
this.loadFabPos();
|
||||
pickMaartenVoice();
|
||||
try {
|
||||
var raw = localStorage.getItem('herman_assistant_open');
|
||||
var raw = localStorage.getItem(STORAGE_OPEN);
|
||||
if (raw === '1') this.open = true;
|
||||
} catch (e) { /* empty */ }
|
||||
var stored = loadStoredChat(this.sessionPrefix);
|
||||
if (stored) {
|
||||
if (stored.sessionId) this.sessionId = stored.sessionId;
|
||||
if (stored.turns && stored.turns.length) this.turns = stored.turns;
|
||||
}
|
||||
this.connOk = null;
|
||||
this.connLabel = 'Herman';
|
||||
if (this.open) this.scrollLog();
|
||||
};
|
||||
return s;
|
||||
},
|
||||
@@ -345,6 +676,8 @@ window.HermanAssistant = (function () {
|
||||
s.lastImage = '';
|
||||
s.initChat = function () {
|
||||
this.open = true;
|
||||
this.applyPrefs();
|
||||
pickMaartenVoice();
|
||||
};
|
||||
s.generateImage = async function () {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
@@ -371,3 +704,11 @@ window.HermanAssistant = (function () {
|
||||
function hermanBrowserWidget() {
|
||||
return HermanAssistant.browserWidget();
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && typeof Alpine.data === 'function') {
|
||||
Alpine.data('hermanBrowserWidget', function () {
|
||||
return HermanAssistant.browserWidget();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+183
-296
@@ -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(); }
|
||||
|
||||
@@ -33,7 +33,7 @@ window.VoiceLive = (function () {
|
||||
busy: false,
|
||||
conversationMode: true,
|
||||
handsFreeMode: false,
|
||||
speakReply: true,
|
||||
speakReply: false,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
pipeline: [],
|
||||
feed: [],
|
||||
@@ -162,6 +162,18 @@ window.VoiceLive = (function () {
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
var voices = window.speechSynthesis.getVoices() || [];
|
||||
var voice = null;
|
||||
for (var vi = 0; vi < voices.length; vi++) {
|
||||
var hay = ((voices[vi].name || '') + ' ' + (voices[vi].lang || ''));
|
||||
if (/maarten/i.test(hay)) { voice = voices[vi]; break; }
|
||||
}
|
||||
if (!voice) {
|
||||
for (var vj = 0; vj < voices.length; vj++) {
|
||||
if (/^nl/i.test(voices[vj].lang || '')) { voice = voices[vj]; break; }
|
||||
}
|
||||
}
|
||||
if (voice) u.voice = voice;
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user