botui/ui/suite/attendant/attendant.js
Rodrigo Rodriguez (Pragmatismo) 69654f37d6 refactor(ui): extract inline CSS/JS to external files
Phase 2 of CSS/JS extraction - replace inline styles and scripts with
external file references for better maintainability and caching.

Files updated:
- home.html -> css/home.css, js/home.js
- tasks/tasks.html -> tasks/tasks.css, tasks/tasks.js
- admin/index.html -> admin/admin.css, admin/admin.js
- analytics/analytics.html -> analytics/analytics.css, analytics/analytics.js
- mail/mail.html -> mail/mail.css, mail/mail.js
- monitoring/monitoring.html -> monitoring/monitoring.css, monitoring/monitoring.js
- attendant/index.html -> attendant/attendant.css, attendant/attendant.js

All JS wrapped in IIFE pattern to prevent global namespace pollution.
Functions called from HTML onclick handlers exposed via window object.
HTMX reload handlers included for proper reinitialization.

Per PROMPT.md: no CDN links, HTMX-first approach, local assets only.
2026-01-10 20:12:48 -03:00

1562 lines
62 KiB
JavaScript

/**
* 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 = `
<div class="message-bubble">${escapeHtml(msg.content)}</div>
<div class="message-time">${msg.time}</div>
`;
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 = `
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
`;
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) => `
<div class="conversation-item ${conv.session_id === currentSessionId ? "active" : ""} ${conv.status === "waiting" ? "unread" : ""}"
onclick="selectConversation('${conv.session_id}')"
data-session-id="${conv.session_id}">
<div class="conversation-header">
<span class="customer-name">${escapeHtml(conv.user_name || "Anonymous")}</span>
<span class="conversation-time">${formatTime(conv.last_message_time)}</span>
</div>
<div class="conversation-preview">${escapeHtml(conv.last_message || "No messages")}</div>
<div class="conversation-meta">
<span class="channel-tag channel-${conv.channel.toLowerCase()}">${conv.channel}</span>
${conv.priority >= 2 ? `<span class="priority-tag priority-${conv.priority >= 3 ? "urgent" : "high"}">🔥 ${conv.priority >= 3 ? "Urgent" : "High"}</span>` : ""}
<span class="waiting-time ${conv.waiting_time_seconds > 300 ? "long" : ""}">${formatWaitTime(conv.waiting_time_seconds)}</span>
</div>
</div>
`,
)
.join("") +
`<div class="empty-queue" id="emptyQueue" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
<p>No conversations in queue</p>
<small>New conversations will appear here</small>
</div>`;
}
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 = '<div class="loading-spinner"></div>';
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 =
'<p style="text-align: center; color: var(--text-muted);">Failed to load messages</p>';
}
}
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 = `
<div class="message ${type}">
<div class="message-avatar ${avatarClass}">${avatarContent}</div>
<div class="message-content">
<div class="message-bubble">${escapeHtml(content)}</div>
<div class="message-meta">
<span>${timeStr}</span>
${type === "bot" ? '<span class="bot-badge">Bot</span>' : ""}
</div>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", messageHtml);
container.scrollTop = container.scrollHeight;
}
function addSystemMessage(content) {
const container = document.getElementById("chatMessages");
const messageHtml = `
<div class="message system">
<div class="message-content">
<div class="message-bubble">${escapeHtml(content)}</div>
</div>
</div>
`;
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) => `
<div class="attendant-option" onclick="selectTransferTarget(this, '${a.attendant_id}')">
<div class="status-indicator ${a.status.toLowerCase()}"></div>
<div>
<div style="font-weight: 500;">${escapeHtml(a.attendant_name)}</div>
<div style="font-size: 12px; color: var(--text-secondary);">${a.preferences}${a.channel}</div>
</div>
</div>
`,
)
.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 =
`<span class="sentiment-indicator ${sentimentClass}">${s.emoji} ${s.overall.charAt(0).toUpperCase() + s.overall.slice(1)}</span>`;
// Show warning for high escalation risk
if (s.escalation_risk === 'high') {
showToast("⚠️ High escalation risk detected", "warning");
}
}
}
} else {
document.getElementById("sentimentValue").innerHTML =
`<span class="sentiment-indicator sentiment-neutral">😐 Neutral</span>`;
}
// 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 =
`<span class="sentiment-indicator sentiment-neutral">😐 Neutral</span>`;
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 => `
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">${escapeHtml(reply.text)}</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">${Math.round(reply.confidence * 100)}% match</span>
<span class="suggestion-source">${reply.tone} • AI</span>
</div>
</div>
`).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 = `
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">Hello! Thank you for reaching out. How can I assist you today?</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
</div>
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">I'd be happy to help you with that. Let me look into it.</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
</div>
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">Is there anything else I can help you with?</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
</div>
`;
}
// 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 = `
<div class="insight-header">
<span class="insight-icon">💡</span>
<span class="insight-label">Tips</span>
</div>
<div class="insight-value" id="tipsValue"></div>
`;
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 `<div style="margin-bottom: 8px;">${emoji} ${escapeHtml(tip.content)}</div>`;
}).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 = `
<span>${escapeHtml(message)}</span>
`;
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,
});
}