/** * Voice Live — push-to-talk, hands-free, export results popup, Herman bevestiging */ window.VoiceLive = (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 || '—'; } return { page: function () { return { status: 'idle', statusDetail: 'Houd de microfoon ingedrukt en spreek', livePreview: '', liveInterim: false, recording: false, busy: false, conversationMode: true, handsFreeMode: false, speakReply: true, useBrowserPreview: !!SpeechRecognition, pipeline: [], feed: [], turns: [], manualText: '', holdIgnoreClick: false, sessionId: 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9), pendingAction: null, resultsOpen: false, resultsTitle: '', resultsTotal: 0, resultsEntities: [], resultsOpenUrl: '', webbuilderOpen: false, webbuilderTitle: '', webbuilderProject: '', webbuilderPreview: '', webbuilderAgentsUrl: '/agents', webbuilderNas: '', mediaRecorder: null, chunks: [], stream: null, recognition: null, agentEs: null, _resumeTimer: null, _silenceTimer: null, _maxRecordTimer: null, init: function () { var self = this; this.connectAgentFeed(); window.addEventListener('keydown', function (e) { if (e.code === 'Space' && !e.target.matches('input, textarea') && !self.recording && !self.busy) { e.preventDefault(); self.startListen(); } }); window.addEventListener('keyup', function (e) { if (e.code === 'Space' && self.recording && !e.target.matches('input, textarea')) { e.preventDefault(); self.stopListen(); } }); }, destroy: function () { if (this._resumeTimer) clearTimeout(this._resumeTimer); if (this._silenceTimer) clearTimeout(this._silenceTimer); if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer); if (this.agentEs) { this.agentEs.close(); this.agentEs = null; } this._stopRecognition(); this._stopStream(); }, connectAgentFeed: function () { var self = this; if (typeof EventSource === 'undefined') return; try { this.agentEs = new EventSource('/api/agents/live/stream'); this.agentEs.onmessage = function (ev) { try { var data = JSON.parse(ev.data); if (!data || data.type === 'connected') return; var ch = (data.channel || '').toLowerCase(); var agent = (data.agent || data.agent_name || '').toLowerCase(); if (ch !== 'voice' && agent !== 'herman' && agent !== 'voice' && agent !== 'sourcing') return; self.feed.unshift({ at: nowTime(), agent: data.agent || data.agent_name || 'agent', message: data.message || data.title || '', }); if (self.feed.length > 40) self.feed.pop(); } catch (e) { /* empty */ } }; } catch (e) { /* empty */ } }, pushPipeline: function (entries) { if (!entries || !entries.length) return; this.pipeline = this.pipeline.concat(entries); if (this.pipeline.length > 60) this.pipeline = this.pipeline.slice(-60); }, addTurn: function (role, text, meta) { this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() }); this.$nextTick(function () { var log = document.getElementById('voice-chat-log'); if (log) log.scrollTop = log.scrollHeight; }); }, 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) { this.addTurn('agent', h.reply, { agent_label: h.agent_label, delegated: h.delegated_agents, routing_reason: h.routing_reason, needs_confirmation: h.needs_confirmation, }); } 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'; 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.status !== 'error') { self.statusDetail = 'Hands-free — luisteren…'; self.startListen(); } }, delay || 500); }, openResultsModal: function (action) { this.resultsTitle = action.title || 'Export Intel resultaten'; this.resultsEntities = action.entities || []; this.resultsTotal = action.total || this.resultsEntities.length; this.resultsOpenUrl = action.open_url || '/export-intel'; this.resultsOpen = true; this.pushPipeline([{ phase: 'ui', label: 'Popup geopend', detail: this.resultsTotal + ' resultaten', status: 'done', at: new Date().toISOString(), }]); }, 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; this.pushPipeline([{ phase: 'ui', label: 'Agy build gestart', detail: this.webbuilderProject, status: 'running', at: new Date().toISOString(), }]); }, closeWebbuilderModal: function () { this.webbuilderOpen = false; }, confirmPending: function () { if (!this.pendingAction || !this.pendingAction.id) return; var id = this.pendingAction.id; this.manualText = 'ja'; this.sendManual(id); }, cancelPending: function () { this.pendingAction = null; this.manualText = 'nee'; this.sendManual(); }, 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 = ''; var 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.onerror = function () { /* optional */ }; 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; var self = this; if (this._resumeTimer) clearTimeout(this._resumeTimer); this.livePreview = ''; this.liveInterim = false; if (!this.handsFreeMode) this.pipeline = []; this.status = 'listening'; this.statusDetail = this.handsFreeMode ? 'Hands-free — spreek je vraag' : 'Spreek nu — laat los om te versturen'; 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 ((self.conversationMode || self.handsFreeMode) && blob.size > 0) self.processTurn(blob); else if (blob.size > 0) { self.status = 'idle'; self.statusDetail = 'Opname klaar'; } }; this.mediaRecorder.start(250); this.recording = true; this._startRecognition(); if (this.handsFreeMode) { var self = this; if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer); this._maxRecordTimer = setTimeout(function () { if (self.recording) self.stopListen(); }, 18000); } } catch (e) { this.status = 'error'; this.statusDetail = 'Microfoon: ' + e.message; Cockpit.toast(this.statusDetail, 'error'); } }, stopListen() { if (!this.recording || !this.mediaRecorder) return; this.mediaRecorder.stop(); }, toggleListen() { if (this.recording) this.stopListen(); else this.startListen(); }, toggleHandsFree() { if (this.handsFreeMode && !this.recording && !this.busy) { Cockpit.toast('Hands-free aan — ik luister na elk antwoord opnieuw', 'success'); this.startListen(); } else if (!this.handsFreeMode && this.recording) { this.stopListen(); } }, onHandsFreeChange() { if (this.handsFreeMode) this.toggleHandsFree(); else if (this.recording) this.stopListen(); }, async processTurn(blob) { this.busy = true; this.status = 'processing'; this.statusDetail = 'Whisper + Herman…'; this.pushPipeline([{ phase: 'capture', label: 'Audio verstuurd', detail: Math.round(blob.size / 1024) + ' KB', status: 'done', at: new Date().toISOString(), }]); 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 turn mislukt'); this.pushPipeline(j.pipeline || []); var text = j.text || ''; this.livePreview = text; this.liveInterim = false; this.manualText = text; if (text) this.addTurn('user', text, { source: 'whisper' }); var h = j.herman || {}; this.handleHermanResponse(h); this.status = 'idle'; this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar — spreek opnieuw'; if (!h.needs_confirmation && h.reply) Cockpit.toast('Herman antwoordde', 'success'); } catch (e) { this.status = 'error'; this.statusDetail = e.message; this.pushPipeline([{ phase: 'error', label: 'Fout', detail: e.message, status: 'error', at: new Date().toISOString() }]); Cockpit.toast(e.message, 'error'); this.maybeResumeListening(1500); } this.busy = false; }, async sendManual(confirmActionId) { var text = (this.manualText || '').trim(); if (!text) return; this.busy = true; this.status = 'processing'; this.statusDetail = 'Herman denkt na…'; var confirmId = confirmActionId || (this.pendingAction && this.pendingAction.id) || null; if (confirmId && (text.toLowerCase() === 'ja' || confirmActionId)) { /* confirm via button passes id explicitly */ } this.pushPipeline([{ phase: 'herman', label: 'Tekst naar Herman', detail: text.slice(0, 120), status: 'running', at: new Date().toISOString() }]); try { var body = { message: text, channel: 'voice', 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) }); if (this.pipeline.length) this.pipeline[this.pipeline.length - 1].status = 'done'; this.addTurn('user', text, { source: 'typed' }); this.handleHermanResponse(r); this.status = 'idle'; this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar'; } catch (e) { Cockpit.toast(e.message, 'error'); this.status = 'error'; this.statusDetail = e.message; } this.busy = false; }, clearSession() { this.turns = []; this.pipeline = []; this.feed = []; this.livePreview = ''; this.manualText = ''; this.pendingAction = null; this.resultsOpen = false; this.sessionId = 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9); this.status = 'idle'; this.statusDetail = 'Sessie gewist'; }, micClass() { if (this.recording) return 'is-listening'; if (this.busy) return 'is-busy'; if (this.handsFreeMode) return 'is-handsfree'; return ''; }, onMicDown() { this.holdIgnoreClick = true; this.startListen(); }, onMicUp() { if (this.recording && !this.handsFreeMode) this.stopListen(); var self = this; setTimeout(function () { self.holdIgnoreClick = false; }, 250); }, onMicClick() { if (this.holdIgnoreClick) return; this.toggleListen(); }, formatDelegated(meta) { var d = (meta && meta.delegated) || []; return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', '); }, }; }, }; })();