Changeset 0.14.0 (#67)
This commit is contained in:
@@ -899,7 +899,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.input-wrap textarea {
|
||||
flex: 1; resize: none; background: none; border: none;
|
||||
color: var(--text); padding: 12px 0 12px 16px;
|
||||
color: var(--text); padding: 12px 0 12px 8px;
|
||||
font-family: var(--font); font-size: 14px;
|
||||
max-height: 200px; line-height: 1.5;
|
||||
}
|
||||
@@ -908,6 +908,12 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
|
||||
|
||||
/* Left-side toolbar: attach, tools, KB — single container handles alignment */
|
||||
.input-toolbar {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 2px 6px 8px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
padding: 6px; border-radius: var(--radius);
|
||||
@@ -925,7 +931,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Attachments ─────────────────────────── */
|
||||
|
||||
.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; }
|
||||
.attach-btn { color: var(--text-3); }
|
||||
.attach-btn:hover { color: var(--text); }
|
||||
|
||||
.attachment-strip {
|
||||
@@ -1950,8 +1956,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
|
||||
/* ── Tools Toggle ────────────────────────── */
|
||||
|
||||
.tools-toggle-wrap { position: relative; }
|
||||
.tools-toggle-btn { padding: 6px 4px 12px 4px; }
|
||||
.tools-toggle-wrap { position: relative; display: flex; align-items: center; }
|
||||
.tools-toggle-btn.has-disabled { color: var(--text-3); }
|
||||
.tools-toggle-btn.has-disabled::after {
|
||||
content: ''; position: absolute; top: 4px; right: 4px;
|
||||
@@ -1997,6 +2002,83 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
}
|
||||
.tools-popup-expand.open { transform: rotate(90deg); }
|
||||
|
||||
/* ── Knowledge Bases ────────────────────── */
|
||||
|
||||
/* Toggle button in input bar */
|
||||
.kb-toggle-wrap {
|
||||
position: relative; display: flex; align-items: center;
|
||||
}
|
||||
.kb-toggle-btn.has-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Channel KB popup */
|
||||
.kb-popup {
|
||||
bottom: 100%; left: 0; margin-bottom: 8px;
|
||||
min-width: 240px; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.kb-popup-header {
|
||||
padding: 8px 12px 4px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.kb-popup-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.kb-popup-item:hover { background: var(--bg-hover); }
|
||||
.kb-popup-item .kb-popup-check {
|
||||
width: 16px; height: 16px; border-radius: 3px;
|
||||
border: 1.5px solid var(--border); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.kb-popup-item.enabled .kb-popup-check {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.kb-popup-item.enabled .kb-popup-check::after {
|
||||
content: '✓'; font-size: 11px; color: #fff;
|
||||
}
|
||||
.kb-popup-label { display: flex; flex-direction: column; min-width: 0; }
|
||||
.kb-popup-name {
|
||||
font-size: 13px; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.kb-popup-meta { font-size: 11px; color: var(--text-3); }
|
||||
.kb-popup-empty, .kb-popup-loading {
|
||||
padding: 16px; text-align: center; font-size: 13px; color: var(--text-3);
|
||||
}
|
||||
|
||||
/* KB management panel (settings tab) */
|
||||
.kb-manage-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.kb-manage-header h3 { flex: 1; margin: 0; font-size: 15px; }
|
||||
.kb-manage-summary { font-size: 11px; color: var(--text-3); margin-bottom: 10px; padding: 0 2px; }
|
||||
.kb-manage-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.kb-manage-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-radius: var(--radius);
|
||||
background: var(--bg-2); transition: background var(--transition);
|
||||
}
|
||||
.kb-manage-item:hover { background: var(--bg-hover); }
|
||||
.kb-manage-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.kb-manage-name { font-size: 13px; font-weight: 500; color: var(--text); }
|
||||
.kb-manage-desc { font-size: 12px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kb-manage-stats { font-size: 11px; color: var(--text-3); }
|
||||
.kb-manage-desc-block { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
|
||||
.kb-manage-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.kb-manage-empty, .kb-manage-loading {
|
||||
padding: 20px; text-align: center; font-size: 13px; color: var(--text-3);
|
||||
}
|
||||
.kb-doc-item[data-doc-status="pending"] .kb-manage-name,
|
||||
.kb-doc-item[data-doc-status="extracting"] .kb-manage-name,
|
||||
.kb-doc-item[data-doc-status="chunking"] .kb-manage-name,
|
||||
.kb-doc-item[data-doc-status="embedding"] .kb-manage-name {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
|
||||
@@ -149,15 +149,23 @@
|
||||
</div>
|
||||
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
|
||||
<div class="input-wrap">
|
||||
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<input type="file" id="fileInput" multiple style="display:none">
|
||||
<div class="tools-toggle-wrap" style="display:none">
|
||||
<button class="action-btn tools-toggle-btn" id="toolsToggleBtn" title="Tools">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<div class="input-toolbar">
|
||||
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<div class="popup-menu tools-popup" id="toolsPopup"></div>
|
||||
<input type="file" id="fileInput" multiple style="display:none">
|
||||
<div class="tools-toggle-wrap" style="display:none">
|
||||
<button class="action-btn tools-toggle-btn" id="toolsToggleBtn" title="Tools">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
<div class="popup-menu tools-popup" id="toolsPopup"></div>
|
||||
</div>
|
||||
<div class="kb-toggle-wrap">
|
||||
<button class="action-btn kb-toggle-btn" id="kbToggleBtn" title="Knowledge Bases">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
</button>
|
||||
<div class="popup-menu kb-popup" id="kbPopup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
@@ -349,6 +357,7 @@
|
||||
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
|
||||
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
|
||||
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
|
||||
<button class="settings-tab" data-stab="knowledgeBases" onclick="UI.switchSettingsTab('knowledgeBases')" id="settingsKBTabBtn" style="display:none">Knowledge</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- General Tab -->
|
||||
@@ -476,6 +485,9 @@
|
||||
<span>ℹ️</span> Add a personal provider first to configure role overrides.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-content" id="settingsKnowledgeBasesTab" style="display:none">
|
||||
<div id="kbManagePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
@@ -500,6 +512,7 @@
|
||||
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Personas</button>
|
||||
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
|
||||
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
|
||||
<button class="admin-tab" data-ttab="knowledge" onclick="UI.switchTeamTab('knowledge')">Knowledge</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Members tab -->
|
||||
@@ -558,12 +571,15 @@
|
||||
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Knowledge tab -->
|
||||
<div class="team-tab-content" id="teamTabKnowledge" style="display:none">
|
||||
<div id="teamKBContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Admin Modal ─────────────────────────── -->
|
||||
<!-- ── Admin Panel (fullscreen overlay) ──── -->
|
||||
<div class="admin-panel" id="adminPanel" style="display:none">
|
||||
<div class="admin-topbar">
|
||||
@@ -665,6 +681,9 @@
|
||||
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
|
||||
<div id="adminPresetList"></div>
|
||||
</div>
|
||||
<div class="admin-section-content" id="adminKnowledgeBasesTab" style="display:none">
|
||||
<div id="adminKBContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="admin-section-content" id="adminSettingsTab" style="display:none">
|
||||
@@ -900,6 +919,7 @@
|
||||
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -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>'; }
|
||||
|
||||
|
||||
16
src/sw.js
16
src/sw.js
@@ -3,9 +3,13 @@
|
||||
// ==========================================
|
||||
// Caches the app shell for offline / instant load.
|
||||
// API calls always go to network (never cached).
|
||||
// Version string is injected by the frontend entrypoint.
|
||||
//
|
||||
// Cache key includes a build hash computed at container
|
||||
// startup from the JS file contents. Every deploy produces
|
||||
// a new hash, which triggers install → precache → activate
|
||||
// → purge old caches automatically.
|
||||
|
||||
const CACHE_NAME = 'switchboard-%%APP_VERSION%%';
|
||||
const CACHE_NAME = 'switchboard-%%APP_VERSION%%-%%BUILD_HASH%%';
|
||||
|
||||
// App shell files to pre-cache on install
|
||||
const SHELL_FILES = [
|
||||
@@ -14,14 +18,18 @@ const SHELL_FILES = [
|
||||
'./css/styles.css',
|
||||
'./js/debug.js',
|
||||
'./js/events.js',
|
||||
'./js/extensions.js',
|
||||
'./js/api.js',
|
||||
'./js/ui-format.js',
|
||||
'./js/ui-primitives.js',
|
||||
'./js/ui-core.js',
|
||||
'./js/ui-settings.js',
|
||||
'./js/ui-admin.js',
|
||||
'./js/tokens.js',
|
||||
'./js/notes.js',
|
||||
'./js/attachments.js',
|
||||
'./js/tools-toggle.js',
|
||||
'./js/knowledge-ui.js',
|
||||
'./js/chat.js',
|
||||
'./js/settings-handlers.js',
|
||||
'./js/admin-handlers.js',
|
||||
@@ -56,7 +64,7 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch: network-first for API/WS, cache-first for shell assets
|
||||
// Fetch: network-first for API/WS, stale-while-revalidate for shell assets
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
@@ -68,7 +76,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for app shell, network fallback
|
||||
// Stale-while-revalidate: serve cached, update in background
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
const fetchPromise = fetch(event.request).then(response => {
|
||||
|
||||
Reference in New Issue
Block a user