1072 lines
32 KiB
JavaScript
1072 lines
32 KiB
JavaScript
const cfg = window.EB_CONCIERGE || {};
|
|
const botId = cfg.botId;
|
|
const apiBase = String(cfg.apiBase || '').replace(/\/$/, '');
|
|
const Livekit = window.LivekitClient || window.livekit;
|
|
|
|
const videoEl = document.getElementById('eba-video');
|
|
const audioEl = document.getElementById('eba-audio');
|
|
const waveEl = document.getElementById('eba-wave');
|
|
const messagesEl = document.getElementById('eba-messages');
|
|
const errorEl = document.getElementById('eba-error');
|
|
const formEl = document.getElementById('eba-form');
|
|
const inputEl = document.getElementById('eba-input');
|
|
const sendBtn = document.getElementById('eba-send');
|
|
const startBtn = document.getElementById('eba-start');
|
|
const startHintEl = document.getElementById('eba-start-hint');
|
|
const statusEl = document.getElementById('eba-avatar-status');
|
|
const titleEl = document.getElementById('eba-title');
|
|
const subEl = document.getElementById('eba-sub');
|
|
|
|
let sessionId = null;
|
|
let busy = false;
|
|
let livekitRoom = null;
|
|
let simliClient = null;
|
|
let started = false;
|
|
let chatReady = false;
|
|
let pipedTrackId = null;
|
|
let seenTranscriptIds = {};
|
|
let chipsEl = null;
|
|
const mutedLivekitAudioEls = [];
|
|
let pendingGreeting = '';
|
|
let pendingQuickReplies = [];
|
|
let greetingShown = false;
|
|
let avatarReadySent = false;
|
|
let lastAgentMediaTrack = null;
|
|
let lastAgentTrackClone = null;
|
|
let sessionAlive = false;
|
|
let simliRestarting = false;
|
|
let simliIdle = false;
|
|
let simliGeneration = 0;
|
|
let videoAvatarOn = false;
|
|
let waveAudioCtx = null;
|
|
let userAnalyser = null;
|
|
let agentAnalyser = null;
|
|
let waveRaf = 0;
|
|
let simliInputEnabled = false;
|
|
let agentPipelineState = '';
|
|
let micListening = true;
|
|
let micPublished = false;
|
|
let micResumeTimer = 0;
|
|
const SIMLI_AUDIO_BUFFER = 3000;
|
|
const AGENT_STATE_ATTR = 'lk.agent.state';
|
|
|
|
function setStatus(text) {
|
|
statusEl.textContent = text || '';
|
|
}
|
|
|
|
function setError(text) {
|
|
const msg = String(text || '').trim();
|
|
errorEl.hidden = !msg;
|
|
errorEl.textContent = msg;
|
|
}
|
|
|
|
function showStartOverlay(label, hint) {
|
|
startBtn.hidden = false;
|
|
const title = startBtn.querySelector('.eba-start-title');
|
|
if (title) title.textContent = label || 'Tap to start';
|
|
if (startHintEl) startHintEl.textContent = hint || '';
|
|
}
|
|
|
|
function hideStartOverlay() {
|
|
startBtn.hidden = true;
|
|
if (startHintEl) startHintEl.textContent = '';
|
|
}
|
|
|
|
function el(tag, attrs, children) {
|
|
const n = document.createElement(tag);
|
|
if (attrs) {
|
|
Object.keys(attrs).forEach((k) => n.setAttribute(k, attrs[k]));
|
|
}
|
|
(children || []).forEach((c) => {
|
|
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
});
|
|
return n;
|
|
}
|
|
|
|
function appendFormattedText(parent, text) {
|
|
const s = String(text || '');
|
|
const re = /\*\*([^*]+)\*\*/g;
|
|
let last = 0;
|
|
let m;
|
|
while ((m = re.exec(s))) {
|
|
if (m.index > last) parent.appendChild(document.createTextNode(s.slice(last, m.index)));
|
|
parent.appendChild(el('strong', null, [m[1]]));
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < s.length) parent.appendChild(document.createTextNode(s.slice(last)));
|
|
return parent;
|
|
}
|
|
|
|
function addMessage(role, text, opts = {}) {
|
|
const bubble = el('div', { class: `eba-msg ${role === 'user' ? 'user' : 'bot'}` });
|
|
if (text) {
|
|
const intro = el('div');
|
|
appendFormattedText(intro, text);
|
|
bubble.appendChild(intro);
|
|
}
|
|
const cards = Array.isArray(opts.cards) ? opts.cards : [];
|
|
if (cards.length) {
|
|
const list = el('div', { class: 'eba-cards' });
|
|
cards.forEach((card) => {
|
|
const node = renderCard(card);
|
|
if (node) list.appendChild(node);
|
|
});
|
|
if (list.childNodes.length) bubble.appendChild(list);
|
|
}
|
|
if (opts.messageId && role !== 'user') {
|
|
bubble.appendChild(buildFeedback(String(opts.messageId)));
|
|
}
|
|
messagesEl.appendChild(bubble);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
return bubble;
|
|
}
|
|
|
|
function renderCard(card) {
|
|
if (!card) return null;
|
|
const d = card.data || {};
|
|
const type = card.type;
|
|
const wrap = el('article', { class: 'eba-card' });
|
|
|
|
if (type === 'cta' && d.url) {
|
|
wrap.appendChild(
|
|
el(
|
|
'a',
|
|
{ href: String(d.url), target: '_blank', rel: 'noopener noreferrer' },
|
|
[String(d.actionLabel || d.title || 'Open')],
|
|
),
|
|
);
|
|
return wrap;
|
|
}
|
|
|
|
if (type === 'ticket_qr') {
|
|
wrap.appendChild(el('div', { class: 'eba-card-title' }, [d.name || d.ticketName || 'Ticket']));
|
|
if (d.status) wrap.appendChild(el('div', { class: 'eba-card-meta' }, [String(d.status)]));
|
|
const img = d.qrImageUrl || d.imageUrl;
|
|
if (img) wrap.appendChild(el('img', { src: String(img), alt: 'Check-in QR' }));
|
|
return wrap;
|
|
}
|
|
|
|
if (type === 'file') {
|
|
const title = String(d.title || 'Download').trim() || 'Download';
|
|
wrap.appendChild(el('div', { class: 'eba-card-title' }, [title]));
|
|
if (d.url) {
|
|
wrap.appendChild(
|
|
el('a', { href: String(d.url), target: '_blank', rel: 'noopener noreferrer' }, ['Open']),
|
|
);
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
const title = d.title || d.name;
|
|
if (title) wrap.appendChild(el('div', { class: 'eba-card-title' }, [String(title)]));
|
|
const meta = [
|
|
d.timeRangeLocal,
|
|
d.location,
|
|
d.track,
|
|
d.booth,
|
|
d.address,
|
|
d.organization || d.organizationName,
|
|
d.speakerRole,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
if (meta) wrap.appendChild(el('div', { class: 'eba-card-meta' }, [meta]));
|
|
if (Array.isArray(d.speakers) && d.speakers.length) {
|
|
wrap.appendChild(
|
|
el('div', { class: 'eba-card-meta' }, [`Speakers: ${d.speakers.slice(0, 4).join(', ')}`]),
|
|
);
|
|
}
|
|
const href = d.url || d.mapsUrl || d.website;
|
|
if (href) {
|
|
wrap.appendChild(
|
|
el('a', { href: String(href), target: '_blank', rel: 'noopener noreferrer' }, [
|
|
d.mapsUrl ? 'Open map' : 'Open',
|
|
]),
|
|
);
|
|
}
|
|
return wrap.childNodes.length ? wrap : null;
|
|
}
|
|
|
|
function buildFeedback(messageId) {
|
|
const footer = el('div', { class: 'eba-feedback' }, ['Was this useful?']);
|
|
const up = el('button', { type: 'button', 'aria-label': 'Yes' }, ['👍']);
|
|
const down = el('button', { type: 'button', 'aria-label': 'No' }, ['👎']);
|
|
const send = async (satisfied) => {
|
|
up.disabled = true;
|
|
down.disabled = true;
|
|
try {
|
|
await api(`/public/concierge/${encodeURIComponent(botId)}/feedback`, {
|
|
method: 'POST',
|
|
body: { sessionId, messageId, satisfied },
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
up.addEventListener('click', () => send(true));
|
|
down.addEventListener('click', () => send(false));
|
|
footer.appendChild(up);
|
|
footer.appendChild(down);
|
|
return footer;
|
|
}
|
|
|
|
function renderChips(items) {
|
|
if (chipsEl && chipsEl.parentNode) chipsEl.parentNode.removeChild(chipsEl);
|
|
chipsEl = null;
|
|
const rows = Array.isArray(items) ? items : [];
|
|
if (!rows.length) return;
|
|
chipsEl = el('div', { class: 'eba-chips' });
|
|
rows.slice(0, 6).forEach((item) => {
|
|
const label = String(item.label || item.message || '').trim();
|
|
if (!label) return;
|
|
const btn = el('button', { class: 'eba-chip', type: 'button' }, [label]);
|
|
btn.addEventListener('click', () => {
|
|
if (busy) return;
|
|
inputEl.value = String(item.message || label);
|
|
sendMessage();
|
|
});
|
|
chipsEl.appendChild(btn);
|
|
});
|
|
if (!chipsEl.childNodes.length) {
|
|
chipsEl = null;
|
|
return;
|
|
}
|
|
messagesEl.appendChild(chipsEl);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
|
|
function normalizeSpeech(text) {
|
|
return String(text || '')
|
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function showPendingGreeting() {
|
|
if (greetingShown) return;
|
|
greetingShown = true;
|
|
if (pendingGreeting) addMessage('bot', pendingGreeting);
|
|
if (pendingQuickReplies.length) renderChips(pendingQuickReplies);
|
|
}
|
|
|
|
async function signalAvatarReady(room) {
|
|
if (avatarReadySent || !room?.localParticipant) return;
|
|
avatarReadySent = true;
|
|
showPendingGreeting();
|
|
const payload = new TextEncoder().encode(JSON.stringify({ type: 'avatar-ready' }));
|
|
const send = async () => {
|
|
try {
|
|
if (typeof room.localParticipant.publishData === 'function') {
|
|
await room.localParticipant.publishData(payload, { reliable: true });
|
|
}
|
|
} catch (err) {
|
|
console.warn('[avatar] ready signal failed', err);
|
|
}
|
|
};
|
|
await send();
|
|
setTimeout(send, 1500);
|
|
}
|
|
|
|
function rememberTranscript(id) {
|
|
if (!id) return false;
|
|
if (seenTranscriptIds[id]) return true;
|
|
seenTranscriptIds[id] = true;
|
|
return false;
|
|
}
|
|
|
|
async function api(path, options) {
|
|
const hasBody = options?.body !== undefined;
|
|
const res = await fetch(apiBase + path, {
|
|
method: options?.method || 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: hasBody ? JSON.stringify(options.body) : undefined,
|
|
});
|
|
const json = await res.json().catch(() => null);
|
|
if (!res.ok || !json || json.success === false) {
|
|
const msg = json?.error?.message || json?.message || `Request failed (${res.status})`;
|
|
throw new Error(msg);
|
|
}
|
|
return json.data;
|
|
}
|
|
|
|
function setBusy(next) {
|
|
busy = next;
|
|
inputEl.disabled = next;
|
|
sendBtn.disabled = next;
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const text = String(inputEl.value || '').trim();
|
|
if (!text || busy) return;
|
|
setError('');
|
|
inputEl.value = '';
|
|
renderChips([]);
|
|
addMessage('user', text);
|
|
ensureSimliConnected('chat');
|
|
setBusy(true);
|
|
try {
|
|
const data = await api(`/public/concierge/${encodeURIComponent(botId)}/chat`, {
|
|
method: 'POST',
|
|
body: { message: text, sessionId: sessionId || undefined },
|
|
});
|
|
sessionId = data.sessionId || sessionId;
|
|
addMessage('bot', data.reply || 'No reply', {
|
|
cards: data.cards || [],
|
|
messageId: data.messageId || null,
|
|
});
|
|
if (Array.isArray(data.quickReplies) && data.quickReplies.length) {
|
|
renderChips(data.quickReplies);
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || 'Failed to send');
|
|
} finally {
|
|
setBusy(false);
|
|
inputEl.focus();
|
|
}
|
|
}
|
|
|
|
function mediaTrackFrom(livekitTrack) {
|
|
if (!livekitTrack) return null;
|
|
return livekitTrack.mediaStreamTrack || livekitTrack.mediaStream?.getAudioTracks?.()[0] || null;
|
|
}
|
|
|
|
function isLocalParticipant(room, participant) {
|
|
if (!room || !participant) return false;
|
|
const local = room.localParticipant;
|
|
if (local && (participant === local || (local.sid && participant.sid === local.sid))) {
|
|
return true;
|
|
}
|
|
const localId = local?.identity;
|
|
return Boolean(localId && participant.identity === localId);
|
|
}
|
|
|
|
function stopAgentTrackClone() {
|
|
if (lastAgentTrackClone && typeof lastAgentTrackClone.stop === 'function') {
|
|
try {
|
|
lastAgentTrackClone.stop();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
lastAgentTrackClone = null;
|
|
}
|
|
|
|
function unmuteSimliPlayback() {
|
|
if (!audioEl) return;
|
|
audioEl.muted = false;
|
|
audioEl.defaultMuted = false;
|
|
audioEl.volume = 1;
|
|
audioEl.removeAttribute('muted');
|
|
}
|
|
|
|
function parseAgentStatePayload(raw) {
|
|
try {
|
|
const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
|
|
if (msg && msg.type === 'agent-state' && msg.state) return String(msg.state).toLowerCase();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function readRemoteAgentState(room) {
|
|
let state = '';
|
|
room?.remoteParticipants?.forEach((p) => {
|
|
if (!p || isLocalParticipant(room, p)) return;
|
|
const value = p.attributes?.[AGENT_STATE_ATTR];
|
|
if (value) state = String(value).toLowerCase();
|
|
});
|
|
return state;
|
|
}
|
|
|
|
function disconnectSimliTts() {
|
|
const src = simliClient?.sourceNode;
|
|
if (src && typeof src.disconnect === 'function') {
|
|
try {
|
|
src.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (simliClient) simliClient.sourceNode = null;
|
|
stopAgentTrackClone();
|
|
simliInputEnabled = false;
|
|
pipedTrackId = null;
|
|
}
|
|
|
|
function connectSimliTts(media) {
|
|
if (!videoAvatarOn || !media || !simliClient || typeof simliClient.listenToMediastreamTrack !== 'function') {
|
|
return;
|
|
}
|
|
if (simliIdle) {
|
|
ensureSimliConnected('agent-tts');
|
|
return;
|
|
}
|
|
const id = media.id || media.label;
|
|
if (simliInputEnabled && pipedTrackId && pipedTrackId === id) return;
|
|
disconnectSimliTts();
|
|
try {
|
|
const clone = typeof media.clone === 'function' ? media.clone() : media;
|
|
lastAgentTrackClone = clone;
|
|
if (clone) clone.enabled = true;
|
|
simliClient.listenToMediastreamTrack(clone);
|
|
simliInputEnabled = true;
|
|
pipedTrackId = id;
|
|
} catch (err) {
|
|
console.warn('[avatar] simli tts pipe failed', err);
|
|
}
|
|
}
|
|
|
|
async function setMicListening(on) {
|
|
const next = Boolean(on);
|
|
if (micResumeTimer) {
|
|
clearTimeout(micResumeTimer);
|
|
micResumeTimer = 0;
|
|
}
|
|
if (!micPublished || !livekitRoom?.localParticipant) {
|
|
micListening = next;
|
|
return;
|
|
}
|
|
if (micListening === next) return;
|
|
micListening = next;
|
|
try {
|
|
if (typeof livekitRoom.localParticipant.setMicrophoneEnabled === 'function') {
|
|
await livekitRoom.localParticipant.setMicrophoneEnabled(next);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[avatar] mic toggle failed', err);
|
|
}
|
|
}
|
|
|
|
function applyAgentState(state) {
|
|
const next = String(state || '').toLowerCase();
|
|
if (!next) return;
|
|
agentPipelineState = next;
|
|
const speaking = next === 'speaking';
|
|
const holdMic = speaking || next === 'thinking';
|
|
|
|
if (videoAvatarOn) {
|
|
if (speaking && lastAgentMediaTrack) {
|
|
if (!simliInputEnabled) connectSimliTts(lastAgentMediaTrack);
|
|
} else if (!speaking) {
|
|
disconnectSimliTts();
|
|
}
|
|
}
|
|
|
|
if (holdMic) {
|
|
setMicListening(false);
|
|
} else {
|
|
micResumeTimer = window.setTimeout(() => setMicListening(true), 700);
|
|
}
|
|
|
|
if (simliIdle || simliRestarting) return;
|
|
if (next === 'thinking') setStatus('Thinking');
|
|
else if (speaking) setStatus('Speaking');
|
|
else setStatus('Listening');
|
|
}
|
|
|
|
function makeSimliClient(SimliClient, LogLevel, token) {
|
|
return new SimliClient(
|
|
token,
|
|
videoEl,
|
|
audioEl,
|
|
null,
|
|
LogLevel?.INFO ?? 1,
|
|
'livekit',
|
|
'websockets',
|
|
'wss://api.simli.ai',
|
|
SIMLI_AUDIO_BUFFER,
|
|
);
|
|
}
|
|
|
|
function attachRemoteAudio(track, audible) {
|
|
if (!track || typeof track.attach !== 'function') return;
|
|
try {
|
|
const node = track.attach();
|
|
if (!node) return;
|
|
node.muted = !audible;
|
|
node.volume = audible ? 1 : 0;
|
|
node.autoplay = true;
|
|
node.playsInline = true;
|
|
node.style.position = 'absolute';
|
|
node.style.width = '1px';
|
|
node.style.height = '1px';
|
|
node.style.opacity = '0';
|
|
node.style.pointerEvents = 'none';
|
|
document.body.appendChild(node);
|
|
mutedLivekitAudioEls.push(node);
|
|
if (audible && node.play) node.play().catch(() => {});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function unmuteLivekitFallback() {
|
|
mutedLivekitAudioEls.forEach((node) => {
|
|
try {
|
|
node.muted = false;
|
|
node.volume = 1;
|
|
if (node.play) node.play().catch(() => {});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
function muteLivekitPlayback() {
|
|
mutedLivekitAudioEls.forEach((node) => {
|
|
try {
|
|
node.muted = true;
|
|
node.volume = 0;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
function parseAccentRgb() {
|
|
const raw = getComputedStyle(document.documentElement).getPropertyValue('--eba-accent').trim();
|
|
const m = raw.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
|
|
return { r: 13, g: 148, b: 136 };
|
|
}
|
|
|
|
function ensureWaveAudioCtx() {
|
|
if (!waveAudioCtx) {
|
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
if (!Ctx) return null;
|
|
waveAudioCtx = new Ctx();
|
|
}
|
|
if (waveAudioCtx.state === 'suspended') waveAudioCtx.resume().catch(() => {});
|
|
return waveAudioCtx;
|
|
}
|
|
|
|
function hookWaveAnalyser(mediaTrack, slot) {
|
|
if (!mediaTrack || videoAvatarOn) return;
|
|
const ctx = ensureWaveAudioCtx();
|
|
if (!ctx) return;
|
|
try {
|
|
const clone = typeof mediaTrack.clone === 'function' ? mediaTrack.clone() : mediaTrack;
|
|
const source = ctx.createMediaStreamSource(new MediaStream([clone]));
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
analyser.smoothingTimeConstant = 0.72;
|
|
source.connect(analyser);
|
|
if (slot === 'user') userAnalyser = analyser;
|
|
else agentAnalyser = analyser;
|
|
} catch (err) {
|
|
console.warn('[avatar] wave analyser failed', err);
|
|
}
|
|
startWaveLoop();
|
|
}
|
|
|
|
function analyserSnapshot(analyser) {
|
|
if (!analyser) return { rms: 0, bins: null };
|
|
const bins = new Uint8Array(analyser.frequencyBinCount);
|
|
analyser.getByteFrequencyData(bins);
|
|
let sum = 0;
|
|
for (let i = 0; i < bins.length; i += 1) sum += bins[i] * bins[i];
|
|
return { rms: Math.sqrt(sum / bins.length) / 255, bins };
|
|
}
|
|
|
|
function stopWaveLoop() {
|
|
if (waveRaf) {
|
|
cancelAnimationFrame(waveRaf);
|
|
waveRaf = 0;
|
|
}
|
|
}
|
|
|
|
function startWaveLoop() {
|
|
if (!waveEl || videoAvatarOn || waveRaf) return;
|
|
const canvas = waveEl;
|
|
const ctx2d = canvas.getContext('2d');
|
|
if (!ctx2d) return;
|
|
|
|
const draw = () => {
|
|
waveRaf = requestAnimationFrame(draw);
|
|
const w = canvas.clientWidth || 1;
|
|
const h = canvas.clientHeight || 1;
|
|
if (canvas.width !== w || canvas.height !== h) {
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
}
|
|
const user = analyserSnapshot(userAnalyser);
|
|
const agent = analyserSnapshot(agentAnalyser);
|
|
const accent = parseAccentRgb();
|
|
const t = Date.now() / 1000;
|
|
const idle = 0.1 + 0.04 * Math.sin(t * 2.2);
|
|
const userRms = Math.max(user.rms, 0);
|
|
const agentRms = Math.max(agent.rms, 0);
|
|
const level = Math.max(userRms, agentRms, idle * 0.35);
|
|
const agentLead = agentRms >= userRms;
|
|
const bins = (agentLead ? agent.bins : user.bins) || new Uint8Array(32);
|
|
const barCount = 36;
|
|
ctx2d.clearRect(0, 0, w, h);
|
|
const bg = ctx2d.createRadialGradient(w / 2, h / 2, 20, w / 2, h / 2, Math.max(w, h) * 0.55);
|
|
bg.addColorStop(0, `rgba(${accent.r},${accent.g},${accent.b},${0.12 + level * 0.28})`);
|
|
bg.addColorStop(1, 'rgba(2,6,23,0)');
|
|
ctx2d.fillStyle = bg;
|
|
ctx2d.fillRect(0, 0, w, h);
|
|
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const radius = Math.min(w, h) * 0.16;
|
|
ctx2d.beginPath();
|
|
ctx2d.arc(cx, cy, radius * (0.85 + level * 0.55), 0, Math.PI * 2);
|
|
ctx2d.fillStyle = agentLead
|
|
? `rgba(${accent.r},${accent.g},${accent.b},${0.35 + agentRms * 0.5})`
|
|
: `rgba(226,232,240,${0.22 + userRms * 0.5})`;
|
|
ctx2d.fill();
|
|
|
|
const step = Math.max(1, Math.floor(bins.length / barCount));
|
|
const gap = 6;
|
|
const barW = Math.max(4, (Math.min(w, h) * 0.72 - gap * (barCount - 1)) / barCount);
|
|
const totalW = barCount * barW + (barCount - 1) * gap;
|
|
const x0 = cx - totalW / 2;
|
|
const maxBar = Math.min(h, w) * 0.28;
|
|
for (let i = 0; i < barCount; i += 1) {
|
|
const v = (bins[i * step] || 0) / 255;
|
|
const amp = Math.max(idle * 0.25, v) * (0.55 + level);
|
|
const bh = Math.max(8, amp * maxBar);
|
|
const x = x0 + i * (barW + gap);
|
|
const y = cy - bh / 2;
|
|
ctx2d.fillStyle = agentLead
|
|
? `rgba(${accent.r},${accent.g},${accent.b},${0.45 + v * 0.5})`
|
|
: `rgba(203,213,225,${0.4 + v * 0.55})`;
|
|
ctx2d.beginPath();
|
|
if (ctx2d.roundRect) ctx2d.roundRect(x, y, barW, bh, 4);
|
|
else ctx2d.rect(x, y, barW, bh);
|
|
ctx2d.fill();
|
|
}
|
|
};
|
|
draw();
|
|
}
|
|
|
|
function applyStageMode() {
|
|
const root = document.getElementById('eba-root');
|
|
if (!root) return;
|
|
root.classList.toggle('eba-mode-wave', !videoAvatarOn);
|
|
root.classList.toggle('eba-mode-video', videoAvatarOn);
|
|
if (videoAvatarOn) stopWaveLoop();
|
|
else startWaveLoop();
|
|
}
|
|
|
|
function handleIncomingAudio(track, participant, room) {
|
|
if (!track || String(track.kind || '').toLowerCase() !== 'audio') return;
|
|
if (isLocalParticipant(room, participant)) return;
|
|
attachRemoteAudio(track, true);
|
|
const media = mediaTrackFrom(track);
|
|
lastAgentMediaTrack = media;
|
|
if (videoAvatarOn) {
|
|
if (agentPipelineState === 'speaking') connectSimliTts(media);
|
|
} else {
|
|
hookWaveAnalyser(media, 'agent');
|
|
setTimeout(() => signalAvatarReady(room), 400);
|
|
}
|
|
}
|
|
|
|
function ensureSimliConnected(reason) {
|
|
if (!videoAvatarOn) return;
|
|
if (!sessionAlive || !livekitRoom || simliRestarting) return;
|
|
if (!simliIdle && simliClient) return;
|
|
restartSimliAvatar(reason || 'session-restart');
|
|
}
|
|
|
|
async function restartSimliAvatar(reason) {
|
|
if (!sessionAlive || simliRestarting || !livekitRoom) return;
|
|
simliRestarting = true;
|
|
simliGeneration += 1;
|
|
setStatus('Reconnecting avatar…');
|
|
console.warn('[avatar] restarting simli', reason);
|
|
try {
|
|
const prev = simliClient;
|
|
simliClient = null;
|
|
if (prev && typeof prev.stop === 'function') {
|
|
await prev.stop().catch(() => {});
|
|
}
|
|
const simli = await api(`/public/concierge/${encodeURIComponent(botId)}/simli/token`, {
|
|
method: 'POST',
|
|
body: {},
|
|
});
|
|
if (!simli?.sessionToken) throw new Error('Avatar session token was empty');
|
|
const { SimliClient, LogLevel } = await loadSimliClient();
|
|
const next = makeSimliClient(SimliClient, LogLevel, simli.sessionToken);
|
|
bindSimliEvents(next);
|
|
simliClient = next;
|
|
await next.start();
|
|
const played = await resumeSimliAudio();
|
|
pipedTrackId = null;
|
|
if (played) muteLivekitPlayback();
|
|
else unmuteLivekitFallback();
|
|
simliIdle = false;
|
|
applyAgentState(readRemoteAgentState(livekitRoom) || agentPipelineState);
|
|
} catch (err) {
|
|
console.warn('[avatar] simli restart failed', err);
|
|
simliIdle = true;
|
|
setStatus('Avatar disconnected');
|
|
} finally {
|
|
simliRestarting = false;
|
|
}
|
|
}
|
|
|
|
function bindSimliEvents(client) {
|
|
if (!client || typeof client.on !== 'function') return;
|
|
const gen = simliGeneration;
|
|
let ready = false;
|
|
client.on('start', () => {
|
|
if (gen !== simliGeneration) return;
|
|
ready = true;
|
|
simliIdle = false;
|
|
muteLivekitPlayback();
|
|
unmuteSimliPlayback();
|
|
if (audioEl?.play) audioEl.play().catch(() => {});
|
|
applyAgentState(readRemoteAgentState(livekitRoom) || agentPipelineState);
|
|
});
|
|
client.on('stop', () => {
|
|
if (gen !== simliGeneration) return;
|
|
simliIdle = true;
|
|
setStatus('Idle — speak to continue');
|
|
});
|
|
client.on('error', (message) => {
|
|
if (gen !== simliGeneration) return;
|
|
console.warn('[avatar] simli error', message);
|
|
if (sessionAlive && ready) restartSimliAvatar(message || 'error');
|
|
});
|
|
}
|
|
|
|
async function loadSimliClient() {
|
|
const mod = await import('/embed/simli-client.js');
|
|
const SimliClient = mod.SimliClient || mod.default?.SimliClient || mod.default;
|
|
if (typeof SimliClient !== 'function') {
|
|
throw new Error('SIMLI client failed to load');
|
|
}
|
|
return { SimliClient, LogLevel: mod.LogLevel || mod.default?.LogLevel };
|
|
}
|
|
|
|
async function resumeSimliAudio() {
|
|
const ctx = simliClient?.audioContext;
|
|
if (ctx && ctx.state === 'suspended' && typeof ctx.resume === 'function') {
|
|
await ctx.resume().catch(() => {});
|
|
}
|
|
unmuteSimliPlayback();
|
|
let played = false;
|
|
if (audioEl?.play) {
|
|
played = await audioEl.play().then(() => true).catch(() => false);
|
|
}
|
|
if (videoEl?.play) videoEl.play().catch(() => {});
|
|
return played;
|
|
}
|
|
|
|
async function startSimliOverlay() {
|
|
if (!videoAvatarOn || simliRestarting) return;
|
|
simliRestarting = true;
|
|
try {
|
|
setStatus('Connecting avatar…');
|
|
const simli = await api(`/public/concierge/${encodeURIComponent(botId)}/simli/token`, {
|
|
method: 'POST',
|
|
body: {},
|
|
});
|
|
if (!simli?.sessionToken) throw new Error('Avatar session token was empty');
|
|
const { SimliClient, LogLevel } = await loadSimliClient();
|
|
const next = makeSimliClient(SimliClient, LogLevel, simli.sessionToken);
|
|
bindSimliEvents(next);
|
|
simliClient = next;
|
|
await next.start();
|
|
const played = await resumeSimliAudio();
|
|
pipedTrackId = null;
|
|
if (played) muteLivekitPlayback();
|
|
else unmuteLivekitFallback();
|
|
applyAgentState(readRemoteAgentState(livekitRoom) || agentPipelineState);
|
|
} catch (err) {
|
|
console.warn('[avatar] simli overlay failed, voice continues', err);
|
|
simliClient = null;
|
|
unmuteLivekitFallback();
|
|
setStatus('Voice only');
|
|
} finally {
|
|
simliRestarting = false;
|
|
}
|
|
}
|
|
|
|
async function teardownMedia() {
|
|
sessionAlive = false;
|
|
simliIdle = false;
|
|
simliGeneration += 1;
|
|
if (micResumeTimer) {
|
|
clearTimeout(micResumeTimer);
|
|
micResumeTimer = 0;
|
|
}
|
|
disconnectSimliTts();
|
|
stopWaveLoop();
|
|
userAnalyser = null;
|
|
agentAnalyser = null;
|
|
if (waveAudioCtx) {
|
|
waveAudioCtx.close().catch(() => {});
|
|
waveAudioCtx = null;
|
|
}
|
|
avatarReadySent = false;
|
|
agentPipelineState = '';
|
|
micPublished = false;
|
|
micListening = true;
|
|
lastAgentMediaTrack = null;
|
|
stopAgentTrackClone();
|
|
pipedTrackId = null;
|
|
mutedLivekitAudioEls.splice(0).forEach((node) => {
|
|
try {
|
|
if (node.srcObject) node.srcObject = null;
|
|
if (node.parentNode) node.parentNode.removeChild(node);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
const room = livekitRoom;
|
|
livekitRoom = null;
|
|
if (room) {
|
|
try {
|
|
await room.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
const simli = simliClient;
|
|
simliClient = null;
|
|
if (simli && typeof simli.stop === 'function') {
|
|
try {
|
|
await simli.stop();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function maybeResume() {
|
|
const params = new URLSearchParams(window.location.search || '');
|
|
const ref = String(params.get('session') || params.get('sessionId') || cfg.sessionId || '').trim();
|
|
if (!ref) return false;
|
|
const data = await api(
|
|
`/public/concierge/${encodeURIComponent(botId)}/session/${encodeURIComponent(ref)}`,
|
|
);
|
|
sessionId = data.sessionId || sessionId;
|
|
messagesEl.innerHTML = '';
|
|
renderChips([]);
|
|
(data.history || []).forEach((m) => {
|
|
addMessage(m.role === 'user' ? 'user' : 'bot', m.text);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function loadChat() {
|
|
if (chatReady) return;
|
|
const config = await api(`/public/concierge/${encodeURIComponent(botId)}/config`);
|
|
titleEl.textContent = config.eventName || 'Concierge';
|
|
subEl.textContent = 'Voice and chat';
|
|
if (config.maxMessageLength) inputEl.setAttribute('maxlength', String(config.maxMessageLength));
|
|
if (config.accentColor) {
|
|
document.documentElement.style.setProperty('--eba-accent', String(config.accentColor));
|
|
}
|
|
let resumed = false;
|
|
try {
|
|
resumed = await maybeResume();
|
|
} catch {
|
|
resumed = false;
|
|
}
|
|
if (!resumed && config.greeting) pendingGreeting = String(config.greeting);
|
|
if (!resumed && Array.isArray(config.quickReplies)) pendingQuickReplies = config.quickReplies;
|
|
chatReady = true;
|
|
if (!config.voiceEnabled) {
|
|
throw new Error('Voice is not enabled for this chatbot');
|
|
}
|
|
videoAvatarOn = Boolean(config.videoAvatarEnabled || config.avatarEnabled);
|
|
applyStageMode();
|
|
}
|
|
|
|
async function startSession() {
|
|
if (started) return;
|
|
started = true;
|
|
hideStartOverlay();
|
|
setError('');
|
|
setStatus('Connecting…');
|
|
|
|
if (!botId || !apiBase) {
|
|
throw new Error('Missing botId or apiBase');
|
|
}
|
|
if (!Livekit || !Livekit.Room) {
|
|
throw new Error('Voice client failed to load');
|
|
}
|
|
|
|
await loadChat();
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
throw new Error('This browser cannot access the microphone');
|
|
}
|
|
const preview = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
preview.getTracks().forEach((t) => t.stop());
|
|
|
|
const granted = await api(`/public/concierge/${encodeURIComponent(botId)}/voice/token`, {
|
|
method: 'POST',
|
|
body: { sessionId: sessionId || undefined },
|
|
});
|
|
sessionId = granted.sessionId || sessionId;
|
|
|
|
sessionAlive = true;
|
|
if (!videoAvatarOn) startWaveLoop();
|
|
|
|
const room = new Livekit.Room({
|
|
adaptiveStream: false,
|
|
dynacast: false,
|
|
audioCaptureDefaults: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
});
|
|
livekitRoom = room;
|
|
|
|
room.on(Livekit.RoomEvent.Disconnected, () => {
|
|
if (livekitRoom === room) {
|
|
livekitRoom = null;
|
|
sessionAlive = false;
|
|
setStatus('Disconnected');
|
|
}
|
|
});
|
|
room.on(Livekit.RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
|
handleIncomingAudio(track, participant, room);
|
|
});
|
|
if (Livekit.RoomEvent.LocalTrackPublished) {
|
|
room.on(Livekit.RoomEvent.LocalTrackPublished, (pub) => {
|
|
const media = mediaTrackFrom(pub?.track);
|
|
if (media) hookWaveAnalyser(media, 'user');
|
|
});
|
|
}
|
|
if (Livekit.RoomEvent.DataReceived) {
|
|
room.on(Livekit.RoomEvent.DataReceived, (payload, participant) => {
|
|
if (isLocalParticipant(room, participant)) return;
|
|
const state = parseAgentStatePayload(payload);
|
|
if (state) applyAgentState(state);
|
|
});
|
|
}
|
|
if (Livekit.RoomEvent.ParticipantAttributesChanged) {
|
|
room.on(Livekit.RoomEvent.ParticipantAttributesChanged, (_changed, participant) => {
|
|
if (isLocalParticipant(room, participant)) return;
|
|
const state = participant?.attributes?.[AGENT_STATE_ATTR];
|
|
if (state) applyAgentState(state);
|
|
});
|
|
}
|
|
room.on(Livekit.RoomEvent.TranscriptionReceived, (segments, participant) => {
|
|
const local = isLocalParticipant(room, participant);
|
|
(segments || []).forEach((seg) => {
|
|
if (!seg || !seg.final) return;
|
|
if (agentPipelineState === 'speaking' && local) return;
|
|
ensureSimliConnected(local ? 'user-speech' : 'agent-speech');
|
|
if (rememberTranscript(seg.id || `${local ? 'user' : 'bot'}:${seg.text}`)) return;
|
|
if (!local && pendingGreeting && normalizeSpeech(seg.text) === normalizeSpeech(pendingGreeting)) {
|
|
showPendingGreeting();
|
|
return;
|
|
}
|
|
addMessage(local ? 'user' : 'bot', seg.text);
|
|
});
|
|
});
|
|
room.on(Livekit.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
if ((speakers || []).length) ensureSimliConnected('speakers');
|
|
if (simliIdle || simliRestarting || agentPipelineState === 'speaking' || agentPipelineState === 'thinking') {
|
|
return;
|
|
}
|
|
if (videoAvatarOn) return;
|
|
const localId = room.localParticipant?.identity;
|
|
const agentSpeaking = (speakers || []).some((p) => p?.identity && p.identity !== localId);
|
|
setStatus(agentSpeaking ? 'Speaking' : 'Listening');
|
|
});
|
|
|
|
await room.connect(granted.url, granted.token);
|
|
if (typeof room.startAudio === 'function') {
|
|
await room.startAudio().catch(() => {});
|
|
}
|
|
room.remoteParticipants.forEach((participant) => {
|
|
participant.trackPublications.forEach((pub) => {
|
|
if (pub?.track) handleIncomingAudio(pub.track, participant, room);
|
|
});
|
|
});
|
|
|
|
let micPub = null;
|
|
if (typeof room.localParticipant.setMicrophoneEnabled === 'function') {
|
|
micPub = await room.localParticipant.setMicrophoneEnabled(true);
|
|
}
|
|
if (!micPub && typeof Livekit.createLocalAudioTrack === 'function') {
|
|
const micTrack = await Livekit.createLocalAudioTrack({
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
});
|
|
micPub = await room.localParticipant.publishTrack(micTrack);
|
|
}
|
|
if (!micPub) throw new Error('Microphone did not publish. Allow mic access and try again.');
|
|
micPublished = true;
|
|
micListening = true;
|
|
const localMedia = mediaTrackFrom(micPub.track);
|
|
if (localMedia) hookWaveAnalyser(localMedia, 'user');
|
|
room.remoteParticipants.forEach((participant) => {
|
|
const state = participant?.attributes?.[AGENT_STATE_ATTR];
|
|
if (state && !isLocalParticipant(room, participant)) applyAgentState(state);
|
|
});
|
|
if (!videoAvatarOn) {
|
|
const ctx = ensureWaveAudioCtx();
|
|
if (ctx && ctx.state === 'suspended') await ctx.resume().catch(() => {});
|
|
setTimeout(() => signalAvatarReady(room), 400);
|
|
setStatus('Listening');
|
|
return;
|
|
}
|
|
setStatus('Connecting avatar…');
|
|
try {
|
|
await Promise.race([
|
|
startSimliOverlay(),
|
|
new Promise((resolve) => setTimeout(resolve, 2500)),
|
|
]);
|
|
} catch (err) {
|
|
console.warn('[avatar] simli overlay', err);
|
|
}
|
|
await signalAvatarReady(room);
|
|
applyAgentState(readRemoteAgentState(room) || agentPipelineState);
|
|
}
|
|
|
|
function needsGesture(err) {
|
|
const msg = String(err?.name || err?.message || err || '').toLowerCase();
|
|
return /notallowed|permission|denied|gesture|autoplay|aborterror|notallowederror|securityerror/.test(
|
|
msg,
|
|
);
|
|
}
|
|
|
|
async function boot() {
|
|
try {
|
|
await startSession();
|
|
} catch (err) {
|
|
started = false;
|
|
console.warn('[avatar] start failed', err);
|
|
await teardownMedia();
|
|
const message = err.message || 'Could not start';
|
|
setError(message);
|
|
setStatus('Waiting');
|
|
showStartOverlay(needsGesture(err) ? 'Tap to start' : 'Tap to retry', message);
|
|
}
|
|
}
|
|
|
|
formEl.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
});
|
|
|
|
startBtn.addEventListener('click', () => {
|
|
hideStartOverlay();
|
|
boot();
|
|
});
|
|
|
|
if (!botId || !apiBase) {
|
|
setError('Missing window.EB_CONCIERGE.botId or apiBase');
|
|
setStatus('Not configured');
|
|
showStartOverlay('Not configured', 'Set botId and apiBase on this page.');
|
|
} else {
|
|
boot();
|
|
}
|