- Add comprehensive documentation in botbook/ with 12 chapters - Add botapp/ Tauri desktop application - Add botdevice/ IoT device support - Add botlib/ shared library crate - Add botmodels/ Python ML models service - Add botplugin/ browser extension - Add botserver/ reorganized server code - Add bottemplates/ bot templates - Add bottest/ integration tests - Add botui/ web UI server - Add CI/CD workflows in .forgejo/workflows/ - Add AGENTS.md and PROD.md documentation - Add dependency management scripts (DEPENDENCIES.sh/ps1) - Remove legacy src/ structure and migrations - Clean up temporary and backup files
1562 lines
62 KiB
JavaScript
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,
|
||
);
|
||
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 = `
|
||
<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,
|
||
});
|
||
}
|