Files
foodlinkk-command-center/cockpit/static/js/herman-assistant.js
T
Aissa 5e43b6b246 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).
2026-08-01 17:50:45 +00:00

715 lines
24 KiB
JavaScript

/**
* Herman Assistant — browser widget + gedeelde chat/voice logica
*/
window.HermanAssistant = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
butcher: 'Slager', doner: 'Döner',
};
return m[t] || t || '—';
}
function newSessionId(prefix) {
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 {
channel: opts.channel || 'browser',
sessionPrefix: opts.sessionPrefix || 'br',
open: false,
hidden: false,
status: 'idle',
statusDetail: '',
connOk: null,
connLabel: 'Herman',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: false,
handsFreeMode: false,
httpsAudioPrompt: false,
fabPos: null,
fabDragging: false,
_fabMoved: false,
_fabPointerId: null,
_fabOrigin: null,
useBrowserPreview: !!SpeechRecognition,
turns: [],
manualText: '',
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
sessionId: newSessionId(opts.sessionPrefix || 'br'),
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
holdIgnoreClick: false,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
initWidget: function () { /* overridden in browserWidget */ },
toggleOpen: function () {
this.open = !this.open;
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 () {
var self = this;
this.$nextTick(function () {
var log = self.$refs.chatLog;
if (log) log.scrollTop = log.scrollHeight;
});
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.persistChat();
this.scrollLog();
},
handleHermanResponse: function (h) {
if (!h) return;
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: label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
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 {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
},
closeResultsModal: function () { this.resultsOpen = false; },
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
},
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
this.manualText = 'ja';
this.sendText(this.pendingAction.id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendText();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '', final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
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…';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if (blob.size > 0) self.processVoice(blob);
else { self.status = 'idle'; self.statusDetail = ''; }
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = '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');
}
},
stopListen: function () {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen: function () {
if (this.recording) this.stopListen();
else this.startListen();
},
micClass: function () {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
return '';
},
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
onMicUp: function () {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick: function () {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
async processVoice(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
var text = j.text || '';
this.livePreview = text;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'voice' });
this.handleHermanResponse(j.herman || {});
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');
}
this.busy = false;
},
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.handleHermanResponse(r);
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;
},
formatDelegated: function (meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
}
return {
browserWidget: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
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(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;
},
chatPage: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
s.imagePrompt = '';
s.generating = false;
s.lastImage = '';
s.initChat = function () {
this.open = true;
this.applyPrefs();
pickMaartenVoice();
};
s.generateImage = async function () {
if (!this.imagePrompt.trim()) return;
this.generating = true;
try {
var r = await Cockpit.api('/api/ai/generate-image', {
method: 'POST',
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 }),
});
if (r.proxy_url) {
this.lastImage = r.proxy_url;
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
}
Cockpit.toast('Afbeelding gegenereerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.generating = false;
};
return s;
},
entityTypeLabel: entityTypeLabel,
};
})();
function hermanBrowserWidget() {
return HermanAssistant.browserWidget();
}
document.addEventListener('alpine:init', function () {
if (window.Alpine && typeof Alpine.data === 'function') {
Alpine.data('hermanBrowserWidget', function () {
return HermanAssistant.browserWidget();
});
}
});