Changeset 0.14.0 (#67)

This commit is contained in:
2026-02-26 15:59:26 +00:00
parent 1a71658b24
commit e2149e249d
38 changed files with 5171 additions and 141 deletions

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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
View 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,
};
})();

View File

@@ -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

View File

@@ -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) {

View File

@@ -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();
},

View File

@@ -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 };

View File

@@ -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>'; }