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:
@@ -541,3 +541,38 @@ section h2 {
|
|||||||
.server-log .log-line.chat {
|
.server-log .log-line.chat {
|
||||||
color: #f8f;
|
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; }
|
||||||
|
|||||||
@@ -21,11 +21,44 @@
|
|||||||
<section class="callers-section">
|
<section class="callers-section">
|
||||||
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
||||||
<div id="callers" class="caller-grid"></div>
|
<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="call-status" class="call-status">No active call</div>
|
||||||
<div id="caller-background" class="caller-background hidden"></div>
|
<div id="caller-background" class="caller-background hidden"></div>
|
||||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
||||||
</section>
|
</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 -->
|
<!-- Chat -->
|
||||||
<section class="chat-section">
|
<section class="chat-section">
|
||||||
<div id="chat" class="chat-log"></div>
|
<div id="chat" class="chat-log"></div>
|
||||||
@@ -173,6 +206,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=8"></script>
|
<script src="/js/app.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ function initEventListeners() {
|
|||||||
// Start log polling
|
// Start log polling
|
||||||
startLogPolling();
|
startLogPolling();
|
||||||
|
|
||||||
|
// Start queue polling
|
||||||
|
startQueuePolling();
|
||||||
|
|
||||||
// Talk button - now triggers server-side recording
|
// Talk button - now triggers server-side recording
|
||||||
const talkBtn = document.getElementById('talk-btn');
|
const talkBtn = document.getElementById('talk-btn');
|
||||||
if (talkBtn) {
|
if (talkBtn) {
|
||||||
@@ -97,6 +100,45 @@ function initEventListeners() {
|
|||||||
phoneFilter = e.target.checked;
|
phoneFilter = e.target.checked;
|
||||||
});
|
});
|
||||||
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
|
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 };
|
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;
|
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
|
// Show caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl && data.background) {
|
if (bgEl && data.background) {
|
||||||
@@ -287,8 +344,10 @@ async function startCall(key, name) {
|
|||||||
btn.classList.toggle('active', btn.dataset.key === key);
|
btn.classList.toggle('active', btn.dataset.key === key);
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`Connected to ${name}`);
|
log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : ''));
|
||||||
clearChat();
|
if (!realCallerActive) clearChat();
|
||||||
|
|
||||||
|
updateActiveCallIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +373,6 @@ async function newSession() {
|
|||||||
async function hangup() {
|
async function hangup() {
|
||||||
if (!currentCaller) return;
|
if (!currentCaller) return;
|
||||||
|
|
||||||
// Stop any playing TTS
|
|
||||||
await fetch('/api/tts/stop', { method: 'POST' });
|
await fetch('/api/tts/stop', { method: 'POST' });
|
||||||
await fetch('/api/hangup', { method: 'POST' });
|
await fetch('/api/hangup', { method: 'POST' });
|
||||||
|
|
||||||
@@ -331,6 +389,10 @@ async function hangup() {
|
|||||||
// Hide caller background
|
// Hide caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl) bgEl.classList.add('hidden');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const div = document.createElement('div');
|
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}`;
|
div.innerHTML = `<strong>${sender}:</strong> ${text}`;
|
||||||
chat.appendChild(div);
|
chat.appendChild(div);
|
||||||
chat.scrollTop = chat.scrollHeight;
|
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() {
|
async function stopServer() {
|
||||||
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user