// ── knowledge-ui.js ───────────────────────── // Knowledge base management UI. // // Three contexts: // 1. User Settings → personal KBs (scope: personal) // 2. Admin Panel → global KBs (scope: global) // 3. Team Admin → team-scoped KBs (scope: team) // // Plus the channel KB toggle popup in the chat input bar. // // Depends on: API (api.js), App (app.js) 'use strict'; const KnowledgeUI = (() => { console.debug('[KnowledgeUI] module loaded'); let _allKBs = []; // [{id, name, scope, document_count, ...}] let _channelKBs = []; // [{kb_id, kb_name, enabled, document_count}] let _pollTimers = {}; // docId → timer let _currentKBId = null; // for document panel let _panelCtx = null; // { container, scope, teamId } — current panel context // ═════════════════════════════════════════ // Channel KB Toggle Popup (chat input bar) // ═════════════════════════════════════════ async function openChannelPopup() { const popup = document.getElementById('kbPopup'); if (!popup) return; if (!App.currentChatId) { popup.innerHTML = '
Start a conversation first
'; popup.classList.add('open'); return; } popup.innerHTML = '
Loading…
'; popup.classList.add('open'); try { const [allResp, chResp] = await Promise.all([ API.listKnowledgeBases(), API.getChannelKBs(App.currentChatId), ]); _allKBs = allResp.data || []; _channelKBs = chResp.data || []; _renderChannelPopup(popup); } catch (e) { popup.innerHTML = '
Failed to load knowledge bases
'; console.warn('KB popup load failed:', e); } } function _renderChannelPopup(popup) { if (_allKBs.length === 0) { popup.innerHTML = `
No knowledge bases yet.
Create one in Settings → Knowledge Bases
`; return; } const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id)); let html = '
Knowledge Bases
'; for (const kb of _allKBs) { const linked = linkedSet.has(kb.id); const scope = kb.scope === 'personal' ? '' : ` · ${kb.scope}`; const docs = kb.document_count === 1 ? '1 doc' : `${kb.document_count} docs`; const status = kb.chunk_count === 0 ? ' · no content' : ''; html += `
${_esc(kb.name)} ${docs}${scope}${status}
`; } popup.innerHTML = html; } async function _toggleChannelKB(kbId) { if (!App.currentChatId) return; const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id)); if (linkedSet.has(kbId)) { linkedSet.delete(kbId); } else { linkedSet.add(kbId); } try { const resp = await API.setChannelKBs(App.currentChatId, [...linkedSet]); _channelKBs = resp.data || []; const popup = document.getElementById('kbPopup'); if (popup) _renderChannelPopup(popup); _updateKBBtnState(); } catch (e) { console.warn('Failed to update channel KBs:', e); } } function _updateKBBtnState() { const btn = document.getElementById('kbToggleBtn'); if (!btn) return; const count = _channelKBs.filter(kb => kb.enabled !== false).length; btn.classList.toggle('has-active', count > 0); btn.title = count > 0 ? `Knowledge Bases (${count} active)` : 'Knowledge Bases'; } // ═════════════════════════════════════════ // Shared KB Management Panel // ═════════════════════════════════════════ // Rendered into any container with { scope, teamId } context. /** * Open KB management panel. * @param {string} containerId — DOM id of the container to render into * @param {string} scope — 'personal', 'global', or 'team' * @param {string} [teamId] — required when scope='team' */ async function openPanel(containerId, scope, teamId) { console.debug(`[KB] openPanel: container=${containerId}, scope=${scope}, teamId=${teamId}`); const panel = document.getElementById(containerId); if (!panel) { console.warn(`[KB] openPanel: container '${containerId}' not found`); return; } _panelCtx = { container: containerId, scope, teamId }; panel.innerHTML = '
Loading…
'; panel.style.display = ''; try { const resp = await API.listKnowledgeBases(); // Filter by scope const all = resp.data || []; _allKBs = all.filter(kb => { if (scope === 'personal') return kb.scope === 'personal'; if (scope === 'global') return kb.scope === 'global'; if (scope === 'team') return kb.scope === 'team' && kb.team_id === teamId; return true; }); _renderManagePanel(panel); } catch (e) { console.warn('[KB] openPanel error:', e); panel.innerHTML = '
Failed to load knowledge bases
'; } } // Convenience wrappers function openManagePanel() { openPanel('kbManagePanel', 'personal'); } function openAdminPanel() { openPanel('adminKBContent', 'global'); } function openTeamPanel(teamId) { openPanel('teamKBContent', 'team', teamId); } function _renderManagePanel(panel) { const ctx = _panelCtx || { scope: 'personal' }; const scopeLabel = ctx.scope === 'global' ? 'Global' : ctx.scope === 'team' ? 'Team' : 'Personal'; let html = `

${scopeLabel} Knowledge Bases

