/** * AI Chat Widget Client * Lightweight, dependency-free chat widget for embedding on external sites * Functional programming style - no classes, pure functions */ import { askAssistant } from './api.mjs'; const STORAGE_KEY = 'chat-history'; const LOADING_INDICATOR = '⏳ Thinking...'; const MAX_EXCHANGES = 10; // Keep last 10 user/assistant pairs const MAX_HISTORY_CHARS = 2000; // Keep at most 2000 characters // State management - mutable refs to avoid global state const state = { isOpen: false, isLoading: false, hasError: false, messagesContainer: null, dialogInput: null, form: null, toggleBtn: null, closeBtn: null, clearBtn: null, sendBtn: null, focusedElementBeforeOpen: null, }; /** * Create widget HTML structure */ const createWidgetHTML = () => ` `; /** * Inject widget HTML into page */ const injectHTML = () => { let container = document.getElementById('chat-client'); if (!container) { container = document.createElement('div'); container.id = 'chat-client'; document.body.appendChild(container); } container.innerHTML = createWidgetHTML(); // Load and inject CSS const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = location.host.endsWith('.localhost:1337') ? 'http://code.a8b.co.localhost:1337/chat/chat.css' : 'https://code.a8b.co/chat/chat.css'; document.head.appendChild(link); }; /** * Cache DOM elements */ const cacheElements = () => { const container = document.getElementById('chat-client'); state.messagesContainer = container.querySelector('.messages-container'); state.dialogInput = container.querySelector('.dialog-input'); state.form = container.querySelector('.chat-form'); state.toggleBtn = container.querySelector('.toggle-chat'); state.closeBtn = container.querySelector('.close-btn'); state.clearBtn = container.querySelector('.clear-btn'); state.sendBtn = container.querySelector('.send-btn'); }; /** * Open chat form */ const openChat = () => { state.isOpen = true; state.focusedElementBeforeOpen = document.activeElement; state.form.style.display = 'flex'; state.form.setAttribute('aria-hidden', 'false'); document.getElementById('chat-client').classList.add('chat-open'); state.dialogInput.focus(); }; /** * Close chat form */ const closeChat = () => { state.isOpen = false; state.form.style.display = 'none'; state.form.setAttribute('aria-hidden', 'true'); document.getElementById('chat-client').classList.remove('chat-open'); // Restore focus to the element that opened the chat if (state.focusedElementBeforeOpen && state.focusedElementBeforeOpen.focus) { state.focusedElementBeforeOpen.focus(); } }; /** * Clear chat history */ const clearHistory = () => { localStorage.removeItem(STORAGE_KEY); state.messagesContainer.innerHTML = ''; state.dialogInput.focus(); // Announce to screen readers that history was cleared const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', 'polite'); announcement.className = 'sr-only'; announcement.textContent = 'Chat history cleared'; document.body.appendChild(announcement); setTimeout(() => announcement.remove(), 1000); }; /** * Toggle chat open/closed */ const toggleChat = () => (state.isOpen ? closeChat() : openChat()); /** * Scroll messages to bottom */ const scrollToBottom = () => { state.messagesContainer.scrollTop = state.messagesContainer.scrollHeight; }; /** * Remove last message from UI */ const removeLastMessage = () => { const lastMessage = state.messagesContainer.lastElementChild; if (lastMessage) lastMessage.remove(); }; /** * Sanitize HTML to allow safe tags while preventing XSS */ const sanitizeHTML = html => { const allowedTags = ['a', 'b', 'i', 'em', 'strong', 'br', 'p', 'ul', 'ol', 'li', 'code', 'pre']; const allowedAttrs = ['href', 'target', 'rel']; const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const walk = node => { for (let i = 0; i < node.childNodes.length; i++) { const child = node.childNodes[i]; if (child.nodeType === 1) { // Element node if (!allowedTags.includes(child.tagName.toLowerCase())) { const textNode = document.createTextNode(child.textContent); node.replaceChild(textNode, child); } else { // Remove disallowed attributes for (let j = child.attributes.length - 1; j >= 0; j--) { const attr = child.attributes[j]; if (!allowedAttrs.includes(attr.name.toLowerCase())) { child.removeAttribute(attr.name); } } // Ensure links open in new tab if (child.tagName.toLowerCase() === 'a') { child.setAttribute('target', '_blank'); child.setAttribute('rel', 'noopener noreferrer'); } walk(child); } } } }; walk(doc.body); return doc.body.innerHTML; }; /** * Add message to UI */ const addMessageToUI = (role, content) => { const messageEl = document.createElement('div'); messageEl.className = `message message--${role}`; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; if (role === 'assistant') { // Handle loading indicator with spinning emoji if (content === LOADING_INDICATOR) { const emojiSpan = document.createElement('span'); emojiSpan.className = 'spinning-emoji'; emojiSpan.textContent = '⏳'; const textSpan = document.createElement('span'); textSpan.textContent = ' Thinking...'; contentDiv.appendChild(emojiSpan); contentDiv.appendChild(textSpan); } else { contentDiv.innerHTML = sanitizeHTML(content); } } else { contentDiv.textContent = content; } messageEl.appendChild(contentDiv); state.messagesContainer.appendChild(messageEl); scrollToBottom(); }; /** * Trim history to stay within limits */ const trimHistory = history => { let lines = history.split('\n'); const maxLines = MAX_EXCHANGES * 2; if (lines.length > maxLines) { lines = lines.slice(lines.length - maxLines); history = lines.join('\n'); } if (history.length > MAX_HISTORY_CHARS) { while (history.length > MAX_HISTORY_CHARS && lines.length > 1) { lines.shift(); history = lines.join('\n'); } } return history; }; /** * Save message to localStorage history */ const saveToHistory = (role, content) => { let history = localStorage.getItem(STORAGE_KEY) || ''; const prefix = role === 'user' ? 'user: ' : 'assistant: '; history += (history ? '\n' : '') + prefix + content; history = trimHistory(history); localStorage.setItem(STORAGE_KEY, history); }; /** * Get formatted history from localStorage */ const getFormattedHistory = () => localStorage.getItem(STORAGE_KEY) || ''; /** * Load and display history from localStorage */ const loadHistory = () => { const history = getFormattedHistory(); if (!history) return; const lines = history.split('\n'); for (const line of lines) { if (line.startsWith('user: ')) { addMessageToUI('user', line.substring(6)); } else if (line.startsWith('assistant: ')) { addMessageToUI('assistant', line.substring(11)); } } }; /** * Handle form submission */ const handleSubmit = async e => { e.preventDefault(); const message = state.dialogInput.value.trim(); if (!message || state.isLoading) return; state.dialogInput.value = ''; state.hasError = false; state.sendBtn.disabled = true; addMessageToUI('user', message); saveToHistory('user', message); state.isLoading = true; addMessageToUI('assistant', LOADING_INDICATOR); try { const history = getFormattedHistory(); const response = await askAssistant(message, history, window.location.href); removeLastMessage(); addMessageToUI('assistant', response); saveToHistory('assistant', response); } catch (error) { state.hasError = true; removeLastMessage(); const errorMsg = 'Sorry, I encountered an error. Please try again.'; addMessageToUI('assistant', errorMsg); // Announce error to screen readers const announcement = document.createElement('div'); announcement.setAttribute('role', 'alert'); announcement.className = 'sr-only'; announcement.textContent = errorMsg; document.body.appendChild(announcement); setTimeout(() => announcement.remove(), 3000); } state.isLoading = false; state.sendBtn.disabled = false; state.dialogInput.focus(); }; /** * Handle Escape key to close chat */ const handleEscapeKey = e => { if (e.key === 'Escape' && state.isOpen) { e.preventDefault(); closeChat(); } }; /** * Handle Tab key for focus trap in dialog */ const handleTabKey = e => { if (e.key !== 'Tab' || !state.isOpen) return; const focusableElements = state.form.querySelectorAll('button, input, [tabindex]:not([tabindex="-1"])'); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; /** * Attach event listeners */ const attachEventListeners = () => { state.toggleBtn.addEventListener('click', toggleChat); state.closeBtn.addEventListener('click', closeChat); state.clearBtn.addEventListener('click', clearHistory); state.form.addEventListener('submit', handleSubmit); document.addEventListener('keydown', handleEscapeKey); document.addEventListener('keydown', handleTabKey); }; /** * Initialize the widget */ const init = () => { // attempt to detect other chat systems if ( !document.querySelectorAll('script[src*=chat]:not([src="proxy.php?url=https://code.a8b.co/chat/chat.mjs"])').length ) { injectHTML(); cacheElements(); attachEventListeners(); loadHistory(); } else console.info('Helium Chat not loaded; other system detected.'); }; /** * Initialize widget when DOM is ready */ export const initWidget = () => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } }; initWidget();