/* ========================================================================= Learning Section – NotebookLM-style RAG Document Chatbot ========================================================================= */ function renderLearningPage(container) { // ── State ──────────────────────────────────────────────────────────── let collections = []; let currentCollectionId = null; let currentDocuments = []; let currentSessionId = null; let sessions = []; let isUploading = false; let isChatLoading = false; let processingTaskIds = new Set(); let pollingTimer = null; // ── API helpers ────────────────────────────────────────────────────── const api = { async listCollections() { const r = await fetch('/api/learning/collections'); return r.json(); }, async createCollection(name, visibility) { const r = await fetch('/api/learning/collections', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, visibility: visibility || 'private' }) }); return r.json(); }, async deleteCollection(id) { const r = await fetch(`/api/learning/collections/${id}`, { method: 'DELETE' }); return r.json(); }, async renameCollection(id, name) { const r = await fetch(`/api/learning/collections/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) }); return r.json(); }, async listDocuments(id) { const r = await fetch(`/api/learning/collections/${id}/documents`); return r.json(); }, async uploadFiles(id, files) { const fd = new FormData(); for (const f of files) fd.append('files', f); const r = await fetch(`/api/learning/collections/${id}/upload`, { method: 'POST', body: fd }); return r.json(); }, async deleteDocument(colId, docId) { const r = await fetch(`/api/learning/collections/${colId}/documents/${docId}`, { method: 'DELETE' }); return r.json(); }, // YouTube URL async addYoutubeUrl(colId, url) { const r = await fetch(`/api/learning/collections/${colId}/add-url`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url }) }); return r.json(); }, // Video upload async uploadVideo(colId, file) { const fd = new FormData(); fd.append('video', file); const r = await fetch(`/api/learning/collections/${colId}/upload-video`, { method: 'POST', body: fd }); return r.json(); }, // Sessions async listSessions(colId) { const r = await fetch(`/api/learning/collections/${colId}/sessions`); return r.json(); }, async createSession(colId, name) { const r = await fetch(`/api/learning/collections/${colId}/sessions`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) }); return r.json(); }, async deleteSession(colId, sessionId) { const r = await fetch(`/api/learning/collections/${colId}/sessions/${sessionId}`, { method: 'DELETE' }); return r.json(); }, async getSessionMessages(colId, sessionId) { const r = await fetch(`/api/learning/collections/${colId}/sessions/${sessionId}/messages`); return r.json(); }, // Chat async chat(colId, question, sessionId, signal) { const r = await fetch(`/api/learning/collections/${colId}/chat`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ question, session_id: sessionId }), signal: signal }); return r.json(); }, async checkTaskStatus(taskId) { const r = await fetch(`/api/learning/tasks/${taskId}`); return r.json(); } }; // ── Background task polling ────────────────────────────────────────── function startPollingTasks() { if (pollingTimer || !processingTaskIds.size) return; pollingTimer = setInterval(async () => { if (!processingTaskIds.size) { clearInterval(pollingTimer); pollingTimer = null; return; } let anyDone = false; for (const taskId of [...processingTaskIds]) { try { const status = await api.checkTaskStatus(taskId); if (status.status !== 'processing' && status.status !== 'PENDING' && status.status !== 'STARTED') { processingTaskIds.delete(taskId); anyDone = true; if (status.status === 'error' && status.error) { showErrorModal('Processing Error', status.error); } } } catch { processingTaskIds.delete(taskId); anyDone = true; } } if (anyDone) { await refreshDocuments(); await refreshCollections(); const col = collections.find(c => c.id === currentCollectionId); if (col && elChatHeader) { elChatHeader.innerHTML = `
${escapeHtml(col.name)} ${col.document_count || 0} documents
`; } } if (!processingTaskIds.size) { clearInterval(pollingTimer); pollingTimer = null; } }, 3000); } // ── Render shell ───────────────────────────────────────────────────── container.innerHTML = `

Learning Hub

Upload documents & chat with your knowledge base

Select a collection

Create or choose a collection to start chatting with your documents.

`; // ── DOM refs ───────────────────────────────────────────────────────── const elCollectionsList = document.getElementById('learning-collections-list'); const elDocPanel = document.getElementById('learning-doc-panel'); const elDocCount = document.getElementById('learning-doc-count'); const elDocList = document.getElementById('learning-doc-list'); const elUploadZone = document.getElementById('learning-upload-zone'); const elFileInput = document.getElementById('learning-file-input'); const elUploadProgress = document.getElementById('learning-upload-progress'); const elChatEmpty = document.getElementById('learning-chat-empty'); const elChatActive = document.getElementById('learning-chat-active'); const elChatHeader = document.getElementById('learning-chat-header'); const elChatMessages = document.getElementById('learning-chat-messages'); const elSuggestedPrompts = document.getElementById('learning-suggested-prompts'); const elChatInput = document.getElementById('learning-chat-input'); const elSendBtn = document.getElementById('learning-send-btn'); const elNewBtn = document.getElementById('learning-new-btn'); const elSessionList = document.getElementById('learning-session-list'); const elNewSessionBtn = document.getElementById('learning-new-session-btn'); const elUrlInput = document.getElementById('learning-url-input'); const elUrlAddBtn = document.getElementById('learning-url-add-btn'); const elProgressText = document.getElementById('learning-progress-text'); // ── Render helpers ─────────────────────────────────────────────────── function fileTypeIcon(type) { if (type === 'pdf') return 'bi-file-earmark-pdf'; if (type === 'docx') return 'bi-file-earmark-word'; if (type === 'pptx') return 'bi-file-earmark-ppt'; if (type === 'xlsx') return 'bi-file-earmark-excel'; if (type === 'youtube') return 'bi-youtube'; if (type === 'video') return 'bi-camera-video'; return 'bi-file-earmark'; } function shortDate(iso) { try { return new Date(iso).toLocaleDateString(undefined, { month:'short', day:'numeric' }); } catch { return ''; } } // Custom confirm modal (replaces browser confirm()) function showConfirmModal(title, message, confirmLabel = 'Delete', confirmClass = 'btn-danger') { return new Promise((resolve) => { // Remove any existing modal const old = document.getElementById('learning-confirm-modal'); if (old) old.remove(); const html = ` `; document.body.insertAdjacentHTML('beforeend', html); const modalEl = document.getElementById('learning-confirm-modal'); const bsModal = new bootstrap.Modal(modalEl); document.getElementById('learning-confirm-cancel').addEventListener('click', () => { bsModal.hide(); resolve(false); }); document.getElementById('learning-confirm-ok').addEventListener('click', () => { bsModal.hide(); resolve(true); }); modalEl.addEventListener('hidden.bs.modal', () => { modalEl.remove(); }, { once: true }); bsModal.show(); }); } // Custom error modal function showErrorModal(title, message) { // Remove any existing modal const old = document.getElementById('learning-error-modal'); if (old) old.remove(); const html = ` `; document.body.insertAdjacentHTML('beforeend', html); const modalEl = document.getElementById('learning-error-modal'); const bsModal = new bootstrap.Modal(modalEl); modalEl.addEventListener('hidden.bs.modal', () => { modalEl.remove(); }, { once: true }); bsModal.show(); } function renderCollections() { if (!collections.length) { elCollectionsList.innerHTML = `

No collections yet.
Click New to create one.

`; return; } elCollectionsList.innerHTML = collections.map(c => { const currentUserId = sessionStorage.getItem('prospectiq_user_id') || '2'; const isOwner = !c.user_id || c.user_id === currentUserId; const isPublic = c.visibility === 'public'; const visIcon = isPublic ? '' : ''; const lockedClass = (!isOwner && !isPublic) ? ' locked' : ''; return `
${visIcon} ${escapeHtml(c.name)}
${c.document_count || 0} doc${c.document_count !== 1 ? 's' : ''} · ${shortDate(c.created_at)}
${isOwner ? ` ` : ''}
`}).join(''); // Bind clicks elCollectionsList.querySelectorAll('.learning-collection-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest('.learning-collection-delete')) return; selectCollection(card.dataset.id); }); }); elCollectionsList.querySelectorAll('.learning-collection-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const confirmed = await showConfirmModal( 'Delete Collection', 'This will permanently delete this collection and all its documents. This action cannot be undone.' ); if (!confirmed) return; const origHtml = btn.innerHTML; btn.innerHTML = ''; btn.disabled = true; try { await api.deleteCollection(btn.dataset.id); if (currentCollectionId === btn.dataset.id) { currentCollectionId = null; currentSessionId = null; showChatEmpty(); } await refreshCollections(); } finally { btn.innerHTML = origHtml; btn.disabled = false; } }); }); } function renderDocuments() { elDocCount.textContent = currentDocuments.length; if (!currentDocuments.length) { elDocList.innerHTML = `

No documents uploaded yet.

`; return; } elDocList.innerHTML = currentDocuments.map(d => { const isProcessing = d.pages === 0 && d.chunks === 0; const metaHtml = isProcessing ? `
Processing…
` : `
${d.pages} pages · ${d.chunks} chunks
`; return `
${escapeHtml(d.filename)}
${metaHtml}
`}).join(''); elDocList.querySelectorAll('.learning-doc-delete').forEach(btn => { btn.addEventListener('click', async () => { const confirmed = await showConfirmModal( 'Remove Document', 'Remove this document from the collection? Its indexed data will also be deleted.' ); if (!confirmed) return; const origHtml = btn.innerHTML; btn.innerHTML = ''; btn.disabled = true; try { await api.deleteDocument(currentCollectionId, btn.dataset.id); await refreshDocuments(); await refreshCollections(); } finally { btn.innerHTML = origHtml; btn.disabled = false; } }); }); } function renderSessions() { if (!sessions.length) { elSessionList.innerHTML = `

No chat sessions yet.

`; return; } elSessionList.innerHTML = sessions.map(s => `
${escapeHtml(s.name)} ${s.message_count || 0} msgs
`).join(''); elSessionList.querySelectorAll('.learning-session-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest('.learning-session-delete')) return; selectSession(card.dataset.id); }); }); elSessionList.querySelectorAll('.learning-session-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const confirmed = await showConfirmModal( 'Delete Chat Session', 'Delete this chat session and all its messages?', 'Delete' ); if (!confirmed) return; const origHtml = btn.innerHTML; btn.innerHTML = ''; btn.disabled = true; try { await api.deleteSession(currentCollectionId, btn.dataset.id); if (currentSessionId === btn.dataset.id) { currentSessionId = null; renderChatMessages(); } await refreshSessions(); } finally { btn.innerHTML = origHtml; btn.disabled = false; } }); }); } function renderChatMessages(messages) { const history = messages || []; if (!history.length) { showSuggestedPrompts(); return; } elSuggestedPrompts.style.display = 'none'; let html = ''; for (const msg of history) { if (msg.role === 'user') { html += `
${escapeHtml(msg.content)}
`; } else { const rendered = typeof marked !== 'undefined' ? marked.parse(msg.content || '') : escapeHtml(msg.content || ''); html += `
${rendered}`; if (msg.sources && msg.sources.length) { html += `
`; msg.sources.forEach(s => { html += `p. ${s.page} · ${escapeHtml(s.filename)}`; }); html += `
`; } html += `
`; } } elChatMessages.innerHTML = html; elChatMessages.scrollTop = elChatMessages.scrollHeight; } function showSuggestedPrompts() { elChatMessages.innerHTML = ''; elChatMessages.appendChild(elSuggestedPrompts); elSuggestedPrompts.style.display = 'flex'; const prompts = [ { icon: 'bi-journal-text', text: 'Summarize the key points of these documents' }, { icon: 'bi-lightbulb', text: 'What are the main takeaways?' }, { icon: 'bi-search', text: 'Explain the core concepts covered' }, { icon: 'bi-question-circle', text: 'What questions can I answer from these documents?' }, ]; elSuggestedPrompts.innerHTML = prompts.map(p => ` `).join(''); elSuggestedPrompts.querySelectorAll('.learning-suggested-prompt').forEach(btn => { btn.addEventListener('click', () => { elChatInput.value = btn.dataset.q; sendMessage(); }); }); } function showChatEmpty() { elDocPanel.style.display = 'none'; elChatEmpty.style.display = 'flex'; elChatActive.style.display = 'none'; } function showChatActive() { elChatEmpty.style.display = 'none'; elChatActive.style.display = 'flex'; } function appendThinking() { const div = document.createElement('div'); div.className = 'learning-chat-bubble assistant learning-thinking'; div.id = 'learning-thinking'; div.innerHTML = `
`; elChatMessages.appendChild(div); setTimeout(() => { const btn = document.getElementById('learning-interrupt-btn'); if (btn) { btn.addEventListener('click', () => { if (window.currentLearningAbortController) { window.currentLearningAbortController.abort(); } }); } }, 10); elChatMessages.scrollTop = elChatMessages.scrollHeight; } function removeThinking() { const el = document.getElementById('learning-thinking'); if (el) el.remove(); } function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } // ── Core actions ───────────────────────────────────────────────────── async function refreshCollections() { collections = await api.listCollections(); renderCollections(); } async function refreshDocuments() { if (!currentCollectionId) return; currentDocuments = await api.listDocuments(currentCollectionId); renderDocuments(); } async function refreshSessions() { if (!currentCollectionId) return; sessions = await api.listSessions(currentCollectionId); renderSessions(); } async function selectCollection(id) { currentCollectionId = id; currentSessionId = null; renderCollections(); elDocPanel.style.display = 'block'; showChatActive(); const col = collections.find(c => c.id === id); elChatHeader.innerHTML = `
${escapeHtml(col?.name || 'Collection')} ${col?.document_count || 0} documents
`; await refreshDocuments(); await refreshSessions(); // Auto-select latest session or show suggested prompts if (sessions.length) { selectSession(sessions[0].id); } else { renderChatMessages([]); } } async function selectSession(sessionId) { currentSessionId = sessionId; renderSessions(); const messages = await api.getSessionMessages(currentCollectionId, sessionId); renderChatMessages(messages); } async function handleUpload(files) { if (!currentCollectionId || !files.length || isUploading) return; isUploading = true; elUploadProgress.style.display = 'block'; // Separate MP4s from document files const docFiles = []; const videoFiles = []; for (const f of files) { if (f.name.toLowerCase().endsWith('.mp4')) { videoFiles.push(f); } else { docFiles.push(f); } } try { const allErrors = []; const collectedTaskIds = []; // Upload document files if (docFiles.length) { elProgressText.textContent = 'Uploading documents…'; const result = await api.uploadFiles(currentCollectionId, docFiles); if (result.errors && result.errors.length) { allErrors.push(...result.errors.map(e => `${e.filename}: ${e.error}`)); } // Collect task IDs from uploaded documents if (result.documents && result.documents.length) { for (const doc of result.documents) { if (doc.task_id) collectedTaskIds.push(doc.task_id); } } } // Upload video files one by one for (const vf of videoFiles) { elProgressText.textContent = `Transcribing ${vf.name}…`; try { const vResult = await api.uploadVideo(currentCollectionId, vf); if (vResult.task_id) collectedTaskIds.push(vResult.task_id); } catch (err) { allErrors.push(`${vf.name}: ${err.message}`); } } if (allErrors.length) { alert('Some files failed:\n' + allErrors.join('\n')); } await refreshDocuments(); await refreshCollections(); const col = collections.find(c => c.id === currentCollectionId); if (col && elChatHeader) { elChatHeader.innerHTML = `
${escapeHtml(col.name)} ${col.document_count || 0} documents
`; } // Start polling for background tasks to finish if (collectedTaskIds.length) { for (const tid of collectedTaskIds) processingTaskIds.add(tid); startPollingTasks(); } } catch (err) { alert('Upload failed: ' + err.message); } finally { isUploading = false; elUploadProgress.style.display = 'none'; elProgressText.textContent = 'Indexing documents…'; elFileInput.value = ''; } } async function handleAddUrl() { const url = (elUrlInput.value || '').trim(); if (!url || !currentCollectionId || isUploading) return; isUploading = true; elUploadProgress.style.display = 'block'; elProgressText.textContent = 'Fetching transcript…'; try { const result = await api.addYoutubeUrl(currentCollectionId, url); if (result.error) { alert(result.error); } else { elUrlInput.value = ''; if (result.task_id) { processingTaskIds.add(result.task_id); } } await refreshDocuments(); await refreshCollections(); if (processingTaskIds.size) startPollingTasks(); } catch (err) { alert('URL failed: ' + err.message); } finally { isUploading = false; elUploadProgress.style.display = 'none'; elProgressText.textContent = 'Indexing documents…'; } } async function sendMessage() { const question = (elChatInput.value || '').trim(); if (!question || !currentCollectionId || isChatLoading) return; // Auto-create session if none selected if (!currentSessionId) { const sessionResult = await api.createSession(currentCollectionId); if (sessionResult.error) { alert(sessionResult.error); return; } currentSessionId = sessionResult.id; await refreshSessions(); } // Remove previous follow-up suggestions const oldFollowups = elChatMessages.querySelectorAll('.learning-followup-chips'); oldFollowups.forEach(el => el.remove()); // Show user message immediately const userBubble = document.createElement('div'); userBubble.className = 'learning-chat-bubble user'; userBubble.textContent = question; elSuggestedPrompts.style.display = 'none'; elChatMessages.appendChild(userBubble); elChatMessages.scrollTop = elChatMessages.scrollHeight; elChatInput.value = ''; autoResizeInput(); appendThinking(); isChatLoading = true; elSendBtn.disabled = true; try { window.currentLearningAbortController = new AbortController(); const result = await api.chat(currentCollectionId, question, currentSessionId, window.currentLearningAbortController.signal); removeThinking(); const assistantBubble = document.createElement('div'); assistantBubble.className = 'learning-chat-bubble assistant'; if (result.error) { assistantBubble.innerHTML = `⚠️ ${escapeHtml(result.error)}`; } else { const rendered = typeof marked !== 'undefined' ? marked.parse(result.answer || '') : escapeHtml(result.answer || ''); let html = rendered; if (result.sources && result.sources.length) { html += `
`; result.sources.forEach(s => { html += `p. ${s.page} · ${escapeHtml(s.filename)}`; }); html += `
`; } assistantBubble.innerHTML = html; } elChatMessages.appendChild(assistantBubble); // Render follow-up suggestion chips if (result.suggestions && result.suggestions.length) { const chipsDiv = document.createElement('div'); chipsDiv.className = 'learning-followup-chips'; result.suggestions.forEach(q => { const chip = document.createElement('button'); chip.className = 'learning-followup-chip'; chip.innerHTML = ` ${escapeHtml(q)}`; chip.addEventListener('click', () => { elChatInput.value = q; sendMessage(); }); chipsDiv.appendChild(chip); }); elChatMessages.appendChild(chipsDiv); } elChatMessages.scrollTop = elChatMessages.scrollHeight; // Refresh session list to update message count await refreshSessions(); } catch (err) { removeThinking(); const errBubble = document.createElement('div'); errBubble.className = 'learning-chat-bubble assistant'; if (err.name === 'AbortError') { errBubble.innerHTML = `⚠️ Request interrupted by user.`; } else { errBubble.innerHTML = `⚠️ Error: ${escapeHtml(err.message)}`; } elChatMessages.appendChild(errBubble); elChatMessages.scrollTop = elChatMessages.scrollHeight; } finally { isChatLoading = false; elSendBtn.disabled = false; elChatInput.focus(); } } function autoResizeInput() { elChatInput.style.height = 'auto'; elChatInput.style.height = Math.min(elChatInput.scrollHeight, 120) + 'px'; } // ── Event bindings ─────────────────────────────────────────────────── elNewBtn.addEventListener('click', () => { let modalEl = document.getElementById('newCollectionModal'); if (!modalEl) { const html = ` `; document.body.insertAdjacentHTML('beforeend', html); modalEl = document.getElementById('newCollectionModal'); // Add interactions for the radio cards const labels = modalEl.querySelectorAll('label'); labels.forEach(l => { l.addEventListener('click', () => { labels.forEach(lb => { lb.style.borderColor = 'var(--grey-border)'; lb.style.background = 'transparent'; }); l.style.borderColor = 'var(--black)'; l.style.background = 'var(--grey-light)'; }); }); // Initial styling for checked radio labels[0].style.borderColor = 'var(--black)'; labels[0].style.background = 'var(--grey-light)'; document.getElementById('createColConfirmBtn').addEventListener('click', async () => { const name = document.getElementById('colNameInput').value.trim(); if (!name) return; const visibility = document.querySelector('input[name="colVis"]:checked').value; const btn = document.getElementById('createColConfirmBtn'); const origText = btn.innerHTML; btn.innerHTML = ' Creating...'; btn.disabled = true; const result = await api.createCollection(name, visibility); btn.innerHTML = origText; btn.disabled = false; if (result.error) { alert(result.error); } else { const bsModal = bootstrap.Modal.getInstance(modalEl); bsModal.hide(); document.getElementById('colNameInput').value = ''; await refreshCollections(); selectCollection(result.id); } }); } const bsModal = new bootstrap.Modal(modalEl); bsModal.show(); setTimeout(() => document.getElementById('colNameInput').focus(), 400); }); elNewSessionBtn.addEventListener('click', async () => { if (!currentCollectionId) return; const result = await api.createSession(currentCollectionId); if (result.error) { alert(result.error); return; } await refreshSessions(); selectSession(result.id); }); // Upload – file input elFileInput.addEventListener('change', () => { if (elFileInput.files.length) handleUpload(Array.from(elFileInput.files)); }); // Upload – drag & drop elUploadZone.addEventListener('dragover', (e) => { e.preventDefault(); elUploadZone.classList.add('drag-over'); }); elUploadZone.addEventListener('dragleave', () => { elUploadZone.classList.remove('drag-over'); }); elUploadZone.addEventListener('drop', (e) => { e.preventDefault(); elUploadZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) handleUpload(Array.from(e.dataTransfer.files)); }); // YouTube URL elUrlAddBtn.addEventListener('click', handleAddUrl); elUrlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddUrl(); } }); // Chat – send elSendBtn.addEventListener('click', sendMessage); elChatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); elChatInput.addEventListener('input', autoResizeInput); // ── Initialise ─────────────────────────────────────────────────────── refreshCollections().then(() => { // Auto-select collection if routed from global command bar if (window.pendingLearningCollectionId) { const pendingId = window.pendingLearningCollectionId; window.pendingLearningCollectionId = null; // Check it exists in our list const found = collections.find(c => c.id === pendingId); if (found) { selectCollection(pendingId); } } }); }