`; if (_allKBs.length === 0) { html += '
No knowledge bases yet. Create one to get started.
'; } else { // Summary stats const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0); const totalChunks = _allKBs.reduce((s, kb) => s + (kb.chunk_count || 0), 0); const totalBytes = _allKBs.reduce((s, kb) => s + (kb.total_bytes || 0), 0); html += `
${_allKBs.length} KB${_allKBs.length !== 1 ? 's' : ''} · ${totalDocs} documents · ${totalChunks} chunks · ${_formatSize(totalBytes)}
`; html += '
'; for (const kb of _allKBs) { const scope = kb.scope === 'personal' ? '🔒' : kb.scope === 'team' ? '👥' : '🌐'; const status = kb.status === 'processing' ? ' · ⏳ processing' : ''; const docs = kb.document_count === 1 ? '1 document' : `${kb.document_count} documents`; const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`; const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : ''; html += `
${scope} ${_esc(kb.name)} ${_esc(kb.description || '')} ${docs} · ${chunks}${size}${status}
${kb.scope !== 'personal' ? `` : ''}
`; } html += '
'; } panel.innerHTML = html; // Wire create button document.getElementById('kbCreateBtn')?.addEventListener('click', () => { _showCreateDialog(ctx.scope, ctx.teamId); }); // Wire item actions panel.querySelectorAll('[data-kb-docs]').forEach(btn => { btn.addEventListener('click', () => _openDocumentsPanel(btn.dataset.kbDocs)); }); panel.querySelectorAll('[data-kb-del]').forEach(btn => { btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel)); }); // Wire grant access buttons (admin only, non-personal KBs) panel.querySelectorAll('[data-kb-grant]').forEach(btn => { btn.addEventListener('click', () => { if (typeof UI !== 'undefined' && UI.openGrantPicker) { UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`); } }); }); } // ── Create KB Dialog ───────────────────── function _showCreateDialog(scope, teamId) { const existing = document.getElementById('kbCreateDialog'); if (existing) existing.remove(); const scopeLabel = scope === 'global' ? 'Global' : scope === 'team' ? 'Team' : 'Personal'; const dialog = document.createElement('div'); dialog.id = 'kbCreateDialog'; dialog.className = 'modal-overlay active'; dialog.innerHTML = ` `; document.body.appendChild(dialog); const close = () => dialog.remove(); document.getElementById('kbCreateClose').addEventListener('click', close); document.getElementById('kbCreateCancel').addEventListener('click', close); document.getElementById('kbCreateSave').addEventListener('click', async () => { const name = document.getElementById('kbCreateName').value.trim(); if (!name) { document.getElementById('kbCreateName').focus(); return; } const desc = document.getElementById('kbCreateDesc').value.trim(); try { await API.createKnowledgeBase(name, desc, scope, teamId); close(); _refreshCurrentPanel(); } catch (e) { alert('Failed to create: ' + (e.message || e)); } }); // Enter key submits document.getElementById('kbCreateName').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('kbCreateSave').click(); }); dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); }); // Focus name field after render requestAnimationFrame(() => document.getElementById('kbCreateName')?.focus()); } async function _deleteKB(kbId) { const kb = _allKBs.find(k => k.id === kbId); if (!confirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return; try { await API.deleteKnowledgeBase(kbId); _refreshCurrentPanel(); } catch (e) { alert('Delete failed: ' + (e.message || e)); } } /** Refresh whatever panel is currently active. */ function _refreshCurrentPanel() { if (!_panelCtx) return; openPanel(_panelCtx.container, _panelCtx.scope, _panelCtx.teamId); } // ═════════════════════════════════════════ // Documents Panel // ═════════════════════════════════════════ async function _openDocumentsPanel(kbId) { // Use the same alias for backward compat return openDocumentsPanel(kbId); } async function openDocumentsPanel(kbId) { _currentKBId = kbId; const containerId = _panelCtx?.container || 'kbManagePanel'; const panel = document.getElementById(containerId); if (!panel) return; panel.innerHTML = '
Loading documents…
'; try { const [kbResp, docsResp] = await Promise.all([ API.getKnowledgeBase(kbId), API.listKBDocuments(kbId), ]); const kb = kbResp; const docs = docsResp.data || docsResp || []; _renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []); } catch (e) { panel.innerHTML = `
Failed to load documents: ${_esc(e.message || '')}
`; } } function _renderDocumentsPanel(panel, kb, docs) { let html = `

${_esc(kb.name)}

