/** * LLM Chat Sidebar for Annotator Assistance * * Self-contained IIFE that builds a chat sidebar UI, manages conversations * with the backend chat API, and tracks instance changes. */ (function () { 'use strict'; let chatConfig = null; let currentInstanceId = null; let isOpen = false; let isSending = false; // DOM references (populated on init) let sidebar = null; let messagesContainer = null; let inputEl = null; let sendBtn = null; let typingIndicator = null; let toggleBtn = null; // ======================================================================== // Initialization // ======================================================================== function init() { fetch('/api/chat/config') .then(function (r) { return r.json(); }) .then(function (cfg) { chatConfig = cfg; if (!cfg.enabled) return; buildUI(cfg); observeInstanceChanges(); detectInitialInstance(); }) .catch(function (err) { console.warn('[LLM Chat] Failed to load config:', err); }); } // ======================================================================== // UI Construction // ======================================================================== function buildUI(cfg) { // -- Sidebar -- sidebar = document.createElement('div'); sidebar.className = 'llm-chat-sidebar'; sidebar.setAttribute('role', 'complementary'); sidebar.setAttribute('aria-label', cfg.title || 'Chat Assistant'); if (cfg.sidebar_width) { sidebar.style.width = cfg.sidebar_width + 'px'; } // Header var header = document.createElement('div'); header.className = 'llm-chat-header'; var title = document.createElement('h3'); title.textContent = cfg.title || 'Ask AI'; header.appendChild(title); var closeBtn = document.createElement('button'); closeBtn.className = 'llm-chat-close-btn'; closeBtn.innerHTML = '×'; closeBtn.title = 'Close chat'; closeBtn.addEventListener('click', toggleSidebar); header.appendChild(closeBtn); sidebar.appendChild(header); // Messages messagesContainer = document.createElement('div'); messagesContainer.className = 'llm-chat-messages'; var emptyMsg = document.createElement('div'); emptyMsg.className = 'llm-chat-empty'; emptyMsg.textContent = 'Ask a question about the current annotation.'; messagesContainer.appendChild(emptyMsg); // Typing indicator typingIndicator = document.createElement('div'); typingIndicator.className = 'llm-chat-typing'; for (var i = 0; i < 3; i++) { var dot = document.createElement('span'); dot.className = 'llm-chat-typing-dot'; typingIndicator.appendChild(dot); } messagesContainer.appendChild(typingIndicator); sidebar.appendChild(messagesContainer); // Input area var inputArea = document.createElement('div'); inputArea.className = 'llm-chat-input-area'; inputEl = document.createElement('textarea'); inputEl.className = 'llm-chat-input'; inputEl.placeholder = cfg.placeholder || 'Ask about this annotation...'; inputEl.rows = 1; // Prevent keyboard shortcuts from firing while typing in chat inputEl.addEventListener('keydown', function (e) { e.stopPropagation(); if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); inputEl.addEventListener('keypress', function (e) { e.stopPropagation(); }); inputEl.addEventListener('keyup', function (e) { e.stopPropagation(); }); // Auto-resize textarea inputEl.addEventListener('input', function () { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 100) + 'px'; }); inputArea.appendChild(inputEl); sendBtn = document.createElement('button'); sendBtn.className = 'llm-chat-send-btn'; sendBtn.textContent = 'Send'; sendBtn.addEventListener('click', sendMessage); inputArea.appendChild(sendBtn); sidebar.appendChild(inputArea); document.body.appendChild(sidebar); // Update sidebar width in CSS for body margin if (cfg.sidebar_width && cfg.sidebar_width !== 380) { var style = document.createElement('style'); style.textContent = 'body.llm-chat-open .container-fluid,' + 'body.llm-chat-open .main-container{margin-right:' + cfg.sidebar_width + 'px}' + '@media(max-width:768px){body.llm-chat-open .container-fluid,' + 'body.llm-chat-open .main-container{margin-right:0}}'; document.head.appendChild(style); } // -- Navbar toggle button -- var navEnd = document.querySelector('.navbar-end'); if (navEnd) { toggleBtn = document.createElement('button'); toggleBtn.className = 'llm-chat-toggle-btn'; toggleBtn.title = cfg.title || 'Ask AI'; toggleBtn.innerHTML = ' ' + (cfg.title || 'Ask AI'); toggleBtn.addEventListener('click', toggleSidebar); navEnd.insertBefore(toggleBtn, navEnd.firstChild); } } // ======================================================================== // Sidebar Toggle // ======================================================================== function toggleSidebar() { isOpen = !isOpen; if (sidebar) { sidebar.classList.toggle('open', isOpen); } document.body.classList.toggle('llm-chat-open', isOpen); if (toggleBtn) { toggleBtn.classList.toggle('active', isOpen); } if (isOpen && inputEl) { setTimeout(function () { inputEl.focus(); }, 300); } } // ======================================================================== // Instance Change Detection // ======================================================================== function observeInstanceChanges() { // Watch for changes to the hidden #instance_id input var instanceInput = document.getElementById('instance_id'); if (instanceInput) { var observer = new MutationObserver(function () { var newId = instanceInput.value; if (newId && newId !== currentInstanceId) { currentInstanceId = newId; loadHistory(newId); } }); observer.observe(instanceInput, { attributes: true, attributeFilter: ['value'] }); } // Also listen for custom navigation events document.addEventListener('instanceChanged', function (e) { var newId = e.detail && e.detail.instance_id; if (newId && newId !== currentInstanceId) { currentInstanceId = newId; loadHistory(newId); } }); } function detectInitialInstance() { var instanceInput = document.getElementById('instance_id'); if (instanceInput && instanceInput.value) { currentInstanceId = instanceInput.value; loadHistory(currentInstanceId); } } // ======================================================================== // Chat History // ======================================================================== function loadHistory(instanceId) { if (!messagesContainer) return; fetch('/api/chat/history?instance_id=' + encodeURIComponent(instanceId)) .then(function (r) { return r.json(); }) .then(function (data) { clearMessages(); var messages = data.messages || []; if (messages.length === 0) { showEmpty(); } else { messages.forEach(function (msg) { appendMessage(msg.role, msg.content); }); } }) .catch(function (err) { console.warn('[LLM Chat] Failed to load history:', err); }); } // ======================================================================== // Sending Messages // ======================================================================== function sendMessage() { if (isSending || !inputEl) return; var text = inputEl.value.trim(); if (!text) return; isSending = true; sendBtn.disabled = true; inputEl.value = ''; inputEl.style.height = 'auto'; // Remove empty state if present var empty = messagesContainer.querySelector('.llm-chat-empty'); if (empty) empty.remove(); appendMessage('user', text); showTyping(true); scrollToBottom(); fetch('/api/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, instance_id: currentInstanceId || '', }), }) .then(function (r) { return r.json(); }) .then(function (data) { showTyping(false); if (data.error) { appendMessage('error', data.error); } else { appendMessage('assistant', data.content || ''); } scrollToBottom(); }) .catch(function (err) { showTyping(false); appendMessage('error', 'Network error. Please try again.'); console.warn('[LLM Chat] Send failed:', err); }) .finally(function () { isSending = false; sendBtn.disabled = false; if (inputEl) inputEl.focus(); }); } // ======================================================================== // DOM Helpers // ======================================================================== function appendMessage(role, content) { if (!messagesContainer) return; var div = document.createElement('div'); div.className = 'llm-chat-message ' + role; div.textContent = content; // Insert before typing indicator messagesContainer.insertBefore(div, typingIndicator); } function clearMessages() { if (!messagesContainer) return; var msgs = messagesContainer.querySelectorAll('.llm-chat-message, .llm-chat-empty'); msgs.forEach(function (el) { el.remove(); }); } function showEmpty() { if (!messagesContainer) return; var div = document.createElement('div'); div.className = 'llm-chat-empty'; div.textContent = 'Ask a question about the current annotation.'; messagesContainer.insertBefore(div, typingIndicator); } function showTyping(show) { if (typingIndicator) { typingIndicator.classList.toggle('visible', show); } } function scrollToBottom() { if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; } } // ======================================================================== // Start // ======================================================================== if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();