From db134262fb4bfc7f2cf8bf97b40369eddce3cb68 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Thu, 5 Feb 2026 13:46:19 -0700 Subject: [PATCH] Add frontend: call queue, active call indicator, three-party chat, three-way calls Co-Authored-By: Claude Opus 4.6 --- frontend/css/style.css | 35 ++++++++ frontend/index.html | 35 +++++++- frontend/js/app.js | 199 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 263 insertions(+), 6 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index c81e964..bc2d783 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -541,3 +541,38 @@ section h2 { .server-log .log-line.chat { color: #f8f; } + +/* Call Queue */ +.queue-section { margin: 1rem 0; } +.call-queue { border: 1px solid #333; border-radius: 4px; padding: 0.5rem; max-height: 150px; overflow-y: auto; } +.queue-empty { color: #666; text-align: center; padding: 0.5rem; } +.queue-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid #222; } +.queue-item:last-child { border-bottom: none; } +.queue-phone { font-family: monospace; color: #4fc3f7; } +.queue-wait { color: #999; font-size: 0.85rem; flex: 1; } +.queue-take-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; cursor: pointer; } +.queue-take-btn:hover { background: #388e3c; } +.queue-drop-btn { background: #c62828; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 3px; cursor: pointer; } +.queue-drop-btn:hover { background: #d32f2f; } + +/* Active Call Indicator */ +.active-call { border: 1px solid #444; border-radius: 4px; padding: 0.75rem; margin: 0.5rem 0; background: #1a1a2e; } +.caller-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.caller-info:last-of-type { margin-bottom: 0; } +.caller-type { font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: 3px; text-transform: uppercase; } +.caller-type.real { background: #c62828; color: white; } +.caller-type.ai { background: #1565c0; color: white; } +.channel-badge { font-size: 0.75rem; color: #999; background: #222; padding: 0.1rem 0.4rem; border-radius: 3px; } +.call-duration { font-family: monospace; color: #4fc3f7; } +.ai-controls { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; } +.mode-toggle { display: flex; border: 1px solid #444; border-radius: 3px; overflow: hidden; } +.mode-btn { background: #222; color: #999; border: none; padding: 0.2rem 0.5rem; font-size: 0.75rem; cursor: pointer; } +.mode-btn.active { background: #1565c0; color: white; } +.respond-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; font-size: 0.8rem; cursor: pointer; } +.hangup-btn.small { font-size: 0.75rem; padding: 0.2rem 0.5rem; } +.auto-followup-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: #999; margin-top: 0.5rem; } + +/* Three-Party Chat */ +.message.real-caller { border-left: 3px solid #c62828; padding-left: 0.5rem; } +.message.ai-caller { border-left: 3px solid #1565c0; padding-left: 0.5rem; } +.message.host { border-left: 3px solid #2e7d32; padding-left: 0.5rem; } diff --git a/frontend/index.html b/frontend/index.html index 6050acb..9486d56 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,11 +21,44 @@

Callers

+ +
No active call
+ +
+

Incoming Calls

+
+
No callers waiting
+
+
+
@@ -173,6 +206,6 @@ - + diff --git a/frontend/js/app.js b/frontend/js/app.js index 8a581e0..475a5ac 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -52,6 +52,9 @@ function initEventListeners() { // Start log polling startLogPolling(); + // Start queue polling + startQueuePolling(); + // Talk button - now triggers server-side recording const talkBtn = document.getElementById('talk-btn'); if (talkBtn) { @@ -97,6 +100,45 @@ function initEventListeners() { phoneFilter = e.target.checked; }); document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels); + + // Real caller hangup + document.getElementById('hangup-real-btn')?.addEventListener('click', async () => { + await fetch('/api/hangup/real', { method: 'POST' }); + hideRealCaller(); + log('Real caller disconnected'); + }); + + // AI respond mode toggle + document.getElementById('mode-manual')?.addEventListener('click', () => { + document.getElementById('mode-manual')?.classList.add('active'); + document.getElementById('mode-auto')?.classList.remove('active'); + document.getElementById('ai-respond-btn')?.classList.remove('hidden'); + fetch('/api/session/ai-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'manual' }), + }); + }); + + document.getElementById('mode-auto')?.addEventListener('click', () => { + document.getElementById('mode-auto')?.classList.add('active'); + document.getElementById('mode-manual')?.classList.remove('active'); + document.getElementById('ai-respond-btn')?.classList.add('hidden'); + fetch('/api/session/ai-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'auto' }), + }); + }); + + // Auto follow-up toggle + document.getElementById('auto-followup')?.addEventListener('change', (e) => { + fetch('/api/session/auto-followup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: e.target.checked }), + }); + }); } @@ -273,9 +315,24 @@ async function startCall(key, name) { currentCaller = { key, name }; - document.getElementById('call-status').textContent = `On call: ${name}`; + // Check if real caller is active (three-way scenario) + const realCallerActive = document.getElementById('real-caller-info') && + !document.getElementById('real-caller-info').classList.contains('hidden'); + + if (realCallerActive) { + document.getElementById('call-status').textContent = `Three-way: ${name} (AI) + Real Caller`; + } else { + document.getElementById('call-status').textContent = `On call: ${name}`; + } + document.getElementById('hangup-btn').disabled = false; + // Show AI caller in active call indicator + const aiInfo = document.getElementById('ai-caller-info'); + const aiName = document.getElementById('ai-caller-name'); + if (aiInfo) aiInfo.classList.remove('hidden'); + if (aiName) aiName.textContent = name; + // Show caller background const bgEl = document.getElementById('caller-background'); if (bgEl && data.background) { @@ -287,8 +344,10 @@ async function startCall(key, name) { btn.classList.toggle('active', btn.dataset.key === key); }); - log(`Connected to ${name}`); - clearChat(); + log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : '')); + if (!realCallerActive) clearChat(); + + updateActiveCallIndicator(); } @@ -314,7 +373,6 @@ async function newSession() { async function hangup() { if (!currentCaller) return; - // Stop any playing TTS await fetch('/api/tts/stop', { method: 'POST' }); await fetch('/api/hangup', { method: 'POST' }); @@ -331,6 +389,10 @@ async function hangup() { // Hide caller background const bgEl = document.getElementById('caller-background'); if (bgEl) bgEl.classList.add('hidden'); + + // Hide AI caller indicator + document.getElementById('ai-caller-info')?.classList.add('hidden'); + updateActiveCallIndicator(); } @@ -647,7 +709,19 @@ function addMessage(sender, text) { return; } const div = document.createElement('div'); - div.className = `message ${sender === 'You' ? 'host' : 'caller'}`; + + let className = 'message'; + if (sender === 'You') { + className += ' host'; + } else if (sender === 'System') { + className += ' system'; + } else if (sender.includes('(caller)') || sender.includes('Caller #')) { + className += ' real-caller'; + } else { + className += ' ai-caller'; + } + + div.className = className; div.innerHTML = `${sender}: ${text}`; chat.appendChild(div); chat.scrollTop = chat.scrollHeight; @@ -769,6 +843,121 @@ async function restartServer() { } +// --- Call Queue --- +let queuePollInterval = null; + +function startQueuePolling() { + queuePollInterval = setInterval(fetchQueue, 3000); + fetchQueue(); +} + +async function fetchQueue() { + try { + const res = await fetch('/api/queue'); + const data = await res.json(); + renderQueue(data.queue); + } catch (err) {} +} + +function renderQueue(queue) { + const el = document.getElementById('call-queue'); + if (!el) return; + + if (queue.length === 0) { + el.innerHTML = '
No callers waiting
'; + return; + } + + el.innerHTML = queue.map(caller => { + const mins = Math.floor(caller.wait_time / 60); + const secs = caller.wait_time % 60; + const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + return ` +
+ ${caller.phone} + waiting ${waitStr} + + +
+ `; + }).join(''); +} + +async function takeCall(callSid) { + try { + const res = await fetch(`/api/queue/take/${callSid}`, { method: 'POST' }); + const data = await res.json(); + if (data.status === 'on_air') { + showRealCaller(data.caller); + log(`${data.caller.name} (${data.caller.phone}) is on air — Channel ${data.caller.channel}`); + } + } catch (err) { + log('Failed to take call: ' + err.message); + } +} + +async function dropCall(callSid) { + try { + await fetch(`/api/queue/drop/${callSid}`, { method: 'POST' }); + fetchQueue(); + } catch (err) { + log('Failed to drop call: ' + err.message); + } +} + + +// --- Active Call Indicator --- +let realCallerTimer = null; +let realCallerStartTime = null; + +function updateActiveCallIndicator() { + const container = document.getElementById('active-call'); + const realInfo = document.getElementById('real-caller-info'); + const aiInfo = document.getElementById('ai-caller-info'); + const statusEl = document.getElementById('call-status'); + + const hasReal = realInfo && !realInfo.classList.contains('hidden'); + const hasAi = aiInfo && !aiInfo.classList.contains('hidden'); + + if (hasReal || hasAi) { + container?.classList.remove('hidden'); + statusEl?.classList.add('hidden'); + } else { + container?.classList.add('hidden'); + statusEl?.classList.remove('hidden'); + if (statusEl) statusEl.textContent = 'No active call'; + } +} + +function showRealCaller(callerInfo) { + const nameEl = document.getElementById('real-caller-name'); + const chEl = document.getElementById('real-caller-channel'); + if (nameEl) nameEl.textContent = `${callerInfo.name} (${callerInfo.phone})`; + if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`; + + document.getElementById('real-caller-info')?.classList.remove('hidden'); + realCallerStartTime = Date.now(); + + if (realCallerTimer) clearInterval(realCallerTimer); + realCallerTimer = setInterval(() => { + const elapsed = Math.floor((Date.now() - realCallerStartTime) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const durEl = document.getElementById('real-caller-duration'); + if (durEl) durEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; + }, 1000); + + updateActiveCallIndicator(); +} + +function hideRealCaller() { + document.getElementById('real-caller-info')?.classList.add('hidden'); + if (realCallerTimer) clearInterval(realCallerTimer); + realCallerTimer = null; + updateActiveCallIndicator(); +} + + async function stopServer() { if (!confirm('Stop the server? You will need to restart it manually.')) return;