From 4f5606d898a86877a89cc48d0d9e742cfe479d9e Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Fri, 5 Dec 2025 13:47:42 -0300 Subject: [PATCH] feat(ui): Integrate LLM assist features in attendant console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real LLM API calls for insights, sentiment, smart replies - Polish button (✨) to improve messages before sending - Tips display when customer messages arrive - Sentiment analysis with escalation warnings - Smart reply suggestions from LLM - Conversation history tracking for context --- ui/suite/attendant/index.html | 3862 +++++++++++++++++++++++++-------- 1 file changed, 2919 insertions(+), 943 deletions(-) diff --git a/ui/suite/attendant/index.html b/ui/suite/attendant/index.html index 466448d..e181df2 100644 --- a/ui/suite/attendant/index.html +++ b/ui/suite/attendant/index.html @@ -1,958 +1,2934 @@ - + - - - - Attendant - General Bots - - - -
- -
-
-
- 💬 - Conversation Queue -
-
- class="status-indicator">
-
Online & Ready
-
-
- -
- - - -
- -
-
-
-
Maria Silva
-
2 min
-
-
- 🤖 Bot: Entendi! Vou transferir você para um atendente... -
-
- WhatsApp - High -
-
- -
-
-
John Doe
-
5 min
-
-
- Customer: Can you help me with my order? -
-
- Teams -
-
- -
-
-
Ana Costa
-
12 min
-
-
- 🤖 Bot: Qual é o seu pedido? -
-
- Instagram -
-
- -
-
-
Carlos Santos
-
20 min
-
-
- Attendant: Obrigado pelo contato! -
-
- Web Chat -
-
-
-
- - -
-
-
-
MS
-
-

Maria Silva

-
Typing...
-
-
-
- - - -
-
- -
-
-
MS
-
-
- Olá! Preciso de ajuda com meu pedido #12345 -
-
- 10:23 AM - via WhatsApp -
-
-
- -
-
🤖
-
-
- Olá Maria! Vejo que você tem uma dúvida sobre o pedido #12345. Posso ajudar com: -
1. Status do pedido -
2. Prazo de entrega -
3. Cancelamento/Troca -

O que você precisa? -
-
- BOT - 10:23 AM -
-
-
- -
-
MS
-
-
- Quero saber o prazo de entrega, já faz 10 dias! -
-
- 10:24 AM -
-
-
- -
-
🤖
-
-
- Entendi sua preocupação. Vou consultar o status do seu pedido e transferir você para um atendente que pode ajudar melhor com isso. Aguarde um momento... -
-
- BOT - 10:24 AM - 🔄 Transferred to queue -
-
-
-
- -
-
- - - - -
-
- - -
-
-
- - -
- - - - - - - -
- - - - + 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, + ); + reconnectAttempts = 0; + 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); + 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, + }); + } + +