/** * 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(); }); } });