call-voice-agent / src /main.ts
jashp2323's picture
revert: remove ringing audio feature and clean up oscillators
f6295f4
Raw History Blame Contribute Delete
28.9 kB
import './style.css';
interface Order {
id: string;
customer_name: string;
product_name: string;
estimated_delivery_date: string;
status: string;
}
interface Feedback {
id: number;
call_id: string;
order_id: string;
rating: number;
feedback_text: string;
category: string;
product_name?: string;
customer_name?: string;
agent_type?: string;
}
interface CallRecord {
id: string;
status: string;
}
const state: {
orders: Order[];
selectedOrderId: string | null;
selectedAgentType: string;
callActive: boolean;
sessionId: string | null;
ws: WebSocket | null;
agentSpeaking: boolean;
messages: { role: string; text: string; at: number }[];
feedback: Feedback[];
mediaStream: MediaStream | null;
audioContext: AudioContext | null;
processor: ScriptProcessorNode | null;
ringingActive: boolean;
ringingOrderId: string | null;
} = {
orders: [],
selectedOrderId: null,
selectedAgentType: 'order_confirmation',
callActive: false,
sessionId: null,
ws: null,
agentSpeaking: false,
messages: [],
feedback: [],
mediaStream: null,
audioContext: null,
processor: null,
ringingActive: false,
ringingOrderId: null,
};
let ringingTimeout: any = null;
let charts: any = {};
const $ = <T extends HTMLElement>(id: string): T => {
const el = document.getElementById(id);
if (!el) throw new Error(`Element ${id} not found`);
return el as T;
};
const ordersList = $<HTMLDivElement>('orders-list');
const orderCount = $<HTMLSpanElement>('order-count');
const selectedOrderInfo = $<HTMLDivElement>('selected-order-info');
const infoCustomer = $<HTMLSpanElement>('info-customer');
const infoOrderId = $<HTMLSpanElement>('info-order-id');
const infoProduct = $<HTMLSpanElement>('info-product');
const infoDelivery = $<HTMLSpanElement>('info-delivery');
const btnStartCall = $<HTMLButtonElement>('btn-start-call');
const btnEndCall = $<HTMLButtonElement>('btn-end-call');
const BACKEND_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? window.location.origin
: 'https://jashp2323-call-voice-agent.hf.space';
const BACKEND_HOST = BACKEND_URL.replace(/^https?:\/\//, '');
const WS_PROTOCOL = BACKEND_URL.startsWith('https:') ? 'wss:' : 'ws:';
const btnWhatsappFallback = $<HTMLButtonElement>('btn-whatsapp-fallback');
const callStatusBadge = $<HTMLDivElement>('call-status-badge');
const callStatusText = $<HTMLSpanElement>('call-status-text');
const micIndicator = $<HTMLDivElement>('mic-indicator');
const transcript = $<HTMLDivElement>('transcript');
const textInput = $<HTMLInputElement>('text-input');
const btnSend = $<HTMLButtonElement>('btn-send');
const serverStatus = $<HTMLSpanElement>('server-status');
const serverStatusText = $<HTMLSpanElement>('server-status-text');
const feedbackList = $<HTMLDivElement>('feedback-list');
const statTotalCalls = $<HTMLSpanElement>('stat-total-calls');
const statAvgRating = $<HTMLSpanElement>('stat-avg-rating');
const btnRefreshFeedback = $<HTMLButtonElement>('btn-refresh-feedback');
document.addEventListener('DOMContentLoaded', async () => {
document.querySelectorAll('.agent-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab as HTMLButtonElement;
selectAgentType(target.dataset.agent || 'order_confirmation');
});
});
btnStartCall.addEventListener('click', startCall);
btnEndCall.addEventListener('click', endCall);
btnWhatsappFallback.addEventListener('click', triggerWhatsappFallback);
btnSend.addEventListener('click', sendTextMessage);
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendTextMessage();
});
btnRefreshFeedback.addEventListener('click', loadFeedback);
try {
const checkoutForm = $('checkout-form');
if (checkoutForm) {
checkoutForm.addEventListener('submit', handleCheckoutSubmit);
}
} catch (err) {}
try {
const navConsole = $('nav-console');
const navAnalytics = $('nav-analytics');
const panelCall = document.querySelector('.panel-call') as HTMLElement;
const panelFeedback = document.querySelector('.panel-feedback') as HTMLElement;
const panelAnalytics = $('panel-analytics');
const btnRefreshAnalytics = $('btn-refresh-analytics');
if (navConsole && navAnalytics && panelCall && panelFeedback && panelAnalytics) {
navConsole.addEventListener('click', () => {
navConsole.classList.add('active');
navAnalytics.classList.remove('active');
panelCall.classList.remove('hidden');
panelFeedback.classList.remove('hidden');
panelAnalytics.classList.add('hidden');
});
navAnalytics.addEventListener('click', () => {
navConsole.classList.remove('active');
navAnalytics.classList.add('active');
panelCall.classList.add('hidden');
panelFeedback.classList.add('hidden');
panelAnalytics.classList.remove('hidden');
loadAnalytics();
});
}
if (btnRefreshAnalytics) {
btnRefreshAnalytics.addEventListener('click', loadAnalytics);
}
} catch (err) {}
await checkServerStatus();
await loadOrders();
await loadFeedback();
connectDashboardWs();
});
async function checkServerStatus() {
try {
const res = await fetch(`${BACKEND_URL}/api/status`);
const data = await res.json();
if (data.ready) {
serverStatus.className = 'status-dot online';
serverStatusText.textContent = 'STT + TTS Ready';
} else {
serverStatus.className = 'status-dot offline';
serverStatusText.textContent = 'Models loading...';
}
} catch {
serverStatus.className = 'status-dot offline';
serverStatusText.textContent = 'Server offline';
}
}
async function loadOrders() {
try {
const res = await fetch(`${BACKEND_URL}/api/orders`);
state.orders = await res.json();
renderOrders();
} catch (e) {
ordersList.innerHTML = '<p class="feedback-empty">Failed to load orders. Is the server running?</p>';
}
}
function renderOrders() {
orderCount.textContent = String(state.orders.length);
if (state.orders.length === 0) {
ordersList.innerHTML = '<p class="feedback-empty">No orders found.</p>';
return;
}
ordersList.innerHTML = state.orders.map(order => {
const isSelected = state.selectedOrderId === order.id;
const statusLabel = order.status.replace(/_/g, ' ');
return `
<div class="order-card ${isSelected ? 'selected' : ''}"
onclick="window.selectOrder('${order.id}')"
data-order-id="${order.id}">
<div class="order-card-header">
<span class="order-id">${order.id}</span>
<span class="order-status-badge status-${order.status}">${statusLabel}</span>
</div>
<div class="order-customer">${escapeHtml(order.customer_name)}</div>
<div class="order-product">${escapeHtml(order.product_name)}</div>
<div class="order-delivery-date">
<i class="fa-regular fa-calendar"></i>
${order.estimated_delivery_date}
</div>
</div>
`;
}).join('');
}
function selectOrder(orderId: string) {
if (state.callActive) return;
state.selectedOrderId = orderId;
const order = state.orders.find(o => o.id === orderId);
if (!order) return;
renderOrders();
selectedOrderInfo.classList.remove('hidden');
infoCustomer.textContent = order.customer_name;
infoOrderId.textContent = order.id;
infoProduct.textContent = order.product_name;
infoDelivery.textContent = order.estimated_delivery_date;
let autoAgent = 'order_confirmation';
if (order.status === 'out_for_delivery') {
autoAgent = 'delivery_verification';
} else if (order.status === 'delivered') {
autoAgent = 'feedback_followup';
}
selectAgentType(autoAgent);
btnStartCall.disabled = false;
btnWhatsappFallback.disabled = false;
callStatusText.textContent = 'Ready to call';
}
(window as any).selectOrder = selectOrder;
function selectAgentType(agentType: string) {
if (state.callActive) return;
state.selectedAgentType = agentType;
document.querySelectorAll('.agent-tab').forEach(tab => {
const button = tab as HTMLButtonElement;
button.classList.toggle('active', button.dataset.agent === agentType);
});
}
async function startCall() {
if (state.ringingActive) {
if (ringingTimeout) {
clearTimeout(ringingTimeout);
ringingTimeout = null;
}
state.ringingActive = false;
btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Start Call';
btnStartCall.className = 'btn btn-call';
btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> End Call';
btnEndCall.className = 'btn btn-end';
}
if (!state.selectedOrderId || state.callActive) return;
state.callActive = true;
state.messages = [];
transcript.innerHTML = '';
btnStartCall.disabled = true;
btnEndCall.disabled = false;
btnWhatsappFallback.disabled = true;
textInput.disabled = false;
btnSend.disabled = false;
callStatusBadge.className = 'call-badge live';
callStatusText.textContent = 'Connecting...';
document.querySelectorAll<HTMLDivElement>('.order-card').forEach(c => c.style.pointerEvents = 'none');
document.querySelectorAll<HTMLButtonElement>('.agent-tab').forEach(t => t.style.pointerEvents = 'none');
state.ws = new WebSocket(`${WS_PROTOCOL}//${BACKEND_HOST}/ws/call`);
state.ws.onopen = () => {
callStatusText.textContent = 'In Call';
state.ws?.send(JSON.stringify({
type: 'start_call',
order_id: state.selectedOrderId,
agent_type: state.selectedAgentType,
}));
};
state.ws.onmessage = (event) => handleWsMessage(event);
state.ws.onclose = () => { if (state.callActive) endCall(); };
state.ws.onerror = () => {
addSystemMessage('Connection error. Please try again.');
endCall();
};
}
function endCall() {
if (state.ringingActive) {
if (ringingTimeout) {
clearTimeout(ringingTimeout);
ringingTimeout = null;
}
state.ringingActive = false;
handleNoAnswerFallback();
return;
}
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
try { state.ws.send(JSON.stringify({ type: 'end_call' })); } catch {}
}
if (state.ws) {
try { state.ws.close(); } catch {}
state.ws = null;
}
stopMicrophone();
state.callActive = false;
state.sessionId = null;
state.agentSpeaking = false;
btnStartCall.disabled = !state.selectedOrderId;
btnEndCall.disabled = true;
btnWhatsappFallback.disabled = !state.selectedOrderId;
textInput.disabled = true;
btnSend.disabled = true;
micIndicator.classList.add('hidden');
callStatusBadge.className = 'call-badge ended';
callStatusText.textContent = 'Call ended';
document.querySelectorAll<HTMLDivElement>('.order-card').forEach(c => c.style.pointerEvents = '');
document.querySelectorAll<HTMLButtonElement>('.agent-tab').forEach(t => t.style.pointerEvents = '');
setTimeout(loadFeedback, 500);
}
function handleWsMessage(event: MessageEvent) {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case 'call_started':
state.sessionId = msg.session_id;
callStatusText.textContent = `In Call — ${formatAgentType(msg.agent_type)}`;
addSystemMessage(`Call started with ${msg.customer_name} about ${msg.product_name}`);
break;
case 'agent_text':
addMessage('agent', msg.text);
break;
case 'user_text':
addMessage('user', msg.text);
break;
case 'tts_stream_start':
state.agentSpeaking = true;
micIndicator.classList.add('hidden');
break;
case 'agent_audio':
playAudioBase64(msg.data);
break;
case 'tts_stream_end':
state.agentSpeaking = false;
if (state.callActive) {
micIndicator.classList.remove('hidden');
startMicrophone();
}
break;
case 'tts_error':
state.agentSpeaking = false;
addSystemMessage('TTS error: ' + msg.error);
if (state.callActive) {
micIndicator.classList.remove('hidden');
startMicrophone();
}
break;
case 'call_ended':
const duration = msg.duration ? `${msg.duration}s` : '';
const feedback = msg.feedback || {};
let endMsg = `Call ended. Duration: ${duration}`;
if (feedback.rating) {
endMsg += ` | Rating: ${'★'.repeat(feedback.rating)}${'☆'.repeat(5 - feedback.rating)}`;
}
addSystemMessage(endMsg);
endCall();
break;
case 'error':
addSystemMessage('Error: ' + msg.message);
break;
}
}
function playAudioBase64(base64Data: string) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.onerror = () => URL.revokeObjectURL(url);
audio.play().catch(() => {});
}
async function startMicrophone() {
if (state.mediaStream) return;
if (!state.callActive || !state.ws) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
});
state.mediaStream = stream;
const audioCtx = new AudioContext({ sampleRate: 16000 });
state.audioContext = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
state.processor = processor;
processor.onaudioprocess = (e) => {
if (!state.callActive || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
if (state.agentSpeaking) return;
const float32 = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
state.ws.send(int16.buffer);
};
source.connect(processor);
const gain = audioCtx.createGain();
gain.gain.value = 0;
processor.connect(gain);
gain.connect(audioCtx.destination);
} catch (e) {
addSystemMessage('Microphone access denied. Use text input instead.');
}
}
function stopMicrophone() {
if (state.processor) {
try { state.processor.disconnect(); } catch {}
state.processor = null;
}
if (state.mediaStream) {
state.mediaStream.getTracks().forEach(t => t.stop());
state.mediaStream = null;
}
if (state.audioContext) {
try { state.audioContext.close(); } catch {}
state.audioContext = null;
}
}
function sendTextMessage() {
const text = textInput.value.trim();
if (!text || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
textInput.value = '';
state.ws.send(JSON.stringify({ type: 'text', data: text }));
}
function addMessage(role: string, text: string) {
state.messages.push({ role, text, at: Date.now() });
const empty = transcript.querySelector('.transcript-empty');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = `msg-bubble ${role}`;
const roleLabel = role === 'agent' ? 'Agent' : 'You';
div.innerHTML = `
<div class="msg-role">${roleLabel}</div>
<div class="msg-text">${escapeHtml(text)}</div>
`;
transcript.appendChild(div);
transcript.scrollTop = transcript.scrollHeight;
}
function addSystemMessage(text: string) {
const empty = transcript.querySelector('.transcript-empty');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = 'msg-bubble system';
div.innerHTML = `<div class="msg-text">${escapeHtml(text)}</div>`;
transcript.appendChild(div);
transcript.scrollTop = transcript.scrollHeight;
}
async function loadFeedback() {
try {
const [feedbackRes, callsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/feedback`),
fetch(`${BACKEND_URL}/api/calls`),
]);
const feedback: Feedback[] = await feedbackRes.json();
const calls: CallRecord[] = await callsRes.json();
state.feedback = feedback;
const completedCalls = calls.filter(c => c.status === 'completed');
statTotalCalls.textContent = String(completedCalls.length);
if (feedback.length > 0) {
const ratings = feedback.map(f => f.rating);
if (ratings.length > 0) {
const avg = (ratings.reduce((a, b) => a + b, 0) / ratings.length).toFixed(1);
statAvgRating.textContent = `${avg}★`;
} else {
statAvgRating.textContent = '—';
}
} else {
statAvgRating.textContent = '—';
}
renderFeedback(feedback);
} catch (e) {
console.error('Feedback load error:', e);
}
}
function renderFeedback(feedback: Feedback[]) {
if (feedback.length === 0) {
feedbackList.innerHTML = '<p class="feedback-empty">No feedback yet. Complete a call to see ratings here.</p>';
return;
}
feedbackList.innerHTML = feedback.map(fb => {
const stars = renderStars(fb.rating || 0);
const agentLabel = formatAgentType(fb.agent_type || fb.category);
const categoryClass = fb.category || fb.agent_type || '';
return `
<div class="feedback-card">
<div class="feedback-header">
<span class="feedback-agent-type ${categoryClass}">${agentLabel}</span>
<div class="feedback-stars">${stars}</div>
</div>
<div class="feedback-product">${escapeHtml(fb.product_name || '')}</div>
<div class="feedback-customer">${escapeHtml(fb.customer_name || '')} • ${fb.order_id}</div>
${fb.feedback_text ? `<div class="feedback-text">"${escapeHtml(fb.feedback_text)}"</div>` : ''}
</div>
`;
}).join('');
}
function renderStars(rating: number) {
let html = '';
for (let i = 1; i <= 5; i++) {
html += `<i class="fa-solid fa-star ${i <= rating ? 'filled' : ''}"></i>`;
}
return html;
}
function formatAgentType(type: string) {
const map: Record<string, string> = {
'order_confirmation': 'Order Confirm',
'delivery_verification': 'Delivery Verify',
'feedback_followup': 'Feedback',
'order': 'Order Confirm',
'delivery': 'Delivery Verify',
'followup': 'Feedback',
};
return map[type] || type;
}
function escapeHtml(str: string) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
async function triggerWhatsappFallback() {
if (!state.selectedOrderId) return;
const originalText = btnWhatsappFallback.innerHTML;
btnWhatsappFallback.disabled = true;
btnWhatsappFallback.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending...';
try {
let url = `${BACKEND_URL}/api/calls/whatsapp-fallback?order_id=${state.selectedOrderId}`;
if (state.sessionId) {
url = `${BACKEND_URL}/api/calls/whatsapp-fallback?call_id=${state.sessionId}`;
}
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (res.ok) {
addSystemMessage(`✅ WhatsApp fallback sent successfully!`);
alert(data.message || 'WhatsApp notification sent!');
} else {
addSystemMessage(`❌ WhatsApp fallback failed: ${data.error || 'Unknown error'}`);
alert(`Error: ${data.error || 'Failed to send WhatsApp message'}`);
}
} catch (err: any) {
addSystemMessage(`❌ Network error while sending WhatsApp fallback.`);
alert(`Network error: ${err.message}`);
} finally {
btnWhatsappFallback.disabled = false;
btnWhatsappFallback.innerHTML = originalText;
}
}
async function handleCheckoutSubmit(e: Event) {
e.preventDefault();
const name = (document.getElementById('checkout-name') as HTMLInputElement).value;
const phone = (document.getElementById('checkout-phone') as HTMLInputElement).value;
const email = (document.getElementById('checkout-email') as HTMLInputElement).value;
const product = (document.getElementById('checkout-product') as HTMLInputElement).value;
const btnSubmit = document.getElementById('btn-submit-checkout') as HTMLButtonElement;
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Processing...';
try {
const res = await fetch(`${BACKEND_URL}/api/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_name: name,
customer_phone: phone,
customer_email: email,
product_name: product
})
});
const data = await res.json();
if (res.ok) {
addSystemMessage(`🛒 Checkout complete! Order placed: ${data.order_id}`);
await loadOrders();
selectOrder(data.order_id);
} else {
alert(`Error: ${data.error || 'Failed to place order'}`);
}
} catch (err: any) {
alert(`Network Error: ${err.message}`);
} finally {
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<i class="fa-solid fa-cash-register"></i> Place Order';
}
}
function startRinging(orderId: string, agentType?: string) {
if (state.callActive) return;
if (ringingTimeout) {
clearTimeout(ringingTimeout);
ringingTimeout = null;
}
state.ringingActive = true;
state.ringingOrderId = orderId;
if (state.selectedOrderId !== orderId) {
selectOrder(orderId);
}
if (agentType) {
selectAgentType(agentType);
}
callStatusBadge.className = 'call-badge ringing';
callStatusText.innerHTML = '<span class="pulse-dot-ringing"></span> Incoming Call...';
addSystemMessage(`🔔 Incoming call... (Click 'Answer' to accept or 'Decline' to reject)`);
btnStartCall.disabled = false;
btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Answer';
btnStartCall.className = 'btn btn-call btn-green-pulse';
btnEndCall.disabled = false;
btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> Decline';
btnEndCall.className = 'btn btn-end';
ringingTimeout = setTimeout(() => {
handleNoAnswerFallback();
}, 15000);
}
async function handleNoAnswerFallback() {
state.ringingActive = false;
if (ringingTimeout) {
clearTimeout(ringingTimeout);
ringingTimeout = null;
}
btnStartCall.disabled = false;
btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Start Call';
btnStartCall.className = 'btn btn-call';
btnEndCall.disabled = true;
btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> End Call';
btnEndCall.className = 'btn btn-end';
callStatusBadge.className = 'call-badge ended';
callStatusText.textContent = 'Unanswered (Declined)';
addSystemMessage(`⚠️ Call missed. Progressing sequence...`);
try {
await fetch(`${BACKEND_URL}/api/calls/whatsapp-fallback?order_id=${state.ringingOrderId}`, { method: 'POST' });
} catch (err) {
console.error(err);
}
}
async function loadAnalytics() {
try {
const res = await fetch(`${BACKEND_URL}/api/analytics`);
const data = await res.json();
$('stat-total-calls-val').textContent = String(data.overview.total_calls);
$('stat-avg-rating-val').textContent = data.overview.avg_rating.toFixed(1);
$('stat-whatsapp-val').textContent = String(data.overview.whatsapp_fallbacks);
$('stat-completed-val').textContent = String(data.overview.completed_calls);
renderCharts(data);
} catch (err) {
console.error("Failed to load analytics data:", err);
}
}
function renderCharts(data: any) {
const Chart = (window as any).Chart;
if (!Chart) return;
// Destroy existing charts to reload cleanly
Object.keys(charts).forEach(key => {
if (charts[key]) charts[key].destroy();
});
// Chart 1: Call Outcomes (Doughnut)
const outcomesCtx = ($('chart-outcomes') as HTMLCanvasElement).getContext('2d');
if (outcomesCtx) {
const outcomeStats = data.status_distribution;
const completed = outcomeStats.completed || 0;
const missed = outcomeStats.whatsapp_fallback || 0;
const failed = outcomeStats.failed || 0;
const inProgress = outcomeStats.in_progress || 0;
charts.outcomes = new Chart(outcomesCtx, {
type: 'doughnut',
data: {
labels: ['Completed', 'Missed (WhatsApp)', 'Failed', 'In Progress'],
datasets: [{
data: [completed, missed, failed, inProgress],
backgroundColor: ['#10b981', '#f97316', '#ef4444', '#3b82f6'],
borderWidth: 1,
borderColor: '#1e293b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8' } }
}
}
});
}
// Chart 2: Daily Call Timeline (Line)
const timelineCanvas = $('chart-timeline') as HTMLCanvasElement;
if (timelineCanvas) {
const timelineCtx = timelineCanvas.getContext('2d');
if (timelineCtx) {
const labels = data.timeline.map((t: any) => t.date);
const counts = data.timeline.map((t: any) => t.count);
charts.timeline = new Chart(timelineCtx, {
type: 'line',
data: {
labels: labels.length ? labels : ['No Data'],
datasets: [{
label: 'Call Volume',
data: counts.length ? counts : [0],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } }
},
plugins: {
legend: { display: false }
}
}
});
}
}
// Chart 3: Satisfaction Rating Distribution (Bar)
const ratingsCanvas = $('chart-ratings') as HTMLCanvasElement;
if (ratingsCanvas) {
const ratingsCtx = ratingsCanvas.getContext('2d');
if (ratingsCtx) {
const ratingData = [1, 2, 3, 4, 5].map(star => data.rating_distribution[star] || 0);
charts.ratings = new Chart(ratingsCtx, {
type: 'bar',
data: {
labels: ['1 Star', '2 Stars', '3 Stars', '4 Stars', '5 Stars'],
datasets: [{
label: 'Reviews',
data: ratingData,
backgroundColor: '#10b981',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { display: false } },
y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } }
},
plugins: {
legend: { display: false }
}
}
});
}
}
// Chart 4: Sentiment Category Breakdown (Horizontal Bar)
const categoriesCanvas = $('chart-categories') as HTMLCanvasElement;
if (categoriesCanvas) {
const categoriesCtx = categoriesCanvas.getContext('2d');
if (categoriesCtx) {
const catLabels = Object.keys(data.category_distribution);
const catCounts = Object.values(data.category_distribution);
charts.categories = new Chart(categoriesCtx, {
type: 'bar',
data: {
labels: catLabels.length ? catLabels : ['None'],
datasets: [{
label: 'Comments',
data: catCounts.length ? catCounts : [0],
backgroundColor: '#a855f7',
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { ticks: { color: '#94a3b8' }, grid: { display: false } }
},
plugins: {
legend: { display: false }
}
}
});
}
}
}
function connectDashboardWs() {
const wsUrl = `${WS_PROTOCOL}//${BACKEND_HOST}/ws/dashboard`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (msg.type === 'incoming_call') {
startRinging(msg.order_id, msg.agent_type);
} else if (msg.type === 'new_order_placed') {
addSystemMessage(`🛒 Checkout complete! Order placed: ${msg.order_id}`);
loadOrders();
selectOrder(msg.order_id);
} else if (msg.type === 'refresh_data') {
loadOrders();
loadFeedback();
}
};
ws.onclose = () => {
setTimeout(connectDashboardWs, 3000);
};
}