Add frontend: call queue, active call indicator, three-party chat, three-way calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 13:46:19 -07:00
parent 8dc1d62487
commit db134262fb
3 changed files with 263 additions and 6 deletions

View File

@@ -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; }

View File

@@ -21,11 +21,44 @@
<section class="callers-section">
<h2>Callers <span id="session-id" class="session-id"></span></h2>
<div id="callers" class="caller-grid"></div>
<!-- Active Call Indicator -->
<div id="active-call" class="active-call hidden">
<div id="real-caller-info" class="caller-info hidden">
<span class="caller-type real">LIVE</span>
<span id="real-caller-name"></span>
<span id="real-caller-channel" class="channel-badge"></span>
<span id="real-caller-duration" class="call-duration"></span>
<button id="hangup-real-btn" class="hangup-btn small">Hang Up</button>
</div>
<div id="ai-caller-info" class="caller-info hidden">
<span class="caller-type ai">AI</span>
<span id="ai-caller-name"></span>
<div class="ai-controls">
<div class="mode-toggle">
<button id="mode-manual" class="mode-btn active">Manual</button>
<button id="mode-auto" class="mode-btn">Auto</button>
</div>
<button id="ai-respond-btn" class="respond-btn">Let them respond</button>
</div>
<button id="hangup-ai-btn" class="hangup-btn small">Hang Up</button>
</div>
<label class="auto-followup-label">
<input type="checkbox" id="auto-followup"> Auto Follow-Up
</label>
</div>
<div id="call-status" class="call-status">No active call</div>
<div id="caller-background" class="caller-background hidden"></div>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
</section>
<!-- Call Queue -->
<section class="queue-section">
<h2>Incoming Calls</h2>
<div id="call-queue" class="call-queue">
<div class="queue-empty">No callers waiting</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
@@ -173,6 +206,6 @@
</div>
</div>
<script src="/js/app.js?v=8"></script>
<script src="/js/app.js?v=9"></script>
</body>
</html>

View File

@@ -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 = `<strong>${sender}:</strong> ${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 = '<div class="queue-empty">No callers waiting</div>';
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 `
<div class="queue-item">
<span class="queue-phone">${caller.phone}</span>
<span class="queue-wait">waiting ${waitStr}</span>
<button class="queue-take-btn" onclick="takeCall('${caller.call_sid}')">Take Call</button>
<button class="queue-drop-btn" onclick="dropCall('${caller.call_sid}')">Drop</button>
</div>
`;
}).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;