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
+350 -9
View File
@@ -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();
});
}
});