/** * Attendant Module JavaScript * Human agent interface for live chat support */ (function() { 'use strict'; // State const state = { activeConversation: null, quickReplies: [], typing: false }; // DOM Elements const elements = { queueList: document.querySelector('.queue-list'), conversationArea: document.querySelector('.conversation-area'), conversationMessages: document.querySelector('.conversation-messages'), messageInput: document.querySelector('.message-input'), userPanel: document.querySelector('.user-panel') }; /** * Initialize attendant module */ function init() { setupQueueHandlers(); setupMessageHandlers(); setupKeyboardShortcuts(); setupQuickReplies(); setupWebSocket(); } /** * Setup queue item click handlers */ function setupQueueHandlers() { if (!elements.queueList) return; elements.queueList.addEventListener('click', function(e) { const queueItem = e.target.closest('.queue-item'); if (!queueItem) return; // Update active state document.querySelectorAll('.queue-item').forEach(item => { item.classList.remove('active'); }); queueItem.classList.add('active'); // Load conversation const conversationId = queueItem.dataset.conversationId; if (conversationId) { loadConversation(conversationId); } }); } /** * Setup message input handlers */ function setupMessageHandlers() { const input = elements.messageInput; if (!input) return; // Handle Enter to send input.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Auto-resize textarea input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 120) + 'px'; }); // Send button const sendBtn = document.querySelector('.send-btn'); if (sendBtn) { sendBtn.addEventListener('click', sendMessage); } } /** * Setup keyboard shortcuts */ function setupKeyboardShortcuts() { document.addEventListener('keydown', function(e) { // Ctrl+Enter to send if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { sendMessage(); return; } // Escape to close panels if (e.key === 'Escape') { closeModals(); } // Ctrl+K to focus search if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); const searchInput = document.querySelector('.queue-search input'); if (searchInput) searchInput.focus(); } // Number keys (1-9) for quick replies if (e.altKey && e.key >= '1' && e.key <= '9') { const index = parseInt(e.key) - 1; const quickReply = document.querySelectorAll('.quick-reply')[index]; if (quickReply) { e.preventDefault(); insertQuickReply(quickReply.textContent); } } }); } /** * Setup quick reply buttons */ function setupQuickReplies() { document.querySelectorAll('.quick-reply').forEach(btn => { btn.addEventListener('click', function() { insertQuickReply(this.textContent); }); }); } /** * Insert quick reply into message input */ function insertQuickReply(text) { const input = elements.messageInput; if (!input) return; input.value = text; input.focus(); input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 120) + 'px'; } /** * Load conversation by ID */ function loadConversation(conversationId) { state.activeConversation = conversationId; // HTMX will handle the actual loading // This is for any additional state management updateUserPanel(conversationId); } /** * Update user info panel */ function updateUserPanel(conversationId) { // User panel is updated via HTMX // Add any additional logic here } /** * Send message */ function sendMessage() { const input = elements.messageInput; if (!input || !input.value.trim()) return; const message = input.value.trim(); // Add message to UI immediately (optimistic update) appendMessage({ type: 'agent', content: message, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }); // Clear input input.value = ''; input.style.height = 'auto'; // Send via HTMX or WebSocket if (window.attendantSocket && window.attendantSocket.readyState === WebSocket.OPEN) { window.attendantSocket.send(JSON.stringify({ type: 'message', conversationId: state.activeConversation, content: message })); } } /** * Append message to conversation */ function appendMessage(msg) { const container = elements.conversationMessages; if (!container) return; const messageDiv = document.createElement('div'); messageDiv.className = `message ${msg.type}`; messageDiv.innerHTML = `
${escapeHtml(msg.content)}
${msg.time}
`; container.appendChild(messageDiv); scrollToBottom(); } /** * Scroll messages to bottom */ function scrollToBottom() { const container = elements.conversationMessages; if (container) { container.scrollTop = container.scrollHeight; } } /** * Setup WebSocket connection */ function setupWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/attendant`; try { window.attendantSocket = new WebSocket(wsUrl); window.attendantSocket.onopen = function() { console.log('Attendant WebSocket connected'); updateConnectionStatus('online'); }; window.attendantSocket.onmessage = function(event) { handleWebSocketMessage(JSON.parse(event.data)); }; window.attendantSocket.onclose = function() { console.log('Attendant WebSocket disconnected'); updateConnectionStatus('offline'); // Attempt reconnection setTimeout(setupWebSocket, 5000); }; window.attendantSocket.onerror = function(error) { console.error('WebSocket error:', error); updateConnectionStatus('error'); }; } catch (e) { console.warn('WebSocket not available:', e); } } /** * Handle incoming WebSocket messages */ function handleWebSocketMessage(data) { switch (data.type) { case 'new_message': if (data.conversationId === state.activeConversation) { appendMessage({ type: 'user', content: data.content, time: data.time || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }); } // Update queue item preview updateQueuePreview(data.conversationId, data.content); break; case 'new_conversation': // Refresh queue list htmx.trigger('.queue-list', 'refresh'); showNotification('New conversation', data.userName || 'New user'); break; case 'typing': if (data.conversationId === state.activeConversation) { showTypingIndicator(data.isTyping); } break; case 'conversation_closed': if (data.conversationId === state.activeConversation) { showConversationClosed(); } break; } } /** * Update queue item preview text */ function updateQueuePreview(conversationId, text) { const item = document.querySelector(`.queue-item[data-conversation-id="${conversationId}"]`); if (item) { const preview = item.querySelector('.queue-preview'); if (preview) { preview.textContent = text.substring(0, 50) + (text.length > 50 ? '...' : ''); } // Update time const time = item.querySelector('.queue-time'); if (time) { time.textContent = 'Just now'; } // Add unread badge if not active if (conversationId !== state.activeConversation) { let badge = item.querySelector('.queue-badge'); if (!badge) { badge = document.createElement('span'); badge.className = 'queue-badge'; badge.textContent = '1'; item.appendChild(badge); } else { badge.textContent = parseInt(badge.textContent || 0) + 1; } } } } /** * Show typing indicator */ function showTypingIndicator(isTyping) { let indicator = document.querySelector('.typing-indicator'); if (isTyping) { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'typing-indicator'; indicator.innerHTML = ` `; elements.conversationMessages?.appendChild(indicator); } } else if (indicator) { indicator.remove(); } } /** * Show conversation closed message */ function showConversationClosed() { const container = elements.conversationMessages; if (!container) return; const closedDiv = document.createElement('div'); closedDiv.className = 'conversation-closed'; closedDiv.textContent = 'This conversation has been closed'; container.appendChild(closedDiv); // Disable input if (elements.messageInput) { elements.messageInput.disabled = true; elements.messageInput.placeholder = 'Conversation closed'; } } /** * Update connection status indicator */ function updateConnectionStatus(status) { const indicator = document.querySelector('.connection-status'); if (indicator) { indicator.className = `connection-status ${status}`; indicator.title = status.charAt(0).toUpperCase() + status.slice(1); } } /** * Show browser notification */ function showNotification(title, body) { if (!('Notification' in window)) return; if (Notification.permission === 'granted') { new Notification(title, { body, icon: '/icons/notification.png' }); } else if (Notification.permission !== 'denied') { Notification.requestPermission().then(permission => { if (permission === 'granted') { new Notification(title, { body, icon: '/icons/notification.png' }); } }); } } /** * Close all modals */ function closeModals() { document.querySelectorAll('.modal').forEach(modal => { modal.classList.add('hidden'); }); } /** * Escape HTML to prevent XSS */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Expose for external use window.Attendant = { sendMessage, loadConversation, insertQuickReply }; })(); // ===================================================================== // Configuration // ===================================================================== const API_BASE = window.location.origin; let currentSessionId = null; let currentAttendantId = null; let currentAttendantStatus = "online"; let conversations = []; let attendants = []; let ws = null; let reconnectAttempts = 0; const MAX_RECONNECT_ATTEMPTS = 5; // LLM Assist configuration let llmAssistConfig = { tips_enabled: false, polish_enabled: false, smart_replies_enabled: false, auto_summary_enabled: false, sentiment_enabled: false }; let conversationHistory = []; // ===================================================================== // Initialization // ===================================================================== document.addEventListener("DOMContentLoaded", async () => { await checkCRMEnabled(); setupEventListeners(); }); async function checkCRMEnabled() { // CRM is now enabled by default try { const response = await fetch( `${API_BASE}/api/attendance/attendants`, ); const data = await response.json(); if (response.ok && Array.isArray(data)) { attendants = data; if (attendants.length > 0) { // Set current attendant (first one for now, should come from auth) currentAttendantId = attendants[0].attendant_id; document.getElementById( "attendantName", ).textContent = attendants[0].attendant_name; } else { // No attendants configured, use default document.getElementById( "attendantName", ).textContent = "Agent"; } } else { // API error, use default document.getElementById( "attendantName", ).textContent = "Agent"; } // Always load queue and connect WebSocket - CRM enabled by default await loadQueue(); connectWebSocket(); } catch (error) { console.error("Failed to load attendants:", error); // Still enable the console with default settings document.getElementById("attendantName").textContent = "Agent"; await loadQueue(); connectWebSocket(); } } function showCRMDisabled() { // Kept for backwards compatibility but no longer used by default document.getElementById("crmDisabled").classList.add("active"); document.getElementById("crmDisabled").style.display = "flex"; document.getElementById("mainLayout").style.display = "none"; } function setupEventListeners() { // Chat input auto-resize const chatInput = document.getElementById("chatInput"); chatInput.addEventListener("input", function () { this.style.height = "auto"; this.style.height = Math.min(this.scrollHeight, 120) + "px"; }); // Send on Enter (without Shift) chatInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Close dropdown on outside click document.addEventListener("click", (e) => { if (!e.target.closest("#attendantStatus")) { document .getElementById("statusDropdown") .classList.remove("show"); } }); } // ===================================================================== // Queue Management // ===================================================================== async function loadQueue() { try { const response = await fetch( `${API_BASE}/api/attendance/queue`, ); if (response.ok) { conversations = await response.json(); renderConversations(); updateStats(); } } catch (error) { console.error("Failed to load queue:", error); showToast("Failed to load queue", "error"); } } function renderConversations() { const list = document.getElementById("conversationList"); const emptyState = document.getElementById("emptyQueue"); if (conversations.length === 0) { emptyState.style.display = "flex"; return; } emptyState.style.display = "none"; // Sort by priority and waiting time conversations.sort((a, b) => { if (b.priority !== a.priority) return b.priority - a.priority; return b.waiting_time_seconds - a.waiting_time_seconds; }); list.innerHTML = conversations .map( (conv) => `
${escapeHtml(conv.user_name || "Anonymous")} ${formatTime(conv.last_message_time)}
${escapeHtml(conv.last_message || "No messages")}
${conv.channel} ${conv.priority >= 2 ? `🔥 ${conv.priority >= 3 ? "Urgent" : "High"}` : ""} ${formatWaitTime(conv.waiting_time_seconds)}
`, ) .join("") + ``; } function updateStats() { const waiting = conversations.filter( (c) => c.status === "waiting", ).length; const active = conversations.filter( (c) => c.status === "active", ).length; const resolved = conversations.filter( (c) => c.status === "resolved", ).length; const mine = conversations.filter( (c) => c.assigned_to === currentAttendantId, ).length; document.getElementById("waitingCount").textContent = waiting; document.getElementById("activeCount").textContent = active; document.getElementById("resolvedCount").textContent = resolved; document.getElementById("allBadge").textContent = conversations.length; document.getElementById("waitingBadge").textContent = waiting; document.getElementById("mineBadge").textContent = mine; } function filterQueue(filter) { document.querySelectorAll(".filter-btn").forEach((btn) => { btn.classList.toggle( "active", btn.dataset.filter === filter, ); }); const items = document.querySelectorAll(".conversation-item"); items.forEach((item) => { const sessionId = item.dataset.sessionId; const conv = conversations.find( (c) => c.session_id === sessionId, ); if (!conv) return; let show = true; switch (filter) { case "waiting": show = conv.status === "waiting"; break; case "mine": show = conv.assigned_to === currentAttendantId; break; case "high": show = conv.priority >= 2; break; } item.style.display = show ? "block" : "none"; }); } // ===================================================================== // Conversation Selection & Chat // ===================================================================== async function selectConversation(sessionId) { currentSessionId = sessionId; conversationHistory = []; // Reset history for new conversation const conv = conversations.find( (c) => c.session_id === sessionId, ); if (!conv) return; // Update UI document .querySelectorAll(".conversation-item") .forEach((item) => { item.classList.toggle( "active", item.dataset.sessionId === sessionId, ); if (item.dataset.sessionId === sessionId) { item.classList.remove("unread"); } }); document.getElementById("noConversation").style.display = "none"; document.getElementById("activeChat").style.display = "flex"; // Update header document.getElementById("customerAvatar").textContent = (conv.user_name || "A")[0].toUpperCase(); document.getElementById("customerName").textContent = conv.user_name || "Anonymous"; document.getElementById("customerChannel").textContent = conv.channel; document.getElementById("customerChannel").className = `channel-tag channel-${conv.channel.toLowerCase()}`; // Show customer details document.getElementById("customerDetails").style.display = "block"; document.getElementById("detailEmail").textContent = conv.user_email || "-"; // Load messages await loadMessages(sessionId); // Load AI insights await loadInsights(sessionId); // Assign to self if unassigned if (!conv.assigned_to && currentAttendantId) { await assignConversation(sessionId, currentAttendantId); } } async function loadMessages(sessionId) { const container = document.getElementById("chatMessages"); container.innerHTML = '
'; try { // For now, show the last message from queue data const conv = conversations.find( (c) => c.session_id === sessionId, ); // In real implementation, fetch from /api/sessions/{id}/messages container.innerHTML = ""; if (conv && conv.last_message) { addMessage( "customer", conv.last_message, conv.last_message_time, ); } // Add system message for transfer if (conv && conv.assigned_to_name) { addSystemMessage( `Assigned to ${conv.assigned_to_name}`, ); } } catch (error) { console.error("Failed to load messages:", error); container.innerHTML = '

Failed to load messages

'; } } function addMessage(type, content, time = null) { const container = document.getElementById("chatMessages"); const timeStr = time ? formatTime(time) : new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); const avatarContent = type === "customer" ? "C" : type === "bot" ? "🤖" : "You"; const avatarClass = type === "bot" ? "bot" : ""; const messageHtml = `
${avatarContent}
${escapeHtml(content)}
${timeStr} ${type === "bot" ? 'Bot' : ""}
`; container.insertAdjacentHTML("beforeend", messageHtml); container.scrollTop = container.scrollHeight; } function addSystemMessage(content) { const container = document.getElementById("chatMessages"); const messageHtml = `
${escapeHtml(content)}
`; container.insertAdjacentHTML("beforeend", messageHtml); } async function sendMessage() { const input = document.getElementById("chatInput"); const message = input.value.trim(); if (!message || !currentSessionId) return; input.value = ""; input.style.height = "auto"; // Add to UI immediately addMessage("attendant", message); // Add to conversation history conversationHistory.push({ role: "attendant", content: message, timestamp: new Date().toISOString() }); try { // Send to attendance respond API const response = await fetch( `${API_BASE}/api/attendance/respond`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: currentSessionId, message: message, attendant_id: currentAttendantId, }), }, ); const result = await response.json(); if (!result.success) { throw new Error( result.error || "Failed to send message", ); } showToast(result.message, "success"); // Refresh smart replies after sending if (llmAssistConfig.smart_replies_enabled) { loadSmartReplies(currentSessionId); } } catch (error) { console.error("Failed to send message:", error); showToast( "Failed to send message: " + error.message, "error", ); } } function useQuickResponse(text) { document.getElementById("chatInput").value = text; document.getElementById("chatInput").focus(); } function useSuggestion(element) { const text = element .querySelector(".suggested-reply-text") .textContent.trim(); document.getElementById("chatInput").value = text; document.getElementById("chatInput").focus(); } // ===================================================================== // Transfer & Assignment // ===================================================================== async function assignConversation(sessionId, attendantId) { try { const response = await fetch( `${API_BASE}/api/attendance/assign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, attendant_id: attendantId, }), }, ); if (response.ok) { showToast("Conversation assigned", "success"); await loadQueue(); } } catch (error) { console.error("Failed to assign conversation:", error); } } function showTransferModal() { if (!currentSessionId) return; const list = document.getElementById("attendantList"); list.innerHTML = attendants .filter((a) => a.attendant_id !== currentAttendantId) .map( (a) => `
${escapeHtml(a.attendant_name)}
${a.preferences} • ${a.channel}
`, ) .join(""); document.getElementById("transferModal").classList.add("show"); } function closeTransferModal() { document .getElementById("transferModal") .classList.remove("show"); document.getElementById("transferReason").value = ""; } let selectedTransferTarget = null; function selectTransferTarget(element, attendantId) { document .querySelectorAll(".attendant-option") .forEach((el) => el.classList.remove("selected")); element.classList.add("selected"); selectedTransferTarget = attendantId; } async function confirmTransfer() { if (!selectedTransferTarget || !currentSessionId) { showToast("Please select an attendant", "warning"); return; } const reason = document.getElementById("transferReason").value; try { const response = await fetch( `${API_BASE}/api/attendance/transfer`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: currentSessionId, from_attendant_id: currentAttendantId, to_attendant_id: selectedTransferTarget, reason: reason, }), }, ); if (response.ok) { showToast("Conversation transferred", "success"); closeTransferModal(); currentSessionId = null; document.getElementById( "noConversation", ).style.display = "flex"; document.getElementById("activeChat").style.display = "none"; await loadQueue(); } else { throw new Error("Transfer failed"); } } catch (error) { console.error("Failed to transfer:", error); showToast("Failed to transfer conversation", "error"); } } async function resolveConversation() { if (!currentSessionId) return; try { const response = await fetch( `${API_BASE}/api/attendance/resolve/${currentSessionId}`, { method: "POST", headers: { "Content-Type": "application/json" }, }, ); if (response.ok) { showToast("Conversation resolved", "success"); currentSessionId = null; document.getElementById( "noConversation", ).style.display = "flex"; document.getElementById("activeChat").style.display = "none"; await loadQueue(); } else { throw new Error("Failed to resolve"); } } catch (error) { console.error("Failed to resolve:", error); showToast("Failed to resolve conversation", "error"); } } // ===================================================================== // Status Management // ===================================================================== function toggleStatusDropdown() { document .getElementById("statusDropdown") .classList.toggle("show"); } async function setStatus(status) { currentAttendantStatus = status; document.getElementById("statusIndicator").className = `status-indicator ${status}`; document .getElementById("statusDropdown") .classList.remove("show"); const statusTexts = { online: "Online - Ready for conversations", busy: "Busy - Handling conversations", away: "Away - Temporarily unavailable", offline: "Offline - Not accepting conversations", }; document.getElementById("statusText").textContent = statusTexts[status]; try { await fetch( `${API_BASE}/api/attendance/attendants/${currentAttendantId}/status`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status }), }, ); } catch (error) { console.error("Failed to update status:", error); } } // ===================================================================== // AI Insights // ===================================================================== async function loadInsights(sessionId) { // Update sentiment (loading state) document.getElementById("sentimentValue").innerHTML = "😐 Analyzing..."; document.getElementById("intentValue").textContent = "Analyzing conversation..."; document.getElementById("summaryValue").textContent = "Loading summary..."; const conv = conversations.find(c => c.session_id === sessionId); // Load LLM Assist config for this bot try { const configResponse = await fetch(`${API_BASE}/api/attendance/llm/config/${conv?.bot_id || 'default'}`); if (configResponse.ok) { llmAssistConfig = await configResponse.json(); } } catch (e) { console.log("LLM config not available, using defaults"); } // Load real insights using LLM Assist APIs try { // Generate summary if enabled if (llmAssistConfig.auto_summary_enabled) { const summaryResponse = await fetch(`${API_BASE}/api/attendance/llm/summary/${sessionId}`); if (summaryResponse.ok) { const summaryData = await summaryResponse.json(); if (summaryData.success) { document.getElementById("summaryValue").textContent = summaryData.summary.brief || "No summary available"; document.getElementById("intentValue").textContent = summaryData.summary.customer_needs?.join(", ") || "General inquiry"; } } } else { document.getElementById("summaryValue").textContent = `Customer ${conv?.user_name || "Anonymous"} via ${conv?.channel || "web"}`; document.getElementById("intentValue").textContent = "General inquiry"; } // Analyze sentiment if we have the last message if (llmAssistConfig.sentiment_enabled && conv?.last_message) { const sentimentResponse = await fetch(`${API_BASE}/api/attendance/llm/sentiment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, message: conv.last_message, history: conversationHistory }) }); if (sentimentResponse.ok) { const sentimentData = await sentimentResponse.json(); if (sentimentData.success) { const s = sentimentData.sentiment; const sentimentClass = s.overall === 'positive' ? 'sentiment-positive' : s.overall === 'negative' ? 'sentiment-negative' : 'sentiment-neutral'; document.getElementById("sentimentValue").innerHTML = `${s.emoji} ${s.overall.charAt(0).toUpperCase() + s.overall.slice(1)}`; // Show warning for high escalation risk if (s.escalation_risk === 'high') { showToast("⚠️ High escalation risk detected", "warning"); } } } } else { document.getElementById("sentimentValue").innerHTML = `😐 Neutral`; } // Generate smart replies if enabled if (llmAssistConfig.smart_replies_enabled) { await loadSmartReplies(sessionId); } else { loadDefaultReplies(); } } catch (error) { console.error("Failed to load insights:", error); // Show fallback data document.getElementById("sentimentValue").innerHTML = `😐 Neutral`; document.getElementById("summaryValue").textContent = `Customer ${conv?.user_name || "Anonymous"} via ${conv?.channel || "web"}`; loadDefaultReplies(); } } // Load smart replies from LLM async function loadSmartReplies(sessionId) { try { const response = await fetch(`${API_BASE}/api/attendance/llm/smart-replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, history: conversationHistory }) }); if (response.ok) { const data = await response.json(); if (data.success && data.replies.length > 0) { const repliesHtml = data.replies.map(reply => `
${escapeHtml(reply.text)}
${Math.round(reply.confidence * 100)}% match ${reply.tone} • AI
`).join(''); document.getElementById("suggestedReplies").innerHTML = repliesHtml; return; } } } catch (e) { console.error("Failed to load smart replies:", e); } loadDefaultReplies(); } // Load default replies when LLM is unavailable function loadDefaultReplies() { document.getElementById("suggestedReplies").innerHTML = `
Hello! Thank you for reaching out. How can I assist you today?
Template Quick Reply
I'd be happy to help you with that. Let me look into it.
Template Quick Reply
Is there anything else I can help you with?
Template Quick Reply
`; } // Generate tips when customer message arrives async function generateTips(sessionId, customerMessage) { if (!llmAssistConfig.tips_enabled) return; try { const response = await fetch(`${API_BASE}/api/attendance/llm/tips`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, customer_message: customerMessage, history: conversationHistory }) }); if (response.ok) { const data = await response.json(); if (data.success && data.tips.length > 0) { displayTips(data.tips); } } } catch (e) { console.error("Failed to generate tips:", e); } } // Display tips in the UI function displayTips(tips) { const tipsContainer = document.getElementById("tipsContainer"); if (!tipsContainer) { // Create tips container if it doesn't exist const insightsSection = document.querySelector(".insights-sidebar .sidebar-section"); if (insightsSection) { const tipsDiv = document.createElement("div"); tipsDiv.id = "tipsContainer"; tipsDiv.className = "ai-insight"; tipsDiv.innerHTML = `
💡 Tips
`; insightsSection.insertBefore(tipsDiv, insightsSection.firstChild); } } const tipsValue = document.getElementById("tipsValue"); if (tipsValue) { const tipsHtml = tips.map(tip => { const emoji = tip.tip_type === 'warning' ? '⚠️' : tip.tip_type === 'intent' ? '🎯' : tip.tip_type === 'action' ? '✅' : '💡'; return `
${emoji} ${escapeHtml(tip.content)}
`; }).join(''); tipsValue.innerHTML = tipsHtml; // Show toast for high priority tips const highPriorityTip = tips.find(t => t.priority === 1); if (highPriorityTip) { showToast(`💡 ${highPriorityTip.content}`, "info"); } } } // Polish message before sending async function polishMessage() { if (!llmAssistConfig.polish_enabled) { showToast("Message polish feature is disabled", "info"); return; } const input = document.getElementById("chatInput"); const message = input.value.trim(); if (!message || !currentSessionId) { showToast("Enter a message first", "info"); return; } showToast("✨ Polishing message...", "info"); try { const response = await fetch(`${API_BASE}/api/attendance/llm/polish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSessionId, message: message, tone: "professional" }) }); if (response.ok) { const data = await response.json(); if (data.success && data.polished !== message) { input.value = data.polished; input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 120) + "px"; if (data.changes.length > 0) { showToast(`✨ Message polished: ${data.changes.join(", ")}`, "success"); } else { showToast("✨ Message polished!", "success"); } } else { showToast("Message looks good already!", "success"); } } } catch (e) { console.error("Failed to polish message:", e); showToast("Failed to polish message", "error"); } } // ===================================================================== // WebSocket // ===================================================================== function connectWebSocket() { if (!currentAttendantId) { console.warn( "No attendant ID, skipping WebSocket connection", ); return; } try { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; ws = new WebSocket( `${protocol}//${window.location.host}/ws/attendant?attendant_id=${encodeURIComponent(currentAttendantId)}`, ); ws.onopen = () => { console.log( "WebSocket connected for attendant:", currentAttendantId, ); showToast( "Connected to notification service", "success", ); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log("WebSocket message received:", data); handleWebSocketMessage(data); }; ws.onclose = () => { console.log("WebSocket disconnected"); attemptReconnect(); }; ws.onerror = (error) => { console.error("WebSocket error:", error); }; } catch (error) { console.error("Failed to connect WebSocket:", error); attemptReconnect(); } } function attemptReconnect() { if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; setTimeout(() => { console.log( `Reconnecting... attempt ${reconnectAttempts}`, ); connectWebSocket(); }, 2000 * reconnectAttempts); } } function handleWebSocketMessage(data) { const msgType = data.type || data.notification_type; switch (msgType) { case "connected": console.log("WebSocket connected:", data.message); reconnectAttempts = 0; break; case "new_conversation": showToast("New conversation in queue", "info"); loadQueue(); // Play notification sound playNotificationSound(); break; case "new_message": // Message from customer showToast( `New message from ${data.user_name || "Customer"}`, "info", ); if (data.session_id === currentSessionId) { addMessage( "customer", data.content, data.timestamp, ); // Add to conversation history for context conversationHistory.push({ role: "customer", content: data.content, timestamp: data.timestamp || new Date().toISOString() }); // Generate tips for this new message generateTips(data.session_id, data.content); // Refresh sentiment analysis if (llmAssistConfig.sentiment_enabled) { loadInsights(data.session_id); } } loadQueue(); playNotificationSound(); break; case "attendant_response": // Response from another attendant if ( data.session_id === currentSessionId && data.assigned_to !== currentAttendantId ) { addMessage( "attendant", data.content, data.timestamp, ); } break; case "queue_update": loadQueue(); break; case "transfer": if (data.assigned_to === currentAttendantId) { showToast( `Conversation transferred to you`, "info", ); loadQueue(); playNotificationSound(); } break; default: console.log( "Unknown WebSocket message type:", msgType, data, ); } } function playNotificationSound() { // Create a simple beep sound try { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 800; oscillator.type = "sine"; gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime( 0.01, audioContext.currentTime + 0.3, ); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); } catch (e) { // Audio not available console.log("Could not play notification sound"); } } // ===================================================================== // Utility Functions // ===================================================================== function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text || ""; return div.innerHTML; } function formatTime(timestamp) { if (!timestamp) return ""; const date = new Date(timestamp); const now = new Date(); const diff = (now - date) / 1000; if (diff < 60) return "Just now"; if (diff < 3600) return `${Math.floor(diff / 60)} min`; if (diff < 86400) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); return date.toLocaleDateString(); } function formatWaitTime(seconds) { if (!seconds || seconds < 0) return ""; if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } function showToast(message, type = "info") { const container = document.getElementById("toastContainer"); const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.innerHTML = ` ${escapeHtml(message)} `; container.appendChild(toast); setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000); } function attachFile() { showToast("File attachment coming soon", "info"); } function insertEmoji() { showToast("Emoji picker coming soon", "info"); } function loadHistoricalConversation(id) { showToast("Loading conversation history...", "info"); } // Periodic refresh (every 30 seconds if WebSocket not connected) setInterval(() => { if (currentAttendantStatus === "online") { // Only refresh if WebSocket is not connected if (!ws || ws.readyState !== WebSocket.OPEN) { loadQueue(); } } }, 30000); // Send status updates via WebSocket function sendWebSocketMessage(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } // Send typing indicator function sendTypingIndicator() { if (currentSessionId) { sendWebSocketMessage({ type: "typing", session_id: currentSessionId, }); } } // Mark messages as read function markAsRead(sessionId) { sendWebSocketMessage({ type: "read", session_id: sessionId, }); }