codebook / potato /static /llm-chat-sidebar.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
12 kB
/**
* 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 = '<i class="fas fa-comments"></i> ' + (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();
}
})();