Spaces:
Running
Running
| /** | |
| * Iribl AI - Document Intelligence Application | |
| * With Dual Sidebars, Collapsible Sections, and Animated Dropdowns | |
| */ | |
| // ==================== App State ==================== | |
| const state = { | |
| token: localStorage.getItem('Iribl AI_token'), | |
| user: JSON.parse(localStorage.getItem('Iribl AI_user') || 'null'), | |
| documents: [], | |
| buckets: [], | |
| employees: [], | |
| messages: [], | |
| summaries: {}, // doc_id -> summary text cache | |
| selectedDocument: null, // Currently selected document for summary display | |
| selectedBucket: '', | |
| chatBucket: '', | |
| isLoading: false, | |
| currentRole: 'admin', | |
| // Chat History | |
| chatHistory: JSON.parse(localStorage.getItem('Iribl AI_chat_history') || '[]'), | |
| currentChatId: null, | |
| // Upload cancellation | |
| uploadCancelled: false, | |
| currentUploadAbortController: null, | |
| // Stream abort controller for stopping generation | |
| streamAbortController: null | |
| }; | |
| // ==================== DOM Elements ==================== | |
| const elements = { | |
| // Auth | |
| authModal: document.getElementById('authModal'), | |
| loginForm: document.getElementById('loginForm'), | |
| registerForm: document.getElementById('registerForm'), | |
| employeeLoginForm: document.getElementById('employeeLoginForm'), | |
| authTabs: document.getElementById('authTabs'), | |
| loginError: document.getElementById('loginError'), | |
| registerError: document.getElementById('registerError'), | |
| employeeLoginError: document.getElementById('employeeLoginError'), | |
| // Modals | |
| addEmployeeModal: document.getElementById('addEmployeeModal'), | |
| addEmployeeForm: document.getElementById('addEmployeeForm'), | |
| addEmployeeError: document.getElementById('addEmployeeError'), | |
| addEmployeeBtn: document.getElementById('addEmployeeBtn'), | |
| cancelAddEmployee: document.getElementById('cancelAddEmployee'), | |
| createBucketModal: document.getElementById('createBucketModal'), | |
| createBucketForm: document.getElementById('createBucketForm'), | |
| createBucketError: document.getElementById('createBucketError'), | |
| createBucketBtn: document.getElementById('createBucketBtn'), | |
| cancelCreateBucket: document.getElementById('cancelCreateBucket'), | |
| docViewerModal: document.getElementById('docViewerModal'), | |
| docViewerTitle: document.getElementById('docViewerTitle'), | |
| docViewerContent: document.getElementById('docViewerContent'), | |
| closeDocViewer: document.getElementById('closeDocViewer'), | |
| // Sidebars | |
| leftSidebar: document.getElementById('leftSidebar'), | |
| rightSidebar: document.getElementById('rightSidebar'), | |
| leftToggle: document.getElementById('leftToggle'), | |
| rightToggle: document.getElementById('rightToggle'), | |
| // App | |
| appContainer: document.getElementById('appContainer'), | |
| userName: document.getElementById('userName'), | |
| userAvatar: document.getElementById('userAvatar'), | |
| userRole: document.getElementById('userRole'), | |
| logoutBtn: document.getElementById('logoutBtn'), | |
| // Admin | |
| adminSection: document.getElementById('adminSection'), | |
| employeesList: document.getElementById('employeesList'), | |
| // Buckets | |
| bucketsList: document.getElementById('bucketsList'), | |
| // Custom Dropdowns | |
| uploadBucketWrapper: document.getElementById('uploadBucketWrapper'), | |
| uploadBucketTrigger: document.getElementById('uploadBucketTrigger'), | |
| uploadBucketOptions: document.getElementById('uploadBucketOptions'), | |
| uploadBucketSelect: document.getElementById('uploadBucketSelect'), | |
| chatBucketWrapper: document.getElementById('chatBucketWrapper'), | |
| chatBucketTrigger: document.getElementById('chatBucketTrigger'), | |
| chatBucketOptions: document.getElementById('chatBucketOptions'), | |
| chatBucketSelect: document.getElementById('chatBucketSelect'), | |
| // Upload | |
| uploadZone: document.getElementById('uploadZone'), | |
| fileInput: document.getElementById('fileInput'), | |
| uploadProgress: document.getElementById('uploadProgress'), | |
| uploadStatus: document.getElementById('uploadStatus'), | |
| progressFill: document.getElementById('progressFill'), | |
| cancelUploadBtn: document.getElementById('cancelUploadBtn'), | |
| // Documents | |
| documentsList: document.getElementById('documentsList'), | |
| docCount: document.getElementById('docCount'), | |
| // Chat | |
| chatMessages: document.getElementById('chatMessages'), | |
| welcomeScreen: document.getElementById('welcomeScreen'), | |
| chatInput: document.getElementById('chatInput'), | |
| sendBtn: document.getElementById('sendBtn'), | |
| stopBtn: document.getElementById('stopBtn'), | |
| typingIndicator: document.getElementById('typingIndicator'), | |
| toastContainer: document.getElementById('toastContainer'), | |
| // Summary Panel | |
| summaryPanel: document.getElementById('summaryPanel'), | |
| summaryTitle: document.getElementById('summaryTitle'), | |
| summaryText: document.getElementById('summaryText'), | |
| summaryClose: document.getElementById('summaryClose'), | |
| // Chat History | |
| newChatBtn: document.getElementById('newChatBtn'), | |
| clearChatBtn: document.getElementById('clearChatBtn'), | |
| clearChatBtnTop: document.getElementById('clearChatBtnTop'), | |
| chatHistoryList: document.getElementById('chatHistoryList'), | |
| chatHistoryCount: document.getElementById('chatHistoryCount'), | |
| // Mobile Navigation | |
| mobileNav: document.getElementById('mobileNav'), | |
| mobileBackdrop: document.getElementById('mobileBackdrop'), | |
| mobileLeftToggle: document.getElementById('mobileLeftToggle'), | |
| mobileChatToggle: document.getElementById('mobileChatToggle'), | |
| mobileRightToggle: document.getElementById('mobileRightToggle') | |
| }; | |
| // ==================== Toast ==================== | |
| function showToast(message, type = 'info') { | |
| const icons = { success: '✅', error: '❌', info: 'ℹ️' }; | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.innerHTML = `<span class="toast-icon">${icons[type]}</span><span class="toast-message">${message}</span><button class="toast-close">✕</button>`; | |
| elements.toastContainer.appendChild(toast); | |
| toast.querySelector('.toast-close').addEventListener('click', () => toast.remove()); | |
| setTimeout(() => { if (toast.parentElement) toast.remove(); }, 4000); | |
| } | |
| // ==================== Sidebar Toggle ==================== | |
| function initSidebars() { | |
| elements.leftToggle.addEventListener('click', () => { | |
| elements.leftSidebar.classList.toggle('collapsed'); | |
| const icon = elements.leftToggle.querySelector('.toggle-icon'); | |
| icon.textContent = elements.leftSidebar.classList.contains('collapsed') ? '▶' : '◀'; | |
| }); | |
| elements.rightToggle.addEventListener('click', () => { | |
| elements.rightSidebar.classList.toggle('collapsed'); | |
| const icon = elements.rightToggle.querySelector('.toggle-icon'); | |
| icon.textContent = elements.rightSidebar.classList.contains('collapsed') ? '◀' : '▶'; | |
| }); | |
| } | |
| // ==================== Mobile Navigation ==================== | |
| function initMobileNavigation() { | |
| // Check if we're on mobile | |
| const isMobile = () => window.innerWidth <= 768; | |
| // Close all sidebars on mobile | |
| function closeMobileSidebars() { | |
| elements.leftSidebar.classList.remove('mobile-open'); | |
| elements.rightSidebar.classList.remove('mobile-open'); | |
| elements.mobileBackdrop.classList.remove('active'); | |
| document.body.style.overflow = ''; | |
| // Reset nav button active states | |
| elements.mobileLeftToggle.classList.remove('active'); | |
| elements.mobileRightToggle.classList.remove('active'); | |
| elements.mobileChatToggle.classList.add('active'); | |
| } | |
| // Open left sidebar (Menu) | |
| function openLeftSidebar() { | |
| closeMobileSidebars(); | |
| elements.leftSidebar.classList.add('mobile-open'); | |
| elements.mobileBackdrop.classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| elements.mobileLeftToggle.classList.add('active'); | |
| elements.mobileChatToggle.classList.remove('active'); | |
| } | |
| // Open right sidebar (Docs) | |
| function openRightSidebar() { | |
| closeMobileSidebars(); | |
| elements.rightSidebar.classList.add('mobile-open'); | |
| elements.mobileBackdrop.classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| elements.mobileRightToggle.classList.add('active'); | |
| elements.mobileChatToggle.classList.remove('active'); | |
| } | |
| // Mobile nav button handlers | |
| elements.mobileLeftToggle.addEventListener('click', () => { | |
| if (elements.leftSidebar.classList.contains('mobile-open')) { | |
| closeMobileSidebars(); | |
| } else { | |
| openLeftSidebar(); | |
| } | |
| }); | |
| elements.mobileChatToggle.addEventListener('click', () => { | |
| closeMobileSidebars(); | |
| }); | |
| elements.mobileRightToggle.addEventListener('click', () => { | |
| if (elements.rightSidebar.classList.contains('mobile-open')) { | |
| closeMobileSidebars(); | |
| } else { | |
| openRightSidebar(); | |
| } | |
| }); | |
| // Close sidebar when backdrop is clicked | |
| elements.mobileBackdrop.addEventListener('click', closeMobileSidebars); | |
| // Close sidebar on window resize to desktop | |
| window.addEventListener('resize', () => { | |
| if (!isMobile()) { | |
| closeMobileSidebars(); | |
| // Reset any mobile-specific classes | |
| elements.leftSidebar.classList.remove('mobile-open'); | |
| elements.rightSidebar.classList.remove('mobile-open'); | |
| } | |
| }); | |
| // Close sidebar when starting a new chat or after uploading (for better UX) | |
| const originalStartNewChat = window.startNewChat; | |
| if (typeof originalStartNewChat === 'function') { | |
| window.startNewChat = function () { | |
| if (isMobile()) closeMobileSidebars(); | |
| return originalStartNewChat.apply(this, arguments); | |
| }; | |
| } | |
| // Handle swipe gestures (optional enhancement) | |
| let touchStartX = 0; | |
| let touchEndX = 0; | |
| document.addEventListener('touchstart', (e) => { | |
| touchStartX = e.changedTouches[0].screenX; | |
| }, { passive: true }); | |
| document.addEventListener('touchend', (e) => { | |
| if (!isMobile()) return; | |
| touchEndX = e.changedTouches[0].screenX; | |
| const swipeDistance = touchEndX - touchStartX; | |
| const minSwipeDistance = 80; | |
| // Swipe right from left edge - open left sidebar | |
| if (touchStartX < 30 && swipeDistance > minSwipeDistance) { | |
| openLeftSidebar(); | |
| } | |
| // Swipe left from right edge - open right sidebar | |
| if (touchStartX > window.innerWidth - 30 && swipeDistance < -minSwipeDistance) { | |
| openRightSidebar(); | |
| } | |
| // Swipe to close sidebars | |
| if (elements.leftSidebar.classList.contains('mobile-open') && swipeDistance < -minSwipeDistance) { | |
| closeMobileSidebars(); | |
| } | |
| if (elements.rightSidebar.classList.contains('mobile-open') && swipeDistance > minSwipeDistance) { | |
| closeMobileSidebars(); | |
| } | |
| }, { passive: true }); | |
| } | |
| // ==================== Collapsible Sections ==================== | |
| function initCollapsible() { | |
| document.querySelectorAll('.collapsible .section-header').forEach(header => { | |
| header.addEventListener('click', (e) => { | |
| // Don't toggle if clicking on action buttons | |
| if (e.target.closest('.btn')) return; | |
| const section = header.closest('.collapsible'); | |
| section.classList.toggle('collapsed'); | |
| }); | |
| }); | |
| } | |
| // ==================== Custom Dropdowns ==================== | |
| function initCustomDropdowns() { | |
| // Close dropdowns when clicking outside | |
| document.addEventListener('click', (e) => { | |
| document.querySelectorAll('.custom-select.open').forEach(select => { | |
| if (!select.contains(e.target)) { | |
| select.classList.remove('open'); | |
| } | |
| }); | |
| }); | |
| // Upload bucket dropdown | |
| elements.uploadBucketTrigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| elements.uploadBucketWrapper.classList.toggle('open'); | |
| elements.chatBucketWrapper.classList.remove('open'); | |
| }); | |
| // Chat bucket dropdown | |
| elements.chatBucketTrigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| elements.chatBucketWrapper.classList.toggle('open'); | |
| elements.uploadBucketWrapper.classList.remove('open'); | |
| }); | |
| } | |
| function updateDropdownOptions() { | |
| // Upload dropdown options | |
| let uploadOptions = `<div class="select-option active" data-value=""><span class="option-icon">📂</span> No Bucket (General)</div>`; | |
| uploadOptions += state.buckets.map(b => | |
| `<div class="select-option" data-value="${b.bucket_id}"><span class="option-icon">📁</span> ${b.name}</div>` | |
| ).join(''); | |
| elements.uploadBucketOptions.innerHTML = uploadOptions; | |
| // Chat dropdown options | |
| let chatOptions = `<div class="select-option active" data-value=""><span class="option-icon">📂</span> All Documents</div>`; | |
| chatOptions += state.buckets.map(b => | |
| `<div class="select-option" data-value="${b.bucket_id}"><span class="option-icon">📁</span> ${b.name}</div>` | |
| ).join(''); | |
| elements.chatBucketOptions.innerHTML = chatOptions; | |
| // Add click handlers | |
| elements.uploadBucketOptions.querySelectorAll('.select-option').forEach(opt => { | |
| opt.addEventListener('click', () => { | |
| const value = opt.dataset.value; | |
| elements.uploadBucketSelect.value = value; | |
| elements.uploadBucketTrigger.querySelector('.select-value').textContent = opt.textContent.trim(); | |
| elements.uploadBucketOptions.querySelectorAll('.select-option').forEach(o => o.classList.remove('active')); | |
| opt.classList.add('active'); | |
| elements.uploadBucketWrapper.classList.remove('open'); | |
| }); | |
| }); | |
| elements.chatBucketOptions.querySelectorAll('.select-option').forEach(opt => { | |
| opt.addEventListener('click', () => { | |
| const value = opt.dataset.value; | |
| elements.chatBucketSelect.value = value; | |
| state.chatBucket = value; | |
| elements.chatBucketTrigger.querySelector('.select-value').textContent = opt.textContent.trim(); | |
| elements.chatBucketOptions.querySelectorAll('.select-option').forEach(o => o.classList.remove('active')); | |
| opt.classList.add('active'); | |
| elements.chatBucketWrapper.classList.remove('open'); | |
| }); | |
| }); | |
| } | |
| // ==================== Auth ==================== | |
| function showAuthModal() { | |
| elements.authModal.classList.add('active'); | |
| elements.appContainer.style.filter = 'blur(5px)'; | |
| } | |
| function hideAuthModal() { | |
| elements.authModal.classList.remove('active'); | |
| elements.appContainer.style.filter = ''; | |
| } | |
| function updateAuthUI() { | |
| if (state.user) { | |
| elements.userName.textContent = state.user.username; | |
| elements.userAvatar.textContent = state.user.username.charAt(0).toUpperCase(); | |
| elements.userRole.textContent = state.user.role === 'admin' ? 'Admin' : 'Employee'; | |
| if (state.user.role === 'admin') { | |
| elements.adminSection.classList.remove('hidden'); | |
| loadEmployees(); | |
| } else { | |
| elements.adminSection.classList.add('hidden'); | |
| } | |
| hideAuthModal(); | |
| } else { | |
| showAuthModal(); | |
| } | |
| } | |
| // Role tabs | |
| document.querySelectorAll('.role-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.role-tab').forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| state.currentRole = tab.dataset.role; | |
| if (state.currentRole === 'admin') { | |
| elements.authTabs.classList.remove('hidden'); | |
| elements.loginForm.classList.remove('hidden'); | |
| elements.registerForm.classList.add('hidden'); | |
| elements.employeeLoginForm.classList.add('hidden'); | |
| } else { | |
| elements.authTabs.classList.add('hidden'); | |
| elements.loginForm.classList.add('hidden'); | |
| elements.registerForm.classList.add('hidden'); | |
| elements.employeeLoginForm.classList.remove('hidden'); | |
| } | |
| }); | |
| }); | |
| // Auth tabs | |
| document.querySelectorAll('.auth-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| const tabName = tab.dataset.tab; | |
| elements.loginForm.classList.toggle('hidden', tabName !== 'login'); | |
| elements.registerForm.classList.toggle('hidden', tabName !== 'register'); | |
| }); | |
| }); | |
| // Admin Login | |
| elements.loginForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const btn = e.target.querySelector('.auth-btn'); | |
| btn.querySelector('.btn-text').classList.add('hidden'); | |
| btn.querySelector('.btn-loader').classList.remove('hidden'); | |
| elements.loginError.classList.add('hidden'); | |
| try { | |
| const response = await fetch('/api/auth/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username: formData.get('username'), password: formData.get('password'), role: 'admin' }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| state.token = data.token; | |
| state.user = { user_id: data.user_id, username: data.username, role: data.role }; | |
| localStorage.setItem('Iribl AI_token', state.token); | |
| localStorage.setItem('Iribl AI_user', JSON.stringify(state.user)); | |
| updateAuthUI(); | |
| loadBuckets(); | |
| loadDocuments(); | |
| loadChatHistoryFromServer(); | |
| showToast('Welcome back!', 'success'); | |
| } else { | |
| elements.loginError.textContent = data.error; | |
| elements.loginError.classList.remove('hidden'); | |
| } | |
| } catch (error) { | |
| elements.loginError.textContent = 'Connection error'; | |
| elements.loginError.classList.remove('hidden'); | |
| } | |
| btn.querySelector('.btn-text').classList.remove('hidden'); | |
| btn.querySelector('.btn-loader').classList.add('hidden'); | |
| }); | |
| // Admin Register | |
| elements.registerForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const btn = e.target.querySelector('.auth-btn'); | |
| btn.querySelector('.btn-text').classList.add('hidden'); | |
| btn.querySelector('.btn-loader').classList.remove('hidden'); | |
| elements.registerError.classList.add('hidden'); | |
| try { | |
| const response = await fetch('/api/auth/register/admin', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username: formData.get('username'), email: formData.get('email'), password: formData.get('password') }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| state.token = data.token; | |
| state.user = { user_id: data.user_id, username: data.username, role: data.role }; | |
| localStorage.setItem('Iribl AI_token', state.token); | |
| localStorage.setItem('Iribl AI_user', JSON.stringify(state.user)); | |
| updateAuthUI(); | |
| loadBuckets(); | |
| loadDocuments(); | |
| loadChatHistoryFromServer(); | |
| showToast('Account created!', 'success'); | |
| } else { | |
| elements.registerError.textContent = data.error; | |
| elements.registerError.classList.remove('hidden'); | |
| } | |
| } catch (error) { | |
| elements.registerError.textContent = 'Connection error'; | |
| elements.registerError.classList.remove('hidden'); | |
| } | |
| btn.querySelector('.btn-text').classList.remove('hidden'); | |
| btn.querySelector('.btn-loader').classList.add('hidden'); | |
| }); | |
| // Employee Login | |
| elements.employeeLoginForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const btn = e.target.querySelector('.auth-btn'); | |
| btn.querySelector('.btn-text').classList.add('hidden'); | |
| btn.querySelector('.btn-loader').classList.remove('hidden'); | |
| elements.employeeLoginError.classList.add('hidden'); | |
| try { | |
| const response = await fetch('/api/auth/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username: formData.get('email'), password: formData.get('password'), role: 'employee' }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| state.token = data.token; | |
| state.user = { user_id: data.user_id, username: data.username, role: data.role }; | |
| localStorage.setItem('Iribl AI_token', state.token); | |
| localStorage.setItem('Iribl AI_user', JSON.stringify(state.user)); | |
| updateAuthUI(); | |
| loadBuckets(); | |
| loadDocuments(); | |
| loadChatHistoryFromServer(); | |
| showToast('Welcome!', 'success'); | |
| } else { | |
| elements.employeeLoginError.textContent = data.error; | |
| elements.employeeLoginError.classList.remove('hidden'); | |
| } | |
| } catch (error) { | |
| elements.employeeLoginError.textContent = 'Connection error'; | |
| elements.employeeLoginError.classList.remove('hidden'); | |
| } | |
| btn.querySelector('.btn-text').classList.remove('hidden'); | |
| btn.querySelector('.btn-loader').classList.add('hidden'); | |
| }); | |
| // Logout | |
| elements.logoutBtn.addEventListener('click', () => { | |
| state.token = null; | |
| state.user = null; | |
| state.documents = []; | |
| state.buckets = []; | |
| state.messages = []; | |
| localStorage.removeItem('Iribl AI_token'); | |
| localStorage.removeItem('Iribl AI_user'); | |
| updateAuthUI(); | |
| renderDocuments(); | |
| renderMessages(); | |
| showToast('Logged out', 'info'); | |
| }); | |
| // ==================== Employees ==================== | |
| async function loadEmployees() { | |
| if (!state.token || state.user?.role !== 'admin') return; | |
| try { | |
| const response = await fetch('/api/admin/employees', { headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| state.employees = data.employees; | |
| renderEmployees(); | |
| } | |
| } catch (error) { console.error('Failed to load employees:', error); } | |
| } | |
| function renderEmployees() { | |
| if (state.employees.length === 0) { | |
| elements.employeesList.innerHTML = `<div class="empty-state small"><div class="empty-text">No employees</div></div>`; | |
| return; | |
| } | |
| elements.employeesList.innerHTML = state.employees.map(emp => ` | |
| <div class="employee-item"> | |
| <span class="employee-email">${emp.email || emp.username}</span> | |
| <button class="btn btn-ghost" onclick="deleteEmployee('${emp.user_id}')" title="Remove">🗑️</button> | |
| </div> | |
| `).join(''); | |
| } | |
| elements.addEmployeeBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| elements.addEmployeeModal.classList.add('active'); | |
| elements.addEmployeeError.classList.add('hidden'); | |
| elements.addEmployeeForm.reset(); | |
| }); | |
| elements.cancelAddEmployee.addEventListener('click', () => elements.addEmployeeModal.classList.remove('active')); | |
| elements.addEmployeeForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const btn = e.target.querySelector('.btn-primary'); | |
| btn.querySelector('.btn-text').classList.add('hidden'); | |
| btn.querySelector('.btn-loader').classList.remove('hidden'); | |
| try { | |
| const response = await fetch('/api/admin/employees', { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${state.token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ email: formData.get('email'), password: formData.get('password') }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| elements.addEmployeeModal.classList.remove('active'); | |
| loadEmployees(); | |
| showToast('Employee added!', 'success'); | |
| } else { | |
| elements.addEmployeeError.textContent = data.error; | |
| elements.addEmployeeError.classList.remove('hidden'); | |
| } | |
| } catch (error) { | |
| elements.addEmployeeError.textContent = 'Connection error'; | |
| elements.addEmployeeError.classList.remove('hidden'); | |
| } | |
| btn.querySelector('.btn-text').classList.remove('hidden'); | |
| btn.querySelector('.btn-loader').classList.add('hidden'); | |
| }); | |
| async function deleteEmployee(employeeId) { | |
| try { | |
| const response = await fetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| state.employees = state.employees.filter(e => e.user_id !== employeeId); | |
| renderEmployees(); | |
| showToast('Employee removed', 'success'); | |
| } | |
| } catch (error) { showToast('Failed to remove employee', 'error'); } | |
| } | |
| // ==================== Buckets ==================== | |
| async function loadBuckets() { | |
| if (!state.token) return; | |
| try { | |
| const response = await fetch('/api/buckets', { headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| state.buckets = data.buckets; | |
| renderBuckets(); | |
| updateDropdownOptions(); | |
| } | |
| } catch (error) { console.error('Failed to load buckets:', error); } | |
| } | |
| function renderBuckets() { | |
| let html = `<div class="bucket-item ${state.selectedBucket === '' ? 'active' : ''}" onclick="selectBucket('')"> | |
| <span class="bucket-name">📂 All Documents</span> | |
| </div>`; | |
| html += state.buckets.map(b => ` | |
| <div class="bucket-item ${state.selectedBucket === b.bucket_id ? 'active' : ''}" data-id="${b.bucket_id}"> | |
| <span class="bucket-name" onclick="selectBucket('${b.bucket_id}')">📁 ${b.name}</span> | |
| <span class="bucket-count">${b.doc_count}</span> | |
| <button class="btn btn-ghost bucket-delete" onclick="event.stopPropagation(); deleteBucket('${b.bucket_id}')">🗑️</button> | |
| </div> | |
| `).join(''); | |
| elements.bucketsList.innerHTML = html; | |
| } | |
| function selectBucket(bucketId) { | |
| state.selectedBucket = bucketId; | |
| state.chatBucket = bucketId; // Sync chat bucket filter | |
| // Get bucket name for display | |
| const bucketName = bucketId ? | |
| (state.buckets.find(b => b.bucket_id === bucketId)?.name || 'Selected Bucket') : | |
| ''; | |
| const displayName = bucketId ? bucketName : 'All Documents'; | |
| const uploadDisplayName = bucketId ? bucketName : 'No Bucket (General)'; | |
| // Sync upload bucket dropdown | |
| elements.uploadBucketSelect.value = bucketId; | |
| elements.uploadBucketTrigger.querySelector('.select-value').textContent = uploadDisplayName; | |
| elements.uploadBucketOptions.querySelectorAll('.select-option').forEach(opt => { | |
| opt.classList.toggle('active', opt.dataset.value === bucketId); | |
| }); | |
| // Sync chat bucket dropdown | |
| elements.chatBucketSelect.value = bucketId; | |
| elements.chatBucketTrigger.querySelector('.select-value').textContent = displayName; | |
| elements.chatBucketOptions.querySelectorAll('.select-option').forEach(opt => { | |
| opt.classList.toggle('active', opt.dataset.value === bucketId); | |
| }); | |
| // Render all filtered components | |
| renderBuckets(); | |
| loadDocuments(); | |
| renderChatHistory(); // Re-render to filter by bucket | |
| } | |
| elements.createBucketBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| elements.createBucketModal.classList.add('active'); | |
| elements.createBucketError.classList.add('hidden'); | |
| elements.createBucketForm.reset(); | |
| }); | |
| elements.cancelCreateBucket.addEventListener('click', () => elements.createBucketModal.classList.remove('active')); | |
| elements.createBucketForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const btn = e.target.querySelector('.btn-primary'); | |
| btn.querySelector('.btn-text').classList.add('hidden'); | |
| btn.querySelector('.btn-loader').classList.remove('hidden'); | |
| try { | |
| const response = await fetch('/api/buckets', { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${state.token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name: formData.get('name'), description: formData.get('description') }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| elements.createBucketModal.classList.remove('active'); | |
| loadBuckets(); | |
| showToast('Bucket created!', 'success'); | |
| } else { | |
| elements.createBucketError.textContent = data.error; | |
| elements.createBucketError.classList.remove('hidden'); | |
| } | |
| } catch (error) { | |
| elements.createBucketError.textContent = 'Connection error'; | |
| elements.createBucketError.classList.remove('hidden'); | |
| } | |
| btn.querySelector('.btn-text').classList.remove('hidden'); | |
| btn.querySelector('.btn-loader').classList.add('hidden'); | |
| }); | |
| async function deleteBucket(bucketId) { | |
| try { | |
| const response = await fetch(`/api/buckets/${bucketId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| if (state.selectedBucket === bucketId) state.selectedBucket = ''; | |
| loadBuckets(); | |
| loadDocuments(); | |
| showToast('Bucket deleted', 'success'); | |
| } | |
| } catch (error) { showToast('Failed to delete bucket', 'error'); } | |
| } | |
| // ==================== Documents ==================== | |
| async function loadDocuments() { | |
| if (!state.token) return; | |
| try { | |
| let url = '/api/documents'; | |
| if (state.selectedBucket) url += `?bucket_id=${state.selectedBucket}`; | |
| const response = await fetch(url, { headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| state.documents = data.documents; | |
| renderDocuments(); | |
| } | |
| } catch (error) { console.error('Failed to load documents:', error); } | |
| } | |
| function renderDocuments() { | |
| elements.docCount.textContent = `(${state.documents.length})`; | |
| if (state.documents.length === 0) { | |
| elements.documentsList.innerHTML = `<div class="empty-state"><div class="empty-icon">📭</div><div class="empty-text">No documents yet</div></div>`; | |
| return; | |
| } | |
| const icons = { pdf: '📕', word: '📘', powerpoint: '📙', excel: '📗', image: '🖼️', text: '📄' }; | |
| elements.documentsList.innerHTML = state.documents.map(doc => ` | |
| <div class="document-item ${state.selectedDocument === doc.doc_id ? 'selected' : ''}" data-id="${doc.doc_id}" onclick="selectDocument('${doc.doc_id}')"> | |
| <div class="doc-icon">${icons[doc.doc_type] || '📄'}</div> | |
| <div class="doc-info"> | |
| <div class="doc-name">${doc.filename}</div> | |
| <div class="doc-meta">${formatDate(doc.created_at)}</div> | |
| </div> | |
| <button class="btn btn-ghost doc-view" onclick="event.stopPropagation(); viewDocument('${doc.doc_id}', '${doc.filename}')" title="View">👁️</button> | |
| <button class="btn btn-ghost doc-delete" onclick="event.stopPropagation(); deleteDocument('${doc.doc_id}')" title="Delete">🗑️</button> | |
| </div> | |
| `).join(''); | |
| } | |
| function formatDate(timestamp) { | |
| const date = new Date(timestamp * 1000); | |
| const now = new Date(); | |
| const diff = now - date; | |
| if (diff < 60000) return 'Just now'; | |
| if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; | |
| if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; | |
| return date.toLocaleDateString(); | |
| } | |
| async function deleteDocument(docId) { | |
| try { | |
| const response = await fetch(`/api/documents/${docId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| state.documents = state.documents.filter(d => d.doc_id !== docId); | |
| // Clear selection if deleted doc was selected | |
| if (state.selectedDocument === docId) { | |
| state.selectedDocument = null; | |
| hideSummary(); | |
| } | |
| // Remove from summaries cache | |
| delete state.summaries[docId]; | |
| renderDocuments(); | |
| loadBuckets(); | |
| showToast('Document deleted', 'success'); | |
| } | |
| } catch (error) { showToast('Failed to delete', 'error'); } | |
| } | |
| // ==================== Document Summary ==================== | |
| function selectDocument(docId) { | |
| state.selectedDocument = docId; | |
| renderDocuments(); | |
| displaySummary(docId); | |
| } | |
| async function displaySummary(docId) { | |
| const doc = state.documents.find(d => d.doc_id === docId); | |
| if (!doc) return; | |
| // Check if summary is cached | |
| if (state.summaries[docId]) { | |
| showSummaryPanel(doc.filename, state.summaries[docId].summary); | |
| } else { | |
| // Show loading state | |
| showSummaryPanel(doc.filename, 'Generating summary...'); | |
| // Fetch summary from server | |
| await fetchSummary(docId); | |
| } | |
| } | |
| async function fetchSummary(docId) { | |
| try { | |
| const response = await fetch(`/api/documents/${docId}/summary`, { | |
| headers: { 'Authorization': `Bearer ${state.token}` } | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.summary) { | |
| // Cache the summary | |
| state.summaries[docId] = { | |
| summary: data.summary, | |
| filename: data.filename | |
| }; | |
| // Update display if still selected | |
| if (state.selectedDocument === docId) { | |
| showSummaryPanel(data.filename, data.summary); | |
| } | |
| } else { | |
| // Show error state | |
| if (state.selectedDocument === docId) { | |
| showSummaryPanel(data.filename || 'Document', 'Unable to generate summary.'); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch summary:', error); | |
| if (state.selectedDocument === docId) { | |
| const doc = state.documents.find(d => d.doc_id === docId); | |
| showSummaryPanel(doc?.filename || 'Document', 'Failed to load summary.'); | |
| } | |
| } | |
| } | |
| function showSummaryPanel(filename, summaryText) { | |
| elements.summaryPanel.classList.remove('hidden'); | |
| elements.summaryTitle.textContent = filename; | |
| elements.summaryText.textContent = summaryText; | |
| } | |
| function hideSummary() { | |
| elements.summaryPanel.classList.add('hidden'); | |
| state.selectedDocument = null; | |
| renderDocuments(); | |
| } | |
| function initSummaryPanel() { | |
| elements.summaryClose.addEventListener('click', hideSummary); | |
| } | |
| // ==================== Document Viewer ==================== | |
| async function viewDocument(docId, filename) { | |
| try { | |
| // Fetch the document with proper authorization | |
| const response = await fetch(`/api/documents/${docId}/view`, { | |
| headers: { 'Authorization': `Bearer ${state.token}` } | |
| }); | |
| if (!response.ok) { | |
| showToast('Failed to load document', 'error'); | |
| return; | |
| } | |
| // Get the blob and create a URL | |
| const blob = await response.blob(); | |
| const blobUrl = URL.createObjectURL(blob); | |
| // Open in a new tab | |
| window.open(blobUrl, '_blank'); | |
| } catch (error) { | |
| console.error('Failed to view document:', error); | |
| showToast('Failed to open document', 'error'); | |
| } | |
| } | |
| elements.closeDocViewer.addEventListener('click', () => elements.docViewerModal.classList.remove('active')); | |
| // ==================== Upload ==================== | |
| let currentPollInterval = null; // Track the current polling interval for cancellation | |
| function initUpload() { | |
| elements.uploadZone.addEventListener('click', () => elements.fileInput.click()); | |
| elements.fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) uploadFiles(Array.from(e.target.files)); | |
| }); | |
| elements.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); elements.uploadZone.classList.add('dragover'); }); | |
| elements.uploadZone.addEventListener('dragleave', () => elements.uploadZone.classList.remove('dragover')); | |
| elements.uploadZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| elements.uploadZone.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length > 0) uploadFiles(Array.from(e.dataTransfer.files)); | |
| }); | |
| // Cancel upload button | |
| elements.cancelUploadBtn.addEventListener('click', cancelUpload); | |
| } | |
| function cancelUpload() { | |
| state.uploadCancelled = true; | |
| // Abort any ongoing fetch request | |
| if (state.currentUploadAbortController) { | |
| state.currentUploadAbortController.abort(); | |
| state.currentUploadAbortController = null; | |
| } | |
| // Clear any polling interval | |
| if (currentPollInterval) { | |
| clearInterval(currentPollInterval); | |
| currentPollInterval = null; | |
| } | |
| // Reset UI | |
| elements.uploadProgress.classList.add('hidden'); | |
| elements.uploadZone.style.pointerEvents = ''; | |
| elements.fileInput.value = ''; | |
| elements.progressFill.style.width = '0%'; | |
| showToast('Upload cancelled', 'info'); | |
| } | |
| async function uploadFiles(files) { | |
| // Reset cancellation state | |
| state.uploadCancelled = false; | |
| elements.uploadProgress.classList.remove('hidden'); | |
| elements.uploadZone.style.pointerEvents = 'none'; | |
| const bucketId = elements.uploadBucketSelect.value; | |
| let completed = 0; | |
| // Process files sequentially to avoid overwhelming the client, | |
| // but the server handles them in background. | |
| for (const file of files) { | |
| // Check if cancelled before processing each file | |
| if (state.uploadCancelled) { | |
| break; | |
| } | |
| elements.uploadStatus.textContent = `Uploading ${file.name}...`; | |
| elements.progressFill.style.width = '10%'; // Initial progress | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('bucket_id', bucketId); | |
| // Create abort controller for this request | |
| state.currentUploadAbortController = new AbortController(); | |
| try { | |
| // Initial upload request | |
| const response = await fetch('/api/documents/upload', { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${state.token}` }, | |
| body: formData, | |
| signal: state.currentUploadAbortController.signal | |
| }); | |
| if (response.status === 202) { | |
| // Async processing started | |
| const data = await response.json(); | |
| await pollUploadStatus(data.doc_id, file.name); | |
| if (!state.uploadCancelled) { | |
| completed++; | |
| } | |
| } else if (response.ok) { | |
| // Instant completion (legacy or small file) | |
| const data = await response.json(); | |
| handleUploadSuccess(data); | |
| completed++; | |
| } else { | |
| const data = await response.json(); | |
| showToast(`Failed: ${file.name} - ${data.error}`, 'error'); | |
| } | |
| } catch (e) { | |
| if (e.name === 'AbortError') { | |
| // Upload was cancelled by user | |
| break; | |
| } | |
| console.error(e); | |
| showToast(`Failed to upload ${file.name}`, 'error'); | |
| } | |
| } | |
| // Clean up abort controller | |
| state.currentUploadAbortController = null; | |
| // Only update UI if not cancelled (cancelUpload already handles UI reset) | |
| if (!state.uploadCancelled) { | |
| elements.uploadProgress.classList.add('hidden'); | |
| elements.uploadZone.style.pointerEvents = ''; | |
| elements.fileInput.value = ''; | |
| elements.progressFill.style.width = '0%'; | |
| // Load documents first, then show summary | |
| await loadDocuments(); | |
| loadBuckets(); | |
| } | |
| } | |
| async function pollUploadStatus(docId, filename) { | |
| return new Promise((resolve, reject) => { | |
| currentPollInterval = setInterval(async () => { | |
| // Check if cancelled | |
| if (state.uploadCancelled) { | |
| clearInterval(currentPollInterval); | |
| currentPollInterval = null; | |
| resolve(); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/documents/${docId}/status`, { | |
| headers: { 'Authorization': `Bearer ${state.token}` } | |
| }); | |
| if (response.ok) { | |
| const statusData = await response.json(); | |
| // Update UI | |
| elements.uploadStatus.textContent = `Processing ${filename}: ${statusData.message || '...'}`; | |
| // Map 0-100 progress to UI width (keeping 10% buffer) | |
| if (statusData.progress) { | |
| elements.progressFill.style.width = `${Math.max(10, statusData.progress)}%`; | |
| } | |
| if (statusData.status === 'completed') { | |
| clearInterval(currentPollInterval); | |
| currentPollInterval = null; | |
| if (statusData.result) { | |
| handleUploadSuccess(statusData.result); | |
| } | |
| resolve(); | |
| } else if (statusData.status === 'failed') { | |
| clearInterval(currentPollInterval); | |
| currentPollInterval = null; | |
| showToast(`Processing failed: ${filename} - ${statusData.error}`, 'error'); | |
| resolve(); // Resolve anyway to continue with next file | |
| } | |
| } else { | |
| // Status check failed - might be network glitch, ignore once | |
| } | |
| } catch (e) { | |
| console.error("Polling error", e); | |
| // Continue polling despite error | |
| } | |
| }, 2000); // Check every 2 seconds | |
| }); | |
| } | |
| function handleUploadSuccess(data) { | |
| showToast(`Ready: ${data.filename}`, 'success'); | |
| // Cache the summary | |
| if (data.summary) { | |
| state.summaries[data.doc_id] = { | |
| summary: data.summary, | |
| filename: data.filename | |
| }; | |
| } | |
| // Auto-display this document | |
| state.selectedDocument = data.doc_id; | |
| // We will re-render documents shortly after this returns | |
| if (data.summary) { | |
| // Defer slightly to ensure DOM is ready if needed | |
| setTimeout(() => { | |
| showSummaryPanel(data.filename, data.summary); | |
| }, 500); | |
| } | |
| } | |
| // ==================== Chat ==================== | |
| function initChat() { | |
| elements.chatInput.addEventListener('input', () => { | |
| elements.chatInput.style.height = 'auto'; | |
| elements.chatInput.style.height = Math.min(elements.chatInput.scrollHeight, 150) + 'px'; | |
| elements.sendBtn.disabled = !elements.chatInput.value.trim(); | |
| }); | |
| elements.chatInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }); | |
| elements.sendBtn.addEventListener('click', sendMessage); | |
| // Stop generation button | |
| elements.stopBtn.addEventListener('click', stopGeneration); | |
| } | |
| function stopGeneration() { | |
| if (state.streamAbortController) { | |
| state.streamAbortController.abort(); | |
| state.streamAbortController = null; | |
| } | |
| // Hide stop button, show send button | |
| elements.stopBtn.classList.add('hidden'); | |
| elements.sendBtn.classList.remove('hidden'); | |
| elements.typingIndicator.classList.add('hidden'); | |
| state.isLoading = false; | |
| // Add a note that generation was stopped | |
| if (state.messages.length > 0) { | |
| const lastMsg = state.messages[state.messages.length - 1]; | |
| if (lastMsg.role === 'assistant' && lastMsg.content) { | |
| lastMsg.content += '\n\n*[Generation stopped]*'; | |
| renderMessages(); | |
| saveCurrentChat(); | |
| } | |
| } | |
| showToast('Generation stopped', 'info'); | |
| } | |
| async function sendMessage() { | |
| const message = elements.chatInput.value.trim(); | |
| if (!message || state.isLoading) return; | |
| elements.chatInput.value = ''; | |
| elements.chatInput.style.height = 'auto'; | |
| elements.sendBtn.disabled = true; | |
| elements.welcomeScreen.classList.add('hidden'); | |
| // Create a chat ID if this is the first message | |
| if (state.messages.length === 0 && !state.currentChatId) { | |
| state.currentChatId = Date.now().toString(); | |
| } | |
| const targetChatId = state.currentChatId; | |
| addMessage('user', message); | |
| elements.typingIndicator.classList.remove('hidden'); | |
| state.isLoading = true; | |
| scrollToBottom(); | |
| // Show stop button, hide send button | |
| elements.sendBtn.classList.add('hidden'); | |
| elements.stopBtn.classList.remove('hidden'); | |
| // Create abort controller for this request | |
| state.streamAbortController = new AbortController(); | |
| try { | |
| // Use streaming endpoint for instant response | |
| const response = await fetch('/api/chat/stream', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${state.token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| message: message, | |
| bucket_id: state.chatBucket || null, | |
| chat_id: state.currentChatId | |
| }), | |
| signal: state.streamAbortController.signal | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Stream request failed'); | |
| } | |
| elements.typingIndicator.classList.add('hidden'); | |
| // Create a placeholder message for streaming | |
| let streamingContent = ''; | |
| let sources = []; | |
| // Add empty assistant message and get reference to its content element | |
| state.messages.push({ role: 'assistant', content: '', sources: [] }); | |
| renderMessages(); | |
| scrollToBottom(); | |
| // Get direct reference to the streaming message element for fast updates | |
| const messageElements = elements.chatMessages.querySelectorAll('.message.assistant .message-content'); | |
| const streamingElement = messageElements[messageElements.length - 1]; | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| // Throttle DOM updates for smooth rendering (update every 50ms max) | |
| let lastUpdateTime = 0; | |
| let pendingUpdate = false; | |
| const UPDATE_INTERVAL = 50; // ms | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const text = decoder.decode(value); | |
| const lines = text.split('\n'); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| try { | |
| const data = JSON.parse(line.slice(6)); | |
| if (data.type === 'sources') { | |
| sources = data.sources || []; | |
| } else if (data.type === 'chunk' || data.type === 'content') { | |
| // Support both 'chunk' (legacy) and 'content' (specialized queries) | |
| streamingContent += data.content; | |
| // Update state for saving later | |
| state.messages[state.messages.length - 1].content = streamingContent; | |
| state.messages[state.messages.length - 1].sources = sources; | |
| // Throttled DOM update for smooth rendering | |
| const now = Date.now(); | |
| if (now - lastUpdateTime >= UPDATE_INTERVAL) { | |
| if (streamingElement) { | |
| streamingElement.innerHTML = formatContent(streamingContent); | |
| } | |
| lastUpdateTime = now; | |
| pendingUpdate = false; | |
| } else { | |
| pendingUpdate = true; | |
| } | |
| // No auto-scroll during streaming - stay at current position | |
| } else if (data.type === 'done') { | |
| // Final update with any pending content | |
| if (pendingUpdate && streamingElement) { | |
| streamingElement.innerHTML = formatContent(streamingContent); | |
| } | |
| // Streaming complete - do final render for proper formatting | |
| renderMessages(); | |
| saveCurrentChat(); | |
| // No auto-scroll - user stays at current position | |
| } else if (data.type === 'error') { | |
| state.messages[state.messages.length - 1].content = data.content || 'Error generating response'; | |
| renderMessages(); | |
| } | |
| } catch (e) { | |
| // Skip malformed JSON | |
| } | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| elements.typingIndicator.classList.add('hidden'); | |
| // Only show error if not aborted by user | |
| if (err.name !== 'AbortError') { | |
| addMessageToChat(targetChatId, 'assistant', 'Connection error. Please try again.'); | |
| } | |
| } | |
| // Cleanup: hide stop button, show send button | |
| elements.stopBtn.classList.add('hidden'); | |
| elements.sendBtn.classList.remove('hidden'); | |
| state.streamAbortController = null; | |
| state.isLoading = false; | |
| // No auto-scroll - user stays at current position | |
| } | |
| function addMessage(role, content, sources = []) { | |
| // Create a new chat ID if this is the first message | |
| if (state.messages.length === 0 && !state.currentChatId) { | |
| state.currentChatId = Date.now().toString(); | |
| } | |
| state.messages.push({ role, content, sources }); | |
| renderMessages(); | |
| // Auto-save after assistant responds (complete exchange) | |
| if (role === 'assistant') { | |
| saveCurrentChat(); | |
| } | |
| } | |
| // Add message to a specific chat (handles case where user switched chats during loading) | |
| function addMessageToChat(chatId, role, content, sources = []) { | |
| // If this is the current chat, add directly | |
| if (chatId === state.currentChatId) { | |
| state.messages.push({ role, content, sources }); | |
| renderMessages(); | |
| saveCurrentChat(); | |
| } else { | |
| // Add to the chat in history | |
| const chatIndex = state.chatHistory.findIndex(c => c.id === chatId); | |
| if (chatIndex >= 0) { | |
| state.chatHistory[chatIndex].messages.push({ role, content, sources }); | |
| saveChatHistory(); | |
| syncChatToServer(state.chatHistory[chatIndex]); | |
| renderChatHistory(); | |
| showToast('Response added to previous chat', 'info'); | |
| } | |
| } | |
| } | |
| function renderMessages() { | |
| // Preserve summary panel state before re-rendering | |
| const summaryVisible = !elements.summaryPanel.classList.contains('hidden'); | |
| const summaryTitle = elements.summaryTitle.textContent; | |
| const summaryText = elements.summaryText.textContent; | |
| if (state.messages.length === 0) { | |
| // Clear chat messages and show welcome screen | |
| elements.chatMessages.innerHTML = ''; | |
| elements.welcomeScreen.classList.remove('hidden'); | |
| elements.chatMessages.appendChild(elements.welcomeScreen); | |
| // Re-show summary if it was visible | |
| if (summaryVisible) { | |
| elements.summaryPanel.classList.remove('hidden'); | |
| } | |
| return; | |
| } | |
| elements.welcomeScreen.classList.add('hidden'); | |
| const html = state.messages.map((msg, i) => { | |
| const avatar = msg.role === 'user' ? (state.user?.username?.charAt(0).toUpperCase() || 'U') : '🧠'; | |
| const isUserMessage = msg.role === 'user'; | |
| return `<div class="message ${msg.role}"><div class="message-avatar">${avatar}</div><div class="message-content">${formatContent(msg.content, isUserMessage)}</div></div>`; | |
| }).join(''); | |
| // Build full content with summary panel and welcome screen | |
| const summaryPanelHTML = ` | |
| <div class="summary-panel ${summaryVisible ? '' : 'hidden'}" id="summaryPanel"> | |
| <div class="summary-header"> | |
| <span class="summary-icon">📄</span> | |
| <span class="summary-title" id="summaryTitle">${summaryTitle}</span> | |
| </div> | |
| <div class="summary-content" id="summaryContent"> | |
| <div class="summary-text" id="summaryText">${summaryText}</div> | |
| </div> | |
| <button class="summary-close" id="summaryClose" title="Close summary">✕</button> | |
| </div> | |
| `; | |
| elements.chatMessages.innerHTML = summaryPanelHTML + html + elements.welcomeScreen.outerHTML; | |
| document.getElementById('welcomeScreen')?.classList.add('hidden'); | |
| // Re-bind summary panel elements and event listener | |
| elements.summaryPanel = document.getElementById('summaryPanel'); | |
| elements.summaryTitle = document.getElementById('summaryTitle'); | |
| elements.summaryText = document.getElementById('summaryText'); | |
| elements.summaryClose = document.getElementById('summaryClose'); | |
| elements.summaryClose.addEventListener('click', hideSummary); | |
| } | |
| function formatContent(content, isUserMessage = false) { | |
| // Enhanced markdown parsing for beautiful formatting | |
| let html = content; | |
| // For user messages, escape HTML and preserve line breaks | |
| if (isUserMessage) { | |
| // Escape HTML to prevent XSS | |
| html = html | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| // Convert line breaks to <br> | |
| html = html.replace(/\n/g, '<br>'); | |
| return html; | |
| } | |
| // Escape HTML special characters first (except for already parsed markdown) | |
| // Skip this if content looks like it's already HTML | |
| if (!html.includes('<table') && !html.includes('<div')) { | |
| // Don't escape - let markdown do its thing | |
| } | |
| // Code blocks: ```code``` | |
| html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => { | |
| return `<pre class="code-block${lang ? ' lang-' + lang : ''}"><code>${code.trim()}</code></pre>`; | |
| }); | |
| // Tables: | Header | Header | | |
| html = html.replace(/(?:^|\n)(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => { | |
| const headers = headerRow.split('|').filter(cell => cell.trim()).map(cell => | |
| `<th>${cell.trim()}</th>` | |
| ).join(''); | |
| const rows = bodyRows.trim().split('\n').map(row => { | |
| const cells = row.split('|').filter(cell => cell.trim()).map(cell => | |
| `<td>${cell.trim()}</td>` | |
| ).join(''); | |
| return `<tr>${cells}</tr>`; | |
| }).join(''); | |
| return `<div class="table-wrapper"><table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table></div>`; | |
| }); | |
| // Headers: ### Header, ## Header, # Header | |
| html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>'); | |
| html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>'); | |
| html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>'); | |
| html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>'); | |
| // Bold headers at start of line (NotebookLM style) | |
| html = html.replace(/^(\*\*[^*]+\*\*):?\s*$/gm, '<h4>$1</h4>'); | |
| // Bold text: **text** | |
| html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); | |
| // Italic text: *text* | |
| html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>'); | |
| // Inline code: `code` | |
| html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>'); | |
| // Horizontal rule: --- or *** | |
| html = html.replace(/^[-*]{3,}$/gm, '<hr class="divider">'); | |
| // Numbered lists: 1. Item, 2. Item, etc. | |
| html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<li class="numbered"><span class="list-num">$1.</span> $2</li>'); | |
| // Bullet points: • Item or - Item or * Item at start of line | |
| html = html.replace(/^[\•\-\*]\s+(.+)$/gm, '<li class="bullet">$1</li>'); | |
| // Sub-bullets with indentation (2+ spaces before bullet) | |
| html = html.replace(/^[\s]{2,}[\•\-\*]\s+(.+)$/gm, '<li class="sub-bullet">$1</li>'); | |
| // Wrap consecutive numbered list items | |
| html = html.replace(/(<li class="numbered">[\s\S]*?<\/li>\n?)+/g, '<ol class="formatted-list">$&</ol>'); | |
| // Wrap consecutive bullet items | |
| html = html.replace(/(<li class="bullet">[\s\S]*?<\/li>\n?)+/g, '<ul class="formatted-list">$&</ul>'); | |
| // Wrap consecutive sub-bullet items | |
| html = html.replace(/(<li class="sub-bullet">[\s\S]*?<\/li>\n?)+/g, '<ul class="formatted-list sub-list">$&</ul>'); | |
| // Blockquotes: > text | |
| html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>'); | |
| // Merge consecutive blockquotes | |
| html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>'); | |
| // Double newlines become paragraph breaks | |
| html = html.replace(/\n\n+/g, '</p><p>'); | |
| // Single newlines become line breaks (but not inside lists) | |
| html = html.replace(/\n/g, '<br>'); | |
| // Clean up br tags in lists, headers, tables | |
| html = html.replace(/<br><li/g, '<li'); | |
| html = html.replace(/<\/li><br>/g, '</li>'); | |
| html = html.replace(/<br><h/g, '<h'); | |
| html = html.replace(/<\/h(\d)><br>/g, '</h$1>'); | |
| html = html.replace(/<br><ul/g, '<ul'); | |
| html = html.replace(/<br><ol/g, '<ol'); | |
| html = html.replace(/<\/ul><br>/g, '</ul>'); | |
| html = html.replace(/<\/ol><br>/g, '</ol>'); | |
| html = html.replace(/<br><table/g, '<table'); | |
| html = html.replace(/<\/table><br>/g, '</table>'); | |
| html = html.replace(/<br><div class="table/g, '<div class="table'); | |
| html = html.replace(/<\/div><br>/g, '</div>'); | |
| html = html.replace(/<br><pre/g, '<pre'); | |
| html = html.replace(/<\/pre><br>/g, '</pre>'); | |
| html = html.replace(/<br><hr/g, '<hr'); | |
| html = html.replace(/<hr[^>]*><br>/g, '<hr class="divider">'); | |
| html = html.replace(/<br><blockquote/g, '<blockquote'); | |
| html = html.replace(/<\/blockquote><br>/g, '</blockquote>'); | |
| // Wrap in paragraph | |
| html = '<p>' + html + '</p>'; | |
| // Clean up empty paragraphs | |
| html = html.replace(/<p><\/p>/g, ''); | |
| html = html.replace(/<p>(\s|<br>)*<\/p>/g, ''); | |
| html = html.replace(/<p><(h\d|ul|ol|table|div|pre|hr|blockquote)/g, '<$1'); | |
| html = html.replace(/<\/(h\d|ul|ol|table|div|pre|blockquote)><\/p>/g, '</$1>'); | |
| html = html.replace(/<p><hr/g, '<hr'); | |
| return html; | |
| } | |
| function scrollToBottom() { | |
| elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; | |
| } | |
| // ==================== Token Verification ==================== | |
| async function verifyToken() { | |
| if (!state.token) { showAuthModal(); return; } | |
| try { | |
| const response = await fetch('/api/auth/verify', { headers: { 'Authorization': `Bearer ${state.token}` } }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| state.user = data; | |
| localStorage.setItem('Iribl AI_user', JSON.stringify(state.user)); | |
| updateAuthUI(); | |
| loadBuckets(); | |
| loadDocuments(); | |
| // Load chat history from server database | |
| loadChatHistoryFromServer(); | |
| } else { | |
| state.token = null; | |
| state.user = null; | |
| localStorage.removeItem('Iribl AI_token'); | |
| localStorage.removeItem('Iribl AI_user'); | |
| showAuthModal(); | |
| } | |
| } catch { showAuthModal(); } | |
| } | |
| // ==================== Chat History ==================== | |
| function generateChatTopic(messages) { | |
| // Get the first user message as the topic | |
| const firstUserMsg = messages.find(m => m.role === 'user'); | |
| if (firstUserMsg) { | |
| // Truncate to first 40 chars | |
| let topic = firstUserMsg.content.substring(0, 40); | |
| if (firstUserMsg.content.length > 40) topic += '...'; | |
| return topic; | |
| } | |
| return 'New Conversation'; | |
| } | |
| function saveChatHistory() { | |
| localStorage.setItem('Iribl AI_chat_history', JSON.stringify(state.chatHistory)); | |
| } | |
| // Sync chat to server | |
| async function syncChatToServer(chatData) { | |
| if (!state.token) return; | |
| try { | |
| await fetch('/api/chats', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${state.token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(chatData) | |
| }); | |
| } catch (error) { | |
| console.error('Failed to sync chat to server:', error); | |
| } | |
| } | |
| // Load chat history from server | |
| async function loadChatHistoryFromServer() { | |
| if (!state.token) return; | |
| try { | |
| const response = await fetch('/api/chats', { | |
| headers: { 'Authorization': `Bearer ${state.token}` } | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (data.chats && data.chats.length > 0) { | |
| // Merge server chats with local (server takes priority) | |
| state.chatHistory = data.chats; | |
| saveChatHistory(); // Update local storage | |
| renderChatHistory(); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to load chats from server:', error); | |
| } | |
| } | |
| // Delete chat from server | |
| async function deleteChatFromServer(chatId) { | |
| if (!state.token) return; | |
| try { | |
| await fetch(`/api/chats/${chatId}`, { | |
| method: 'DELETE', | |
| headers: { 'Authorization': `Bearer ${state.token}` } | |
| }); | |
| } catch (error) { | |
| console.error('Failed to delete chat from server:', error); | |
| } | |
| } | |
| function saveCurrentChat() { | |
| // Only save if there are messages | |
| if (state.messages.length === 0) return null; | |
| const chatId = state.currentChatId || Date.now().toString(); | |
| const topic = generateChatTopic(state.messages); | |
| // Check if this chat already exists | |
| const existingIndex = state.chatHistory.findIndex(c => c.id === chatId); | |
| const chatData = { | |
| id: chatId, | |
| topic: topic, | |
| messages: [...state.messages], | |
| timestamp: Date.now(), | |
| bucket: state.chatBucket | |
| }; | |
| if (existingIndex >= 0) { | |
| // Update existing chat | |
| state.chatHistory[existingIndex] = chatData; | |
| } else { | |
| // Add new chat at the beginning | |
| state.chatHistory.unshift(chatData); | |
| } | |
| saveChatHistory(); | |
| renderChatHistory(); | |
| // Sync to server | |
| syncChatToServer(chatData); | |
| return chatId; | |
| } | |
| function startNewChat() { | |
| // Warn if AI is still generating | |
| if (state.isLoading) { | |
| showToast('AI is still responding - response will go to current chat', 'info'); | |
| } | |
| // Save current chat first if it has messages | |
| if (state.messages.length > 0) { | |
| saveCurrentChat(); | |
| } | |
| // Clear current chat | |
| state.messages = []; | |
| state.currentChatId = null; | |
| // Reset UI | |
| renderMessages(); | |
| elements.welcomeScreen.classList.remove('hidden'); | |
| hideSummary(); | |
| renderChatHistory(); | |
| showToast('Started new chat', 'info'); | |
| } | |
| function loadChatFromHistory(chatId) { | |
| // Warn if AI is still generating | |
| if (state.isLoading) { | |
| showToast('AI is still responding - response will go to current chat', 'info'); | |
| } | |
| // Save current chat first if it has messages | |
| if (state.messages.length > 0 && state.currentChatId !== chatId) { | |
| saveCurrentChat(); | |
| } | |
| const chat = state.chatHistory.find(c => c.id === chatId); | |
| if (!chat) return; | |
| // Load the chat | |
| state.messages = [...chat.messages]; | |
| state.currentChatId = chat.id; | |
| state.chatBucket = chat.bucket || ''; | |
| // Update bucket dropdown | |
| if (elements.chatBucketSelect) { | |
| elements.chatBucketSelect.value = state.chatBucket; | |
| const bucketName = state.chatBucket ? | |
| state.buckets.find(b => b.bucket_id === state.chatBucket)?.name || 'Selected Bucket' : | |
| 'All Documents'; | |
| elements.chatBucketTrigger.querySelector('.select-value').textContent = bucketName; | |
| } | |
| // Render messages | |
| renderMessages(); | |
| // Show/hide welcome screen based on whether chat has messages | |
| if (state.messages.length === 0) { | |
| elements.welcomeScreen.classList.remove('hidden'); | |
| } else { | |
| elements.welcomeScreen.classList.add('hidden'); | |
| } | |
| renderChatHistory(); | |
| scrollToBottom(); | |
| } | |
| function deleteChatFromHistory(chatId) { | |
| event.stopPropagation(); | |
| state.chatHistory = state.chatHistory.filter(c => c.id !== chatId); | |
| // If deleting current chat, clear it | |
| if (state.currentChatId === chatId) { | |
| state.messages = []; | |
| state.currentChatId = null; | |
| renderMessages(); | |
| elements.welcomeScreen.classList.remove('hidden'); | |
| } | |
| saveChatHistory(); | |
| renderChatHistory(); | |
| // Delete from server | |
| deleteChatFromServer(chatId); | |
| showToast('Chat deleted', 'success'); | |
| } | |
| function renderChatHistory() { | |
| // Filter chats by selected bucket | |
| let filteredChats = state.chatHistory; | |
| if (state.selectedBucket) { | |
| filteredChats = state.chatHistory.filter(chat => | |
| chat.bucket === state.selectedBucket || | |
| // Also include chats with no bucket for backwards compatibility | |
| (!chat.bucket && !state.selectedBucket) | |
| ); | |
| } | |
| const count = filteredChats.length; | |
| const totalCount = state.chatHistory.length; | |
| // Show filtered count vs total if filtering is active | |
| elements.chatHistoryCount.textContent = state.selectedBucket && count !== totalCount ? | |
| `(${count}/${totalCount})` : `(${totalCount})`; | |
| if (count === 0) { | |
| elements.chatHistoryList.innerHTML = state.selectedBucket ? | |
| `<div class="empty-state small"><div class="empty-text">No chats in this bucket</div></div>` : | |
| `<div class="empty-state small"><div class="empty-text">No chats yet</div></div>`; | |
| return; | |
| } | |
| elements.chatHistoryList.innerHTML = filteredChats.map(chat => { | |
| const isActive = state.currentChatId === chat.id; | |
| const date = formatDate(chat.timestamp / 1000); | |
| return ` | |
| <div class="chat-history-item ${isActive ? 'active' : ''}" onclick="loadChatFromHistory('${chat.id}')"> | |
| <span class="chat-history-icon">💬</span> | |
| <div class="chat-history-info"> | |
| <div class="chat-history-topic">${chat.topic}</div> | |
| <div class="chat-history-date">${date}</div> | |
| </div> | |
| <button class="btn btn-ghost chat-history-delete" onclick="deleteChatFromHistory('${chat.id}')" title="Delete">🗑️</button> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function clearCurrentChat() { | |
| // Warn if AI is still generating | |
| if (state.isLoading) { | |
| showToast('AI is still responding - response will go to current chat', 'info'); | |
| } | |
| // If there's a current chat, clear its messages but keep it in history | |
| if (state.currentChatId) { | |
| const chatIndex = state.chatHistory.findIndex(c => c.id === state.currentChatId); | |
| if (chatIndex >= 0) { | |
| // Clear the messages in history | |
| state.chatHistory[chatIndex].messages = []; | |
| saveChatHistory(); | |
| // Sync cleared chat to server | |
| syncChatToServer(state.chatHistory[chatIndex]); | |
| } | |
| } | |
| // Clear current chat messages | |
| state.messages = []; | |
| // Reset UI | |
| renderMessages(); | |
| elements.welcomeScreen.classList.remove('hidden'); | |
| hideSummary(); | |
| renderChatHistory(); | |
| showToast('Chat cleared', 'info'); | |
| } | |
| function initChatHistory() { | |
| // New Chat button handler | |
| elements.newChatBtn.addEventListener('click', startNewChat); | |
| // Clear Chat button handler (sidebar) | |
| elements.clearChatBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| clearCurrentChat(); | |
| }); | |
| // Clear Chat button handler (top) | |
| elements.clearChatBtnTop.addEventListener('click', clearCurrentChat); | |
| // Render existing history | |
| renderChatHistory(); | |
| // Auto-save current chat when sending messages (hook into sendMessage) | |
| // This is handled by updating currentChatId after first message | |
| } | |
| // ==================== Init ==================== | |
| function init() { | |
| initSidebars(); | |
| initMobileNavigation(); | |
| initCollapsible(); | |
| initCustomDropdowns(); | |
| initUpload(); | |
| initChat(); | |
| initSummaryPanel(); | |
| initChatHistory(); | |
| verifyToken(); | |
| } | |
| document.addEventListener('DOMContentLoaded', init); | |