SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* 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(', ');
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user