jashdoshi77
Update UI styling and message formatting improvements
abc646e
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// 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);