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