5e43b6b246
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).
715 lines
24 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
});
|