/* =========================================================================
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 = `
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);
}
}
});
}