← back to Butlr
views/public/call-live.ejs
322 lines
<%- include('../partials/head', { title, meta_desc }) %>
<%- include('../partials/header') %>
<main class="live-call-page">
<section class="section">
<div class="wrap">
<p class="muted small"><a href="/calls">← All calls</a></p>
<div class="live-grid">
<!-- LEFT: Live status card -->
<article class="live-status-card">
<header class="live-status-head">
<h1 id="live-business"><%= call.business_name %></h1>
<span class="status status-<%= call.status %>" id="live-status-pill"><%= call.status %></span>
</header>
<p class="muted small">
Calling <span id="live-phone"><%= call.business_phone %></span> · we'll ring you at <strong><%= call.callback_phone %></strong> when an agent picks up
</p>
<div class="live-timeline">
<div class="live-stage" data-stage="queued"><span class="live-stage-dot"></span> Queued</div>
<div class="live-stage" data-stage="dialing"><span class="live-stage-dot"></span> Dialing</div>
<div class="live-stage" data-stage="on_hold"><span class="live-stage-dot"></span> On hold</div>
<div class="live-stage" data-stage="connected"><span class="live-stage-dot"></span> Connected — bridging you</div>
<div class="live-stage" data-stage="done"><span class="live-stage-dot"></span> Done</div>
</div>
<div class="live-elapsed">
Elapsed: <span id="live-elapsed">0s</span>
</div>
<section class="live-goal-block">
<h3>Your goal</h3>
<p class="prose"><%= call.goal %></p>
</section>
<!-- LISTEN LIVE button — gesture-policy unlock for Web Audio -->
<div id="listen-live-wrap" class="live-actions" style="margin-bottom:.75rem">
<button id="listen-live-btn" class="btn ghost" style="font-size:1.05rem;padding:1rem 1.4rem;border:2px solid var(--accent,#1a56db);color:var(--accent,#1a56db)">
🔊 Listen live
</button>
</div>
<div id="listen-banner" style="display:none;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;padding:.6rem 1rem;margin-bottom:.75rem;font-size:.92rem;font-weight:600;color:#92400e">
Call is dialing — click <strong>🔊 Listen live</strong> to hear it live
</div>
<div id="listen-status-bar" style="display:none;font-size:.78rem;color:var(--ink-dim,#6b7280);margin-bottom:.75rem;font-family:monospace">
<span id="listen-ws-state">disconnected</span> · <span id="listen-frame-count">0 frames</span>
</div>
<div class="live-actions">
<button id="bridge-now" class="btn primary" style="font-size:1.05rem;padding:1rem 1.4rem">
📞 Bridge me NOW — connect my phone
</button>
</div>
<p class="muted small" style="margin-top:.5rem">
Click <strong>🔊 Listen live</strong> first to open the audio stream. The moment you hear a real human, click "Bridge me now" — your phone rings in 2 seconds and you're connected. Use this when AMD didn't auto-detect the human.
</p>
<div class="live-actions" style="margin-top:1rem">
<a class="btn ghost" href="/calls/<%= call.id %>">Detail view</a>
<a class="btn ghost" href="/calls/<%= call.id %>?reveal=1">Reveal account info</a>
</div>
</article>
<!-- RIGHT: Butlr AI chat (qwen3:14b via Ollama, local) -->
<aside class="live-chat-card">
<header class="live-chat-head">
<span class="brand-mark">🎩</span>
<div>
<strong>Butlr Assistant</strong>
<span class="muted small">Chat while we wait on hold</span>
</div>
</header>
<div class="live-chat-messages" id="chat-messages">
<div class="chat-msg butlr">
<strong>Butlr</strong>
<p>Hi <%= call.callback_name || 'there' %>. I'm on the line with <%= call.business_name %> right now. Status: <strong><%= call.status %></strong>. While we wait, tell me anything else you want me to mention to the rep — or just ask me what's happening.</p>
</div>
</div>
<form id="chat-form" class="live-chat-form">
<textarea id="chat-input" rows="2" placeholder="Ask anything, or add a note for the rep…" maxlength="800" required></textarea>
<button type="submit" class="btn primary" id="chat-send">Send</button>
</form>
</aside>
</div>
</div>
</section>
</main>
<%- include('../partials/footer') %>
<style>
@keyframes listen-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(245,158,11,.6); border-color: #f59e0b; color: #92400e; }
50% { box-shadow: 0 0 0 8px rgba(245,158,11,0); border-color: #d97706; color: #78350f; }
}
#listen-live-btn.pulse {
animation: listen-pulse 1.2s ease-in-out infinite;
}
</style>
<script>
(function() {
const callId = <%- JSON.stringify(call.id) %>;
const createdAt = new Date(<%- JSON.stringify(call.created_at) %>).getTime();
const STAGE_ORDER = ['queued','dialing','on_hold','connected','done'];
const statusPill = document.getElementById('live-status-pill');
const stages = Array.from(document.querySelectorAll('.live-stage'));
const elapsedEl = document.getElementById('live-elapsed');
function fmtElapsed(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
return Math.floor(s/60) + 'm ' + (s%60) + 's';
}
function updateStages(status) {
const idx = STAGE_ORDER.indexOf(status);
stages.forEach((el, i) => {
el.classList.toggle('done', i < idx);
el.classList.toggle('current', i === idx);
});
}
setInterval(() => { elapsedEl.textContent = fmtElapsed(Date.now() - createdAt); }, 1000);
let lastStatus = <%- JSON.stringify(call.status) %>;
updateStages(lastStatus);
async function poll() {
try {
const r = await fetch('/api/calls/' + callId + '/status', { cache: 'no-store' });
const j = await r.json();
if (!j.ok) return;
if (j.status !== lastStatus) {
lastStatus = j.status;
statusPill.textContent = j.status;
statusPill.className = 'status status-' + j.status;
updateStages(j.status);
// System message in chat
const msgEl = document.createElement('div');
msgEl.className = 'chat-msg system';
msgEl.innerHTML = '<em>Status changed → <strong>' + j.status.replace(/_/g,' ') + '</strong></em>';
document.getElementById('chat-messages').appendChild(msgEl);
msgEl.scrollIntoView({behavior:'smooth'});
// If connected, alert + bigger visual
if (j.status === 'connected') {
document.title = '📞 PICK UP — ' + j.business_name;
// Only beep if the user has already unlocked audio via "🔊 Listen live".
// Creating a fresh AudioContext from a poll callback (no gesture) gets
// silently auto-suspended by Chrome — guard so we don't burn cycles.
if (window.__butlrAudioCtx) {
try {
const ctx = window.__butlrAudioCtx;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 880; gain.gain.value = .25;
osc.start(); osc.stop(ctx.currentTime + 0.4);
} catch (_) {}
}
}
}
} catch (e) {}
}
setInterval(poll, 2000);
poll(); // immediate
// ── AI Chat ──────────────────────────────────────────────────────
const form = document.getElementById('chat-form');
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send');
const msgs = document.getElementById('chat-messages');
function appendMsg(who, text) {
const el = document.createElement('div');
el.className = 'chat-msg ' + who;
const strong = document.createElement('strong');
strong.textContent = who === 'you' ? 'You' : 'Butlr';
const p = document.createElement('p');
p.textContent = text;
el.appendChild(strong); el.appendChild(p);
msgs.appendChild(el);
el.scrollIntoView({behavior:'smooth'});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
appendMsg('you', text);
input.value = '';
sendBtn.disabled = true;
sendBtn.textContent = 'Thinking…';
try {
const r = await fetch('/api/calls/' + callId + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const j = await r.json();
appendMsg('butlr', j.reply || '(no reply)');
} catch (e) {
appendMsg('butlr', '(AI offline)');
} finally {
sendBtn.disabled = false;
sendBtn.textContent = 'Send';
input.focus();
}
});
input.focus();
// ── Listen-live audio (inline, gesture-unlocked) ──────────────────
(function() {
const SAMPLE_RATE = 8000;
const listenBtn = document.getElementById('listen-live-btn');
const banner = document.getElementById('listen-banner');
const statusBar = document.getElementById('listen-status-bar');
const wsStateEl = document.getElementById('listen-ws-state');
const frameEl = document.getElementById('listen-frame-count');
let audioCtx = null;
let ws = null;
let frameCount = 0;
let nextPlayTime = 0;
let listening = false;
function connect() {
listening = true;
banner.style.display = 'none';
statusBar.style.display = 'block';
listenBtn.textContent = '🔇 Stop listening';
listenBtn.classList.remove('pulse');
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
window.__butlrAudioCtx = audioCtx; // unlocks the "connected" beep below
nextPlayTime = audioCtx.currentTime + 0.1;
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${scheme}//${location.host}/listen-ws/${callId}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => { wsStateEl.textContent = 'connected · waiting for audio'; };
ws.onclose = () => { wsStateEl.textContent = 'disconnected'; if (listening) { listening = false; listenBtn.textContent = '🔊 Listen live'; } };
ws.onerror = () => { wsStateEl.textContent = 'error'; };
ws.onmessage = (ev) => {
// Server sends PCM16 LE @ 8 kHz mono (same as listen.ejs)
const pcm16 = new Int16Array(ev.data);
const float32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) float32[i] = pcm16[i] / 32768;
const buf = audioCtx.createBuffer(1, float32.length, SAMPLE_RATE);
buf.copyToChannel(float32, 0);
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
src.start(startAt);
nextPlayTime = startAt + buf.duration;
frameCount++;
if (frameCount % 25 === 0) frameEl.textContent = frameCount + ' frames';
if (wsStateEl.textContent !== 'streaming') wsStateEl.textContent = 'streaming';
};
}
function disconnectAudio() {
listening = false;
if (ws) { ws.close(); ws = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; window.__butlrAudioCtx = null; }
listenBtn.textContent = '🔊 Listen live';
statusBar.style.display = 'none';
frameCount = 0;
}
listenBtn.addEventListener('click', () => {
if (listening) disconnectAudio(); else connect();
});
// When status hits dialing/on_hold, pulse the button and show the banner
// The existing poll() updates `lastStatus` — we hook in via a MutationObserver
// on the status pill which is already updated by poll().
const pill = document.getElementById('live-status-pill');
if (pill) {
const obs = new MutationObserver(() => {
const s = pill.textContent.trim();
if ((s === 'dialing' || s === 'on_hold') && !listening) {
listenBtn.classList.add('pulse');
banner.style.display = 'block';
}
// Auto-close WSS when call ends
if ((s === 'done' || s === 'failed') && listening) {
disconnectAudio();
}
});
obs.observe(pill, { childList: true, characterData: true, subtree: true });
}
})();
// ── Manual bridge button ──────────────────────────────────────────
const bridgeBtn = document.getElementById('bridge-now');
if (bridgeBtn) {
bridgeBtn.addEventListener('click', async () => {
if (!confirm('Bridge your phone to the call NOW? Your phone will ring within 2 seconds.')) return;
bridgeBtn.disabled = true;
bridgeBtn.textContent = '📞 Bridging…';
try {
const r = await fetch('/twilio/bridge/' + callId, { method: 'POST' });
const j = await r.json();
if (j.ok) {
bridgeBtn.textContent = '✓ Bridge fired — answer your phone';
const sysMsg = document.createElement('div');
sysMsg.className = 'chat-msg system';
sysMsg.innerHTML = '<em>Bridge fired. Your phone (' + <%- JSON.stringify(call.callback_phone) %> + ') should ring in ~2 seconds.</em>';
document.getElementById('chat-messages').appendChild(sysMsg);
} else {
bridgeBtn.textContent = '✗ Bridge failed — retry';
bridgeBtn.disabled = false;
alert('Bridge failed: ' + (j.error || 'unknown'));
}
} catch (e) {
bridgeBtn.textContent = '✗ Bridge error — retry';
bridgeBtn.disabled = false;
}
});
}
})();
</script>