// ── 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 = '
';
popup.classList.add('open');
return;
}
popup.innerHTML = '';
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 = '';
console.warn('KB popup load failed:', e);
}
}
function _renderChannelPopup(popup) {
if (_allKBs.length === 0) {
popup.innerHTML = `
`;
return;
}
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
let html = '';
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 += `
`;
}
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 = `
`;
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 = `
`;
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,
};
})();