`; if (kb.description) { html += `
${_esc(kb.description)}
`; } // Stats line const totalBytes = docs.reduce((s, d) => s + (d.size_bytes || 0), 0); const totalChunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0); if (docs.length > 0) { html += `
${docs.length} file${docs.length !== 1 ? 's' : ''} · ${totalChunks} chunks · ${_formatSize(totalBytes)}
`; } if (docs.length === 0) { html += '
No documents yet. Upload text, markdown, CSV, or HTML files.
'; } else { html += '
'; for (const doc of docs) { const statusIcon = _docStatusIcon(doc.status); const size = _formatSize(doc.size_bytes); const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : ''; const errText = doc.error ? ` · ⚠ ${_esc(doc.error)}` : ''; html += `
${statusIcon} ${_esc(doc.filename)} ${size}${chunks}${errText}
`; } html += '
'; } panel.innerHTML = html; // Wire back button document.getElementById('kbDocsBack')?.addEventListener('click', () => _refreshCurrentPanel()); // Wire upload document.getElementById('kbFileInput')?.addEventListener('change', async (e) => { const files = e.target.files; if (!files.length) return; for (const file of files) { await _uploadDocument(kb.id, file); } e.target.value = ''; openDocumentsPanel(kb.id); }); // Wire delete buttons panel.querySelectorAll('[data-doc-del]').forEach(btn => { btn.addEventListener('click', () => _deleteDocument(kb.id, btn.dataset.docDel)); }); // Start polling for in-progress documents for (const doc of docs) { if (['pending', 'extracting', 'chunking', 'embedding'].includes(doc.status)) { _startPolling(kb.id, doc.id); } } } async function _uploadDocument(kbId, file) { try { const resp = await API.uploadKBDocument(kbId, file); console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`); if (resp.id) _startPolling(kbId, resp.id); } catch (e) { alert(`Upload failed for ${file.name}: ${e.message || e}`); } } async function _deleteDocument(kbId, docId) { if (!confirm('Delete this document and all its chunks?')) return; try { _stopPolling(docId); await API.deleteKBDocument(kbId, docId); openDocumentsPanel(kbId); } catch (e) { alert('Delete failed: ' + (e.message || e)); } } // ═════════════════════════════════════════ // Status Polling // ═════════════════════════════════════════ function _startPolling(kbId, docId) { if (_pollTimers[docId]) return; _pollTimers[docId] = setInterval(async () => { try { const status = await API.getKBDocumentStatus(kbId, docId); _updateDocStatus(docId, status); if (status.status === 'ready' || status.status === 'error') { _stopPolling(docId); if (_currentKBId === kbId) openDocumentsPanel(kbId); } } catch { _stopPolling(docId); } }, 2000); } function _stopPolling(docId) { if (_pollTimers[docId]) { clearInterval(_pollTimers[docId]); delete _pollTimers[docId]; } } function _updateDocStatus(docId, status) { const item = document.querySelector(`[data-doc-id="${docId}"]`); if (!item) return; item.dataset.docStatus = status.status; const nameEl = item.querySelector('.kb-manage-name'); if (nameEl) { const icon = _docStatusIcon(status.status); const filename = nameEl.textContent.replace(/^[^\s]+ /, ''); nameEl.innerHTML = `${icon} ${_esc(status.filename || filename)}`; } const statsEl = item.querySelector('.kb-manage-stats'); if (statsEl && status.chunk_count > 0) { statsEl.textContent = `${status.chunk_count} chunks`; } } // ═════════════════════════════════════════ // Event Wiring // ═════════════════════════════════════════ function init() { // KB toggle button in chat input bar const btn = document.getElementById('kbToggleBtn'); const popup = document.getElementById('kbPopup'); if (btn && popup) { btn.addEventListener('click', (e) => { e.stopPropagation(); if (popup.classList.contains('open')) { popup.classList.remove('open'); } else { openChannelPopup(); } }); popup.addEventListener('click', (e) => { e.stopPropagation(); const item = e.target.closest('.kb-popup-item'); if (item) _toggleChannelKB(item.dataset.kbId); }); document.addEventListener('click', () => popup.classList.remove('open')); } } /** Refresh channel KB state when switching chats. */ async function onChatChanged() { _channelKBs = []; if (App.currentChatId) { try { const resp = await API.getChannelKBs(App.currentChatId); _channelKBs = resp.data || []; } catch { /* silent */ } } _updateKBBtnState(); } // ═════════════════════════════════════════ // Helpers // ═════════════════════════════════════════ function _docStatusIcon(status) { switch (status) { case 'ready': return '✅'; case 'error': return '❌'; case 'pending': return '⏳'; case 'extracting': return '📖'; case 'chunking': return '✂️'; case 'embedding': return '🧮'; default: return '📄'; } } function _formatSize(bytes) { if (!bytes || bytes === 0) return '0 B'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function _esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ═════════════════════════════════════════ // Public API // ═════════════════════════════════════════ return { init, onChatChanged, openManagePanel, // User Settings → personal openAdminPanel, // Admin Panel → global openTeamPanel, // Team Admin → team-scoped openDocumentsPanel, openChannelPopup, }; })();