ProspectIQ / static /js /views /learning.js
praneelchettys's picture
Replace main with mobile-friendly ProspectIQ codebase
5bcd5ab
Raw
History Blame Contribute Delete
49.1 kB
/* =========================================================================
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 = `
<div>
<strong>${escapeHtml(col.name)}</strong>
<span class="text-muted small ms-2">${col.document_count || 0} documents</span>
</div>
`;
}
}
if (!processingTaskIds.size) {
clearInterval(pollingTimer);
pollingTimer = null;
}
}, 3000);
}
// ── Render shell ─────────────────────────────────────────────────────
container.innerHTML = `
<div class="learning-workspace animate-fade-in">
<!-- Left panel -->
<div class="learning-sidebar" id="learning-sidebar">
<div class="learning-sidebar-header">
<div>
<h1 class="learning-title">Learning Hub</h1>
<p class="learning-subtitle">Upload documents &amp; chat with your knowledge base</p>
</div>
<button class="btn btn-dark-custom btn-sm" id="learning-new-btn">
<i class="bi bi-plus-lg me-1"></i>New
</button>
</div>
<div class="learning-collections-list" id="learning-collections-list"></div>
<div id="learning-doc-panel" style="display:none">
<div class="learning-section-label">
<span>Sources</span>
<span class="badge bg-dark" id="learning-doc-count">0</span>
</div>
<div class="learning-upload-zone" id="learning-upload-zone">
<i class="bi bi-cloud-arrow-up" style="font-size:1.5rem;color:var(--grey-text)"></i>
<span>Drop files here or <label for="learning-file-input" class="learning-upload-link">browse</label></span>
<span class="text-muted" style="font-size:0.75rem">PDF, DOCX, PPT, XLSX, MP4</span>
<input type="file" id="learning-file-input" multiple accept=".pdf,.docx,.pptx,.ppt,.doc,.xlsx,.xls,.mp4" style="display:none">
</div>
<div class="learning-url-input-row">
<input type="text" id="learning-url-input" class="learning-url-input" placeholder="Paste YouTube URL…">
<button class="learning-url-add-btn" id="learning-url-add-btn" title="Add YouTube video">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="learning-upload-progress" style="display:none">
<div class="d-flex align-items-center gap-2 px-2 py-2">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="text-muted small" id="learning-progress-text">Indexing documents…</span>
</div>
</div>
<div class="learning-doc-list" id="learning-doc-list"></div>
<div class="learning-section-label" style="margin-top:8px">
<span>Chat Sessions</span>
<button class="learning-new-session-btn" id="learning-new-session-btn" title="New chat session">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="learning-session-list" id="learning-session-list"></div>
</div>
</div>
<!-- Right panel -->
<div class="learning-chat-panel" id="learning-chat-panel">
<div id="learning-chat-empty" class="learning-empty-state">
<i class="bi bi-chat-square-text" style="font-size:3rem;color:var(--grey-border)"></i>
<h5 style="margin-top:12px;color:var(--grey-text)">Select a collection</h5>
<p class="text-muted small">Create or choose a collection to start chatting with your documents.</p>
</div>
<div id="learning-chat-active" style="display:none;height:100%;flex-direction:column">
<div class="learning-chat-header" id="learning-chat-header"></div>
<div class="learning-chat-messages" id="learning-chat-messages">
<div id="learning-suggested-prompts" class="learning-suggested-prompts"></div>
</div>
<div class="learning-chat-input-area">
<div class="learning-chat-input-wrapper">
<textarea id="learning-chat-input" placeholder="Ask a question about your documents…" rows="1"></textarea>
<button class="learning-send-btn" id="learning-send-btn" title="Send">
<i class="bi bi-arrow-up"></i>
</button>
</div>
</div>
</div>
</div>
</div>
`;
// ── 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 = `
<div class="modal fade" id="learning-confirm-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content" style="border:none; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15);">
<div class="modal-body" style="padding: 24px 24px 16px; text-align:center;">
<i class="bi bi-exclamation-triangle" style="font-size:2.2rem; color:#dc3545;"></i>
<h6 style="font-weight:600; margin-top:12px; margin-bottom:6px;">${title}</h6>
<p class="text-muted small mb-0">${message}</p>
</div>
<div class="modal-footer" style="border-top:none; padding: 8px 24px 20px; justify-content:center; gap:10px;">
<button type="button" class="btn btn-light btn-sm" id="learning-confirm-cancel" style="border-radius:10px; padding:7px 18px; font-weight:500;">Cancel</button>
<button type="button" class="btn ${confirmClass} btn-sm" id="learning-confirm-ok" style="border-radius:10px; padding:7px 18px; font-weight:500;">${confirmLabel}</button>
</div>
</div>
</div>
</div>
`;
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 = `
<div class="modal fade" id="learning-error-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content" style="border:none; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15);">
<div class="modal-body" style="padding: 24px 24px 16px; text-align:center;">
<i class="bi bi-x-circle text-danger" style="font-size:2.2rem;"></i>
<h6 style="font-weight:600; margin-top:12px; margin-bottom:6px;">${title}</h6>
<p class="text-muted small mb-0">${message}</p>
</div>
<div class="modal-footer" style="border-top:none; padding: 8px 24px 20px; justify-content:center;">
<button type="button" class="btn btn-dark-custom btn-sm" data-bs-dismiss="modal" style="border-radius:10px; padding:7px 24px; font-weight:500;">OK</button>
</div>
</div>
</div>
</div>
`;
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 = `
<div class="learning-empty-state" style="padding:40px 16px">
<i class="bi bi-journal-plus" style="font-size:2.5rem;color:var(--grey-border)"></i>
<p class="text-muted small mt-2 mb-0">No collections yet.<br>Click <strong>New</strong> to create one.</p>
</div>`;
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
? '<i class="bi bi-globe2 text-success" title="Public" style="font-size: 0.7rem;"></i>'
: '<i class="bi bi-lock-fill text-muted" title="Private" style="font-size: 0.7rem;"></i>';
const lockedClass = (!isOwner && !isPublic) ? ' locked' : '';
return `
<div class="learning-collection-card ${c.id === currentCollectionId ? 'active' : ''}${lockedClass}"
data-id="${c.id}">
<div class="learning-collection-info">
<div class="learning-collection-name d-flex align-items-center gap-1">${visIcon} ${escapeHtml(c.name)}</div>
<div class="learning-collection-meta">
<span>${c.document_count || 0} doc${c.document_count !== 1 ? 's' : ''}</span>
<span>Β·</span>
<span>${shortDate(c.created_at)}</span>
</div>
</div>
${isOwner ? `
<button class="learning-collection-delete" data-id="${c.id}" title="Delete collection">
<i class="bi bi-trash3"></i>
</button>
` : ''}
</div>
`}).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 = '<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;border-width:0.12em;" role="status"></span>';
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 = `<p class="text-muted small px-2 py-3 mb-0 text-center">No documents uploaded yet.</p>`;
return;
}
elDocList.innerHTML = currentDocuments.map(d => {
const isProcessing = d.pages === 0 && d.chunks === 0;
const metaHtml = isProcessing
? `<div class="learning-doc-meta" style="color: var(--primary, #e67e22);"><span class="spinner-border spinner-border-sm me-1" style="width:0.65rem;height:0.65rem;border-width:0.12em;" role="status"></span>Processing…</div>`
: `<div class="learning-doc-meta">${d.pages} pages Β· ${d.chunks} chunks</div>`;
return `
<div class="learning-doc-item">
<i class="bi ${fileTypeIcon(d.original_type)} learning-doc-type-icon"></i>
<div class="learning-doc-info">
<div class="learning-doc-name" title="${escapeHtml(d.filename)}">${escapeHtml(d.filename)}</div>
${metaHtml}
</div>
<button class="learning-doc-delete" data-id="${d.id}" title="Remove document">
<i class="bi bi-x-lg"></i>
</button>
</div>
`}).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 = '<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;border-width:0.12em;" role="status"></span>';
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 = `<p class="text-muted small px-2 py-2 mb-0 text-center">No chat sessions yet.</p>`;
return;
}
elSessionList.innerHTML = sessions.map(s => `
<div class="learning-session-card ${s.id === currentSessionId ? 'active' : ''}" data-id="${s.id}">
<div class="learning-session-info">
<i class="bi bi-chat-dots" style="font-size:0.85rem;color:var(--grey-text)"></i>
<span class="learning-session-name">${escapeHtml(s.name)}</span>
<span class="learning-session-count">${s.message_count || 0} msgs</span>
</div>
<button class="learning-session-delete" data-id="${s.id}" title="Delete session">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).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 = '<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;border-width:0.12em;" role="status"></span>';
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 += `<div class="learning-chat-bubble user">${escapeHtml(msg.content)}</div>`;
} else {
const rendered = typeof marked !== 'undefined' ? marked.parse(msg.content || '') : escapeHtml(msg.content || '');
html += `<div class="learning-chat-bubble assistant">${rendered}`;
if (msg.sources && msg.sources.length) {
html += `<div class="learning-source-chips">`;
msg.sources.forEach(s => {
html += `<span class="learning-source-chip">p. ${s.page} Β· ${escapeHtml(s.filename)}</span>`;
});
html += `</div>`;
}
html += `</div>`;
}
}
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 => `
<button class="learning-suggested-prompt" data-q="${escapeHtml(p.text)}">
<i class="bi ${p.icon}"></i>
<span>${p.text}</span>
</button>
`).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 = `
<div class="d-flex flex-column gap-2 align-items-start">
<div class="d-flex gap-1 align-items-center">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<button class="btn btn-outline-danger btn-sm rounded-pill px-2.5 py-0.5 mt-1" id="learning-interrupt-btn" style="font-size: 0.72rem; font-weight: 500;" type="button">
<i class="bi bi-x-circle me-1"></i>Interrupt
</button>
</div>
`;
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 = `
<div>
<strong>${escapeHtml(col?.name || 'Collection')}</strong>
<span class="text-muted small ms-2">${col?.document_count || 0} documents</span>
</div>
`;
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 = `
<div>
<strong>${escapeHtml(col.name)}</strong>
<span class="text-muted small ms-2">${col.document_count || 0} documents</span>
</div>
`;
}
// 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 += `<div class="learning-source-chips">`;
result.sources.forEach(s => {
html += `<span class="learning-source-chip">p. ${s.page} Β· ${escapeHtml(s.filename)}</span>`;
});
html += `</div>`;
}
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 = `<i class="bi bi-arrow-return-right"></i> ${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 = `
<div class="modal fade" id="newCollectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="border:none; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.1);">
<div class="modal-header" style="border-bottom:1px solid var(--grey-light); padding: 20px 24px 16px;">
<h5 class="modal-title" style="font-weight:600; font-size:1.1rem;">Create Knowledge Base</h5>
<button type="button" class="btn-close shadow-none" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="padding: 24px;">
<div class="mb-4">
<label class="form-label text-muted" style="font-size:0.85rem; font-weight:500;">Collection Name</label>
<input type="text" class="form-control shadow-none" id="colNameInput" placeholder="e.g. Q3 Sales Playbooks" style="border-radius:10px; padding:10px 14px; border:1px solid var(--grey-border);">
</div>
<div>
<label class="form-label text-muted" style="font-size:0.85rem; font-weight:500;">Visibility</label>
<div class="d-flex flex-column gap-2">
<label class="d-flex align-items-start gap-3 p-3" style="border:1px solid var(--grey-border); border-radius:12px; cursor:pointer; transition:all 0.2s;">
<input type="radio" name="colVis" value="private" class="form-check-input mt-1" checked>
<div>
<div style="font-weight:600; font-size:0.95rem; color:var(--black);"><i class="bi bi-lock me-1"></i> Private</div>
<div class="text-muted mt-1" style="font-size:0.8rem;">Visible only to you and workspace admins.</div>
</div>
</label>
<label class="d-flex align-items-start gap-3 p-3" style="border:1px solid var(--grey-border); border-radius:12px; cursor:pointer; transition:all 0.2s;">
<input type="radio" name="colVis" value="public" class="form-check-input mt-1">
<div>
<div style="font-weight:600; font-size:0.95rem; color:var(--black);"><i class="bi bi-globe2 me-1"></i> Public</div>
<div class="text-muted mt-1" style="font-size:0.8rem;">Everyone in your workspace can view and chat with these documents.</div>
</div>
</label>
</div>
</div>
</div>
<div class="modal-footer" style="border-top:none; padding: 16px 24px 24px;">
<button type="button" class="btn btn-light" data-bs-dismiss="modal" style="border-radius:10px; padding:8px 16px; font-weight:500;">Cancel</button>
<button type="button" class="btn btn-dark-custom" id="createColConfirmBtn" style="border-radius:10px; padding:8px 20px; font-weight:500;">Create Collection</button>
</div>
</div>
</div>
</div>
`;
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 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);
}
}
});
}