Files

374 lines
13 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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);
}
function baseState(opts) {
opts = opts || {};
return {
channel: opts.channel || 'browser',
sessionPrefix: opts.sessionPrefix || 'br',
open: false,
hidden: false,
status: 'idle',
statusDetail: '',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: true,
handsFreeMode: false,
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('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
},
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.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) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_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';
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;
var self = this;
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';
this.statusDetail = e.message;
Cockpit.toast('Microfoon: ' + e.message, '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) {
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.busy = true;
this.status = 'processing';
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.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.handleHermanResponse(r);
this.status = 'idle';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
}
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;
try {
var raw = localStorage.getItem('herman_assistant_open');
if (raw === '1') this.open = true;
} catch (e) { /* empty */ }
};
return s;
},
chatPage: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
s.imagePrompt = '';
s.generating = false;
s.lastImage = '';
s.initChat = function () {
this.open = true;
};
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();
}