Changeset 0.14.0 (#67)
This commit is contained in:
@@ -576,6 +576,55 @@ const API = {
|
||||
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
|
||||
},
|
||||
|
||||
// ── Knowledge Bases ──────────────────────
|
||||
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
||||
createKnowledgeBase(name, description, scope, teamId) {
|
||||
const body = { name, description: description || '' };
|
||||
if (scope) body.scope = scope;
|
||||
if (teamId) body.team_id = teamId;
|
||||
return this._post('/api/v1/knowledge-bases', body);
|
||||
},
|
||||
getKnowledgeBase(id) { return this._get(`/api/v1/knowledge-bases/${id}`); },
|
||||
updateKnowledgeBase(id, updates) { return this._put(`/api/v1/knowledge-bases/${id}`, updates); },
|
||||
deleteKnowledgeBase(id) { return this._del(`/api/v1/knowledge-bases/${id}`); },
|
||||
searchKnowledgeBase(id, query, limit) {
|
||||
const body = { query };
|
||||
if (limit) body.limit = limit;
|
||||
return this._post(`/api/v1/knowledge-bases/${id}/search`, body);
|
||||
},
|
||||
|
||||
// KB Documents
|
||||
listKBDocuments(kbId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents`); },
|
||||
getKBDocumentStatus(kbId, docId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents/${docId}/status`); },
|
||||
deleteKBDocument(kbId, docId) { return this._del(`/api/v1/knowledge-bases/${kbId}/documents/${docId}`); },
|
||||
|
||||
async uploadKBDocument(kbId, file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
|
||||
const doFetch = () => fetch(BASE + `/api/v1/knowledge-bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
body: form,
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return this._parseJSON(resp, `/api/v1/knowledge-bases/${kbId}/documents`);
|
||||
},
|
||||
|
||||
// Channel ↔ KB linking
|
||||
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
|
||||
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_esc(s) {
|
||||
|
||||
@@ -188,6 +188,11 @@ async function startApp() {
|
||||
await initBanners();
|
||||
initAttachments();
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||
if (typeof KnowledgeUI !== 'undefined') {
|
||||
KnowledgeUI.init();
|
||||
} else {
|
||||
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
|
||||
}
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
|
||||
@@ -119,6 +119,9 @@ async function selectChat(chatId) {
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
|
||||
// Refresh KB toggle state for the new channel
|
||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||
}
|
||||
|
||||
// ── Per-Chat Model/Preset Persistence ────────
|
||||
@@ -227,6 +230,7 @@ async function newChat() {
|
||||
const ov = document.getElementById('sidebarOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
}
|
||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||
}
|
||||
|
||||
async function deleteChat(chatId) {
|
||||
|
||||
@@ -281,9 +281,7 @@ const DebugLog = {
|
||||
this.log('DIAG', '── Starting connection diagnostics ──');
|
||||
this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`);
|
||||
|
||||
const baseUrl = (typeof API !== 'undefined' && API._base)
|
||||
? API._base
|
||||
: window.location.origin;
|
||||
const baseUrl = window.location.origin + (window.__BASE__ || '');
|
||||
|
||||
// Test 1: Basic fetch to same origin
|
||||
this.log('DIAG', `Test 1: Health check → ${baseUrl}/api/v1/health`);
|
||||
|
||||
552
src/js/knowledge-ui.js
Normal file
552
src/js/knowledge-ui.js
Normal file
@@ -0,0 +1,552 @@
|
||||
// ── 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 = '<div class="kb-popup-empty">Start a conversation first</div>';
|
||||
popup.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
popup.innerHTML = '<div class="kb-popup-loading">Loading…</div>';
|
||||
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 = '<div class="kb-popup-empty">Failed to load knowledge bases</div>';
|
||||
console.warn('KB popup load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderChannelPopup(popup) {
|
||||
if (_allKBs.length === 0) {
|
||||
popup.innerHTML = `
|
||||
<div class="kb-popup-empty">
|
||||
No knowledge bases yet.
|
||||
<br><small>Create one in Settings → Knowledge Bases</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
||||
|
||||
let html = '<div class="kb-popup-header">Knowledge Bases</div>';
|
||||
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 += `
|
||||
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
|
||||
<span class="kb-popup-check"></span>
|
||||
<span class="kb-popup-label">
|
||||
<span class="kb-popup-name">${_esc(kb.name)}</span>
|
||||
<span class="kb-popup-meta">${docs}${scope}${status}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
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 = '<div class="kb-manage-loading">Loading…</div>';
|
||||
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 = '<div class="kb-manage-empty">Failed to load knowledge bases</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div class="kb-manage-header">
|
||||
<h3>${scopeLabel} Knowledge Bases</h3>
|
||||
<button class="btn-small btn-primary" id="kbCreateBtn">+ New</button>
|
||||
</div>`;
|
||||
|
||||
if (_allKBs.length === 0) {
|
||||
html += '<div class="kb-manage-empty">No knowledge bases yet. Create one to get started.</div>';
|
||||
} 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 += `<div class="kb-manage-summary">${_allKBs.length} KB${_allKBs.length !== 1 ? 's' : ''} · ${totalDocs} documents · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
|
||||
|
||||
html += '<div class="kb-manage-list">';
|
||||
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 += `
|
||||
<div class="kb-manage-item" data-kb-id="${kb.id}">
|
||||
<div class="kb-manage-info">
|
||||
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
|
||||
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
|
||||
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
|
||||
</div>
|
||||
<div class="kb-manage-actions">
|
||||
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
|
||||
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 = `
|
||||
<div class="modal" style="max-width: 420px">
|
||||
<div class="modal-header">
|
||||
<h2>New ${scopeLabel} Knowledge Base</h2>
|
||||
<button class="modal-close" id="kbCreateClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Name</label>
|
||||
<input type="text" id="kbCreateName" placeholder="e.g. Project Docs" maxlength="200"
|
||||
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Description (optional)</label>
|
||||
<textarea id="kbCreateDesc" rows="2" maxlength="2000" placeholder="What documents will this contain?"
|
||||
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;resize:vertical"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-small" id="kbCreateCancel">Cancel</button>
|
||||
<button class="btn-small btn-primary" id="kbCreateSave">Create</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = '<div class="kb-manage-loading">Loading documents…</div>';
|
||||
|
||||
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 = `<div class="kb-manage-empty">Failed to load documents: ${_esc(e.message || '')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDocumentsPanel(panel, kb, docs) {
|
||||
let html = `
|
||||
<div class="kb-manage-header">
|
||||
<button class="btn-small" id="kbDocsBack">← Back</button>
|
||||
<h3>${_esc(kb.name)}</h3>
|
||||
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
|
||||
+ Upload
|
||||
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
|
||||
</label>
|
||||
</div>`;
|
||||
|
||||
if (kb.description) {
|
||||
html += `<div class="kb-manage-desc-block">${_esc(kb.description)}</div>`;
|
||||
}
|
||||
|
||||
// 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 += `<div class="kb-manage-summary">${docs.length} file${docs.length !== 1 ? 's' : ''} · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
html += '<div class="kb-manage-empty">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
|
||||
} else {
|
||||
html += '<div class="kb-manage-list">';
|
||||
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 += `
|
||||
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
|
||||
<div class="kb-manage-info">
|
||||
<span class="kb-manage-name">${statusIcon} ${_esc(doc.filename)}</span>
|
||||
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
|
||||
</div>
|
||||
<div class="kb-manage-actions">
|
||||
<button class="btn-small btn-danger" data-doc-del="${doc.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
@@ -10,10 +10,11 @@ const ToolsToggle = (() => {
|
||||
|
||||
// Category display config
|
||||
const CATEGORY_META = {
|
||||
search: { label: 'Web Search', icon: '🔍' },
|
||||
notes: { label: 'Notes', icon: '📝' },
|
||||
utilities: { label: 'Utilities', icon: '🧮' },
|
||||
browser: { label: 'Extensions', icon: '🧩' },
|
||||
search: { label: 'Web Search', icon: '🔍' },
|
||||
knowledge: { label: 'Knowledge Bases', icon: '📚' },
|
||||
notes: { label: 'Notes', icon: '📝' },
|
||||
utilities: { label: 'Utilities', icon: '🧮' },
|
||||
browser: { label: 'Extensions', icon: '🧩' },
|
||||
};
|
||||
|
||||
// Cached tool manifest from GET /api/v1/tools
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
// ── Category → Section mapping ────────────
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams'],
|
||||
ai: ['providers', 'models', 'presets', 'roles'],
|
||||
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
|
||||
system: ['settings', 'storage', 'extensions'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
};
|
||||
|
||||
const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams',
|
||||
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles',
|
||||
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
@@ -27,6 +27,10 @@ const ADMIN_LOADERS = {
|
||||
providers: () => UI.loadAdminProviders(),
|
||||
models: () => UI.loadAdminModels(),
|
||||
presets: () => UI.loadAdminPresets(),
|
||||
knowledgeBases: () => {
|
||||
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
|
||||
KnowledgeUI.openAdminPanel();
|
||||
},
|
||||
settings: () => UI.loadAdminSettings(),
|
||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||
extensions: () => UI.loadAdminExtensions(),
|
||||
@@ -174,6 +178,15 @@ Object.assign(UI, {
|
||||
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
|
||||
if (tab === 'usage') UI.loadTeamUsage();
|
||||
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||
if (tab === 'knowledge') {
|
||||
if (typeof KnowledgeUI === 'undefined') {
|
||||
console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js');
|
||||
const c = document.getElementById('teamKBContent');
|
||||
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
|
||||
} else {
|
||||
KnowledgeUI.openTeamPanel(teamId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadAdminUsers(quiet) {
|
||||
|
||||
@@ -918,6 +918,15 @@ const UI = {
|
||||
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||||
if (tab === 'usage') UI.loadMyUsage();
|
||||
if (tab === 'roles') UI.loadUserRoles();
|
||||
if (tab === 'knowledgeBases') {
|
||||
if (typeof KnowledgeUI === 'undefined') {
|
||||
console.error('[Settings] KnowledgeUI not loaded — check js/knowledge-ui.js');
|
||||
const c = document.getElementById('kbManagePanel');
|
||||
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
|
||||
} else {
|
||||
KnowledgeUI.openManagePanel();
|
||||
}
|
||||
}
|
||||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||||
},
|
||||
|
||||
|
||||
@@ -402,32 +402,79 @@ function renderRoleConfig(containerEl, opts = {}) {
|
||||
|
||||
function filterModels(providerId, roleId) {
|
||||
const typeFilter = Roles.typeFor(roleId);
|
||||
return _models.filter(m =>
|
||||
m[pidField] === providerId &&
|
||||
(m.model_type || 'chat') === typeFilter
|
||||
);
|
||||
return _models.filter(m => {
|
||||
if (m[pidField] !== providerId) return false;
|
||||
const mt = m.model_type || '';
|
||||
// Embedding role: include models typed as 'embedding' OR untyped.
|
||||
// Many providers don't report type for embedding models.
|
||||
if (typeFilter === 'embedding') return !mt || mt === 'embedding';
|
||||
// Other roles: default untyped to 'chat' (existing behavior).
|
||||
return (mt || 'chat') === typeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
function onProviderChange(roleId, slot) {
|
||||
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
|
||||
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
||||
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
||||
const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`);
|
||||
if (!provSel || !modelSel) return;
|
||||
|
||||
modelSel.innerHTML = '<option value="">— select model —</option>';
|
||||
const provId = provSel.value;
|
||||
if (!provId) return;
|
||||
|
||||
filterModels(provId, roleId).forEach(m => {
|
||||
const models = filterModels(provId, roleId);
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m[midField];
|
||||
opt.textContent = m[mnameField] || m[midField];
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
|
||||
// Auto-switch to manual entry if dropdown has no models.
|
||||
if (models.length === 0 && modelInput && toggleBtn) {
|
||||
modelSel.style.display = 'none';
|
||||
modelInput.style.display = '';
|
||||
toggleBtn.title = 'Switch to dropdown';
|
||||
toggleBtn.textContent = '▾';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManualEntry(roleId, slot) {
|
||||
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
||||
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
||||
const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`);
|
||||
if (!modelSel || !modelInput || !toggleBtn) return;
|
||||
|
||||
const showingDropdown = modelSel.style.display !== 'none';
|
||||
if (showingDropdown) {
|
||||
// Switch to manual: copy dropdown value into input.
|
||||
modelInput.value = modelSel.value || modelInput.value;
|
||||
modelSel.style.display = 'none';
|
||||
modelInput.style.display = '';
|
||||
toggleBtn.title = 'Switch to dropdown';
|
||||
toggleBtn.textContent = '▾';
|
||||
} else {
|
||||
// Switch to dropdown: try to select the typed value.
|
||||
modelSel.style.display = '';
|
||||
modelInput.style.display = 'none';
|
||||
toggleBtn.title = 'Type model ID manually';
|
||||
toggleBtn.textContent = '✎';
|
||||
if (modelInput.value) {
|
||||
modelSel.value = modelInput.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getBinding(roleId, slot) {
|
||||
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
|
||||
const model = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`)?.value;
|
||||
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
||||
const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`);
|
||||
// Use manual input if it's visible, otherwise use dropdown.
|
||||
const model = (modelInput && modelInput.style.display !== 'none')
|
||||
? modelInput.value.trim()
|
||||
: (modelSel?.value || '');
|
||||
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
|
||||
}
|
||||
|
||||
@@ -478,11 +525,15 @@ function renderRoleConfig(containerEl, opts = {}) {
|
||||
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
|
||||
).join('');
|
||||
|
||||
const modelOptions = selectedProv
|
||||
? filterModels(selectedProv, roleId).map(m =>
|
||||
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
|
||||
).join('')
|
||||
: '';
|
||||
const dropdownModels = selectedProv ? filterModels(selectedProv, roleId) : [];
|
||||
const modelOptions = dropdownModels.map(m =>
|
||||
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
|
||||
).join('');
|
||||
|
||||
// Show manual entry if: saved model ID not in dropdown, or dropdown is empty with a provider selected.
|
||||
const savedNotInDropdown = selectedModel && selectedProv &&
|
||||
!dropdownModels.some(m => m[midField] === selectedModel);
|
||||
const showManual = savedNotInDropdown || (selectedProv && dropdownModels.length === 0);
|
||||
|
||||
return `
|
||||
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
|
||||
@@ -491,10 +542,17 @@ function renderRoleConfig(containerEl, opts = {}) {
|
||||
<option value="">${noneLabel}</option>
|
||||
${provOptions}
|
||||
</select>
|
||||
<select data-role-model="${roleId}-${slotName}" style="min-width:200px">
|
||||
<select data-role-model="${roleId}-${slotName}" style="min-width:200px${showManual ? ';display:none' : ''}">
|
||||
<option value="">— select model —</option>
|
||||
${modelOptions}
|
||||
</select>
|
||||
<input type="text" data-role-model-input="${roleId}-${slotName}"
|
||||
placeholder="Model ID (e.g. text-embedding-3-small)"
|
||||
value="${esc(showManual ? selectedModel : '')}"
|
||||
style="min-width:200px;font-size:12px${showManual ? '' : ';display:none'}">
|
||||
<button type="button" class="btn-small" data-role-model-toggle="${roleId}-${slotName}"
|
||||
title="${showManual ? 'Switch to dropdown' : 'Type model ID manually'}"
|
||||
style="padding:2px 6px;font-size:12px;line-height:1">${showManual ? '▾' : '✎'}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -553,6 +611,12 @@ function renderRoleConfig(containerEl, opts = {}) {
|
||||
if (testBtn) { handleTest(testBtn.dataset.roleTest); return; }
|
||||
const clearBtn = e.target.closest('[data-role-clear]');
|
||||
if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; }
|
||||
const toggleBtn = e.target.closest('[data-role-model-toggle]');
|
||||
if (toggleBtn) {
|
||||
const [roleId, slot] = toggleBtn.dataset.roleModelToggle.split('-');
|
||||
toggleManualEntry(roleId, slot);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return { refresh };
|
||||
|
||||
@@ -58,13 +58,16 @@ Object.assign(UI, {
|
||||
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
||||
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
|
||||
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
|
||||
const kbTabBtn = document.getElementById('settingsKBTabBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.policies?.allow_user_byok === 'true';
|
||||
if (!kbTabBtn) console.warn('[Settings] settingsKBTabBtn not found in DOM');
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
|
||||
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
|
||||
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
checkUserPresetsAllowed() {
|
||||
@@ -146,11 +149,11 @@ Object.assign(UI, {
|
||||
document.getElementById('teamAdminPicker').style.display = 'none';
|
||||
document.getElementById('teamAdminContent').style.display = '';
|
||||
|
||||
// Reset forms
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||
// Reset forms (null-safe — not all form containers may exist)
|
||||
['settingsTeamAddMember', 'settingsTeamAddPreset', 'settingsTeamProviderForm'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
const af = document.getElementById('teamAuditFilterAction');
|
||||
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user