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:
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user