Spaces:
Sleeping
Sleeping
| /* ========================================================================= | |
| 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 & 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); | |
| } | |
| } | |
| }); | |
| } | |