Changeset 0.17.0 (#75)
This commit is contained in:
@@ -193,6 +193,8 @@ function ensureAdminPresetForm() {
|
||||
prefix: 'adminPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: true,
|
||||
showKBPicker: true,
|
||||
kbScope: 'admin',
|
||||
onSubmit: (vals) => createAdminPreset(vals),
|
||||
onCancel: () => {
|
||||
container.style.display = 'none';
|
||||
@@ -263,6 +265,10 @@ function editAdminPreset(id) {
|
||||
document.getElementById('adminAddPresetForm').style.display = '';
|
||||
form.setValues(p);
|
||||
form.setSubmitLabel('Update');
|
||||
// Load KB bindings (v0.17.0)
|
||||
if (form.kbPicker) {
|
||||
loadPersonaKBs(id, form.kbPicker, '/api/v1/admin');
|
||||
}
|
||||
}
|
||||
|
||||
async function createAdminPreset(vals) {
|
||||
@@ -299,6 +305,12 @@ async function createAdminPreset(vals) {
|
||||
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
// Save KB bindings (v0.17.0)
|
||||
if (presetId && vals._kbValues && _adminPresetForm?.kbPicker) {
|
||||
try { await API.adminSetPresetKBs(presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('Preset KB binding failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
if (_adminPresetForm) {
|
||||
@@ -646,6 +658,20 @@ function _initAdminListeners() {
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
|
||||
if (typeof Events !== 'undefined') {
|
||||
Events.on('role.fallback', (payload) => {
|
||||
const banner = document.getElementById('roleFallbackBanner');
|
||||
if (!banner) return;
|
||||
const msg = payload?.message || 'A model role is using its fallback provider.';
|
||||
banner.querySelector('.fallback-msg').textContent = msg;
|
||||
banner.style.display = '';
|
||||
// Auto-dismiss after 60s
|
||||
clearTimeout(banner._dismissTimer);
|
||||
banner._dismissTimer = setTimeout(() => { banner.style.display = 'none'; }, 60000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension admin actions (global scope for onclick) ──
|
||||
|
||||
@@ -158,6 +158,7 @@ const API = {
|
||||
},
|
||||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
@@ -302,6 +303,8 @@ const API = {
|
||||
// User presets
|
||||
listUserPresets() { return this._get('/api/v1/presets'); },
|
||||
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||
getUserPresetKBs(presetId) { return this._get(`/api/v1/presets/${presetId}/knowledge-bases`); },
|
||||
setUserPresetKBs(presetId, data) { return this._put(`/api/v1/presets/${presetId}/knowledge-bases`, data); },
|
||||
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||
listAllModels() { return this._get('/api/v1/models'); },
|
||||
@@ -389,6 +392,8 @@ const API = {
|
||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||
adminGetPresetKBs(presetId) { return this._get(`/api/v1/admin/presets/${presetId}/knowledge-bases`); },
|
||||
adminSetPresetKBs(presetId, data) { return this._put(`/api/v1/admin/presets/${presetId}/knowledge-bases`, data); },
|
||||
|
||||
// ── Admin Teams ─────────────────────────
|
||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||
@@ -447,6 +452,8 @@ const API = {
|
||||
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||||
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||||
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||
teamGetPresetKBs(teamId, presetId) { return this._get(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`); },
|
||||
teamSetPresetKBs(teamId, presetId, data) { return this._put(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`, data); },
|
||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
||||
@@ -611,6 +618,7 @@ const API = {
|
||||
|
||||
// ── Knowledge Bases ──────────────────────
|
||||
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
||||
listDiscoverableKBs() { return this._get('/api/v1/knowledge-bases-discoverable'); },
|
||||
createKnowledgeBase(name, description, scope, teamId) {
|
||||
const body = { name, description: description || '' };
|
||||
if (scope) body.scope = scope;
|
||||
|
||||
@@ -199,6 +199,15 @@ async function startApp() {
|
||||
}
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
|
||||
// Restore last-active chat from sessionStorage (QOL: state restore on refresh)
|
||||
try {
|
||||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||||
selectChat(savedChat);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
@@ -206,6 +215,13 @@ async function startApp() {
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (App.user?.role !== 'admin') return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
@@ -213,6 +229,27 @@ async function startApp() {
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
// ── Role Fallback Banner (v0.17.0) ──────────
|
||||
|
||||
function showFallbackBanner(data) {
|
||||
let banner = document.getElementById('roleFallbackBanner');
|
||||
if (!banner) {
|
||||
// Create persistent banner above messages
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'roleFallbackBanner';
|
||||
banner.className = 'role-fallback-banner';
|
||||
const msgs = document.getElementById('chatMessages');
|
||||
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
|
||||
else return;
|
||||
}
|
||||
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
|
||||
banner.innerHTML = `
|
||||
<span class="fallback-icon">⚠️</span>
|
||||
<span class="fallback-msg">${esc(msg)}</span>
|
||||
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
|
||||
banner.style.display = '';
|
||||
}
|
||||
|
||||
// ── Modal Helpers ───────────────────────────
|
||||
|
||||
function openModal(id) {
|
||||
@@ -553,6 +590,9 @@ async function initBanners() {
|
||||
// Storage capabilities (file upload, vision)
|
||||
App.storageConfigured = !!data.storage_configured;
|
||||
|
||||
// Paste-to-file threshold (synced from backend, default 2000)
|
||||
App.pasteToFileChars = data.paste_to_file_chars || 2000;
|
||||
|
||||
// Also flatten into serverSettings for backward compat
|
||||
App.serverSettings = {};
|
||||
if (data.banner) App.serverSettings.banner = data.banner;
|
||||
|
||||
@@ -647,9 +647,11 @@ async function loadAdminStorage() {
|
||||
|
||||
// ── Constants (Paste) ───────────────────────
|
||||
// Threshold for auto-attaching pasted text as a file.
|
||||
// TODO: sync from backend global_settings (storage_paste_to_file_chars)
|
||||
// once it's included in the PublicSettings boot payload.
|
||||
const PASTE_TO_FILE_CHARS = 2000;
|
||||
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
|
||||
// Falls back to 2000 if not yet loaded.
|
||||
function _pasteToFileChars() {
|
||||
return App.pasteToFileChars || 2000;
|
||||
}
|
||||
|
||||
// Accept string for file picker — mirrors backend allowedMIMETypes.
|
||||
// Backend is authoritative (rejects disallowed types server-side),
|
||||
@@ -761,9 +763,10 @@ function _initAttachmentListeners() {
|
||||
}
|
||||
|
||||
// Large text detection — auto-attach as .txt file
|
||||
if (PASTE_TO_FILE_CHARS > 0) {
|
||||
const _ptfChars = _pasteToFileChars();
|
||||
if (_ptfChars > 0) {
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text && text.length > PASTE_TO_FILE_CHARS) {
|
||||
if (text && text.length > _ptfChars) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
|
||||
|
||||
118
src/js/chat.js
118
src/js/chat.js
@@ -38,7 +38,7 @@ async function summarizeAndContinue() {
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
updateInputTokens();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -96,6 +96,7 @@ async function loadChats() {
|
||||
async function selectChat(chatId) {
|
||||
clearStaged(); // Discard any staged attachments from previous chat
|
||||
App.currentChatId = chatId;
|
||||
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
|
||||
UI.renderChatList();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
@@ -137,11 +138,32 @@ async function selectChat(chatId) {
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
updateInputTokens();
|
||||
|
||||
// Refresh KB toggle state for the new channel
|
||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||
updateChatTokenCount();
|
||||
}
|
||||
|
||||
// ── Chat Header Token Count ──────────────────
|
||||
|
||||
function updateChatTokenCount() {
|
||||
const el = document.getElementById('chatTokenCount');
|
||||
if (!el) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
||||
|
||||
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext > 0) {
|
||||
const pct = tokens / budget.maxContext;
|
||||
el.textContent = `${Tokens.format(tokens)} / ${Tokens.format(budget.maxContext)}`;
|
||||
el.className = 'chat-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
|
||||
} else {
|
||||
el.textContent = `~${Tokens.format(tokens)}`;
|
||||
el.className = 'chat-token-count';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-Chat Model/Preset Persistence ────────
|
||||
@@ -242,7 +264,7 @@ async function newChat() {
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
updateInputTokens();
|
||||
document.getElementById('messageInput').focus();
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -269,6 +291,89 @@ async function deleteChat(chatId) {
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Auto-Name Chat (utility model) ───────────
|
||||
|
||||
let _autoNamePending = new Set(); // debounce: one inflight per chat
|
||||
|
||||
async function autoNameChat(chatId, chat) {
|
||||
if (!chatId || !chat) return;
|
||||
if (_autoNamePending.has(chatId)) return;
|
||||
|
||||
// Only auto-name if title looks auto-generated (first 50 chars of user msg or "New Chat")
|
||||
const firstUserMsg = chat.messages.find(m => m.role === 'user');
|
||||
if (!firstUserMsg) return;
|
||||
const truncated = firstUserMsg.content.slice(0, 50).trim();
|
||||
const currentTitle = (chat.title || '').trim();
|
||||
const isAutoGenerated = !currentTitle ||
|
||||
currentTitle === 'New Chat' ||
|
||||
currentTitle === truncated ||
|
||||
truncated.startsWith(currentTitle);
|
||||
if (!isAutoGenerated) return;
|
||||
|
||||
_autoNamePending.add(chatId);
|
||||
try {
|
||||
const resp = await API.generateTitle(chatId);
|
||||
if (resp?.title && resp.title !== chat.title) {
|
||||
chat.title = resp.title;
|
||||
UI.renderChatList();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail — keep truncated title
|
||||
console.debug('Auto-name failed:', e.message);
|
||||
} finally {
|
||||
_autoNamePending.delete(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chat Rename (inline edit) ────────────────
|
||||
|
||||
function startRenameChat(chatId) {
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
// Find the title span for this chat
|
||||
const items = document.querySelectorAll('.chat-item');
|
||||
let titleSpan = null;
|
||||
for (const item of items) {
|
||||
if (item.getAttribute('onclick')?.includes(chatId)) {
|
||||
titleSpan = item.querySelector('.chat-item-title');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!titleSpan) return;
|
||||
|
||||
// Replace span with input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'chat-rename-input';
|
||||
input.value = chat.title || '';
|
||||
input.onclick = (e) => e.stopPropagation();
|
||||
input.ondblclick = (e) => e.stopPropagation();
|
||||
|
||||
const commit = async () => {
|
||||
const newTitle = input.value.trim();
|
||||
if (newTitle && newTitle !== chat.title) {
|
||||
try {
|
||||
await API.updateChannel(chatId, { title: newTitle });
|
||||
chat.title = newTitle;
|
||||
} catch (e) {
|
||||
UI.toast('Rename failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
UI.renderChatList();
|
||||
};
|
||||
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||
if (e.key === 'Escape') { input.value = chat.title || ''; input.blur(); }
|
||||
});
|
||||
|
||||
titleSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
// ── Send Message ─────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -328,6 +433,11 @@ async function sendMessage() {
|
||||
|
||||
// Persist the model/preset selection for this chat
|
||||
_saveChatModel(App.currentChatId);
|
||||
|
||||
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||
if (chat.messages.length <= 3) {
|
||||
autoNameChat(App.currentChatId, chat);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
@@ -381,7 +491,7 @@ async function reloadActivePath() {
|
||||
chat.messageCount = chat.messages.length;
|
||||
await loadChannelAttachments(App.currentChatId);
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
} catch (e) {
|
||||
console.error('Failed to reload path:', e.message);
|
||||
}
|
||||
|
||||
177
src/js/persona-kb.js
Normal file
177
src/js/persona-kb.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// persona-kb.js — Persona–Knowledge Base binding UI (v0.17.0)
|
||||
//
|
||||
// Provides renderPersonaKBPicker(container, opts) which renders a multi-select
|
||||
// KB picker with auto_search toggles for use in persona create/edit forms.
|
||||
// Pattern: (container, options) → control object with getValues/setValues/clear.
|
||||
|
||||
'use strict';
|
||||
|
||||
// ── Persona-KB Picker ───────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a KB picker into a container element.
|
||||
*
|
||||
* Options:
|
||||
* scope - 'admin' | 'team' | 'personal' — controls which KBs are shown
|
||||
* teamId - required for team scope
|
||||
* prefix - DOM id prefix (default: 'pkb')
|
||||
*
|
||||
* Returns: { getValues(), setValues(kbs), clear(), refresh() }
|
||||
*/
|
||||
function renderPersonaKBPicker(containerEl, options = {}) {
|
||||
const pfx = options.prefix || 'pkb';
|
||||
let _allKBs = []; // available KBs from server
|
||||
let _selected = {}; // kb_id → { selected: bool, auto_search: bool }
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="persona-kb-picker" id="${pfx}_picker">
|
||||
<label>Knowledge Bases</label>
|
||||
<div class="form-hint">Bind KBs to this persona — users will search these KBs automatically when using this persona.</div>
|
||||
<div class="persona-kb-list" id="${pfx}_list">
|
||||
<div class="empty-hint">Loading knowledge bases…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Load available KBs ──
|
||||
async function loadKBs() {
|
||||
try {
|
||||
let resp;
|
||||
if (options.scope === 'team' && options.teamId) {
|
||||
resp = await API._get(`/api/v1/teams/${options.teamId}/knowledge-bases`);
|
||||
} else if (options.scope === 'admin') {
|
||||
resp = await API._get('/api/v1/knowledge-bases');
|
||||
} else {
|
||||
// User scope — show discoverable KBs
|
||||
resp = await API._get('/api/v1/knowledge-bases-discoverable');
|
||||
}
|
||||
_allKBs = (resp.data || []).filter(kb => kb.chunk_count > 0);
|
||||
} catch (e) {
|
||||
_allKBs = [];
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Render KB list ──
|
||||
function render() {
|
||||
const list = document.getElementById(`${pfx}_list`);
|
||||
if (!list) return;
|
||||
|
||||
if (_allKBs.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">No knowledge bases with content available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = _allKBs.map(kb => {
|
||||
const sel = _selected[kb.id] || { selected: false, auto_search: false };
|
||||
return `
|
||||
<label class="persona-kb-item ${sel.selected ? 'selected' : ''}">
|
||||
<input type="checkbox" data-kb-id="${esc(kb.id)}" ${sel.selected ? 'checked' : ''}>
|
||||
<span class="kb-name">${esc(kb.name)}</span>
|
||||
<span class="kb-meta">${kb.document_count} docs · ${kb.chunk_count} chunks</span>
|
||||
<label class="auto-search-toggle ${sel.selected ? '' : 'hidden'}" title="Auto-inject top results into context (no tool call needed)">
|
||||
<input type="checkbox" data-kb-auto="${esc(kb.id)}" ${sel.auto_search ? 'checked' : ''}>
|
||||
<span>Auto-inject</span>
|
||||
</label>
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
// Wire checkboxes
|
||||
list.querySelectorAll('input[data-kb-id]').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const kbId = this.dataset.kbId;
|
||||
if (!_selected[kbId]) _selected[kbId] = { selected: false, auto_search: false };
|
||||
_selected[kbId].selected = this.checked;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('input[data-kb-auto]').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const kbId = this.dataset.kbAuto;
|
||||
if (_selected[kbId]) {
|
||||
_selected[kbId].auto_search = this.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Control object ──
|
||||
const control = {
|
||||
getValues() {
|
||||
const kbIds = [];
|
||||
const autoSearch = {};
|
||||
for (const [kbId, state] of Object.entries(_selected)) {
|
||||
if (state.selected) {
|
||||
kbIds.push(kbId);
|
||||
if (state.auto_search) {
|
||||
autoSearch[kbId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kb_ids: kbIds, auto_search: autoSearch };
|
||||
},
|
||||
|
||||
setValues(personaKBs) {
|
||||
_selected = {};
|
||||
if (Array.isArray(personaKBs)) {
|
||||
for (const pkb of personaKBs) {
|
||||
_selected[pkb.kb_id] = {
|
||||
selected: true,
|
||||
auto_search: !!pkb.auto_search,
|
||||
};
|
||||
}
|
||||
}
|
||||
render();
|
||||
},
|
||||
|
||||
clear() {
|
||||
_selected = {};
|
||||
render();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
await loadKBs();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-load on create
|
||||
loadKBs();
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
|
||||
// ── Persona-KB Save/Load Helpers ────────────────
|
||||
|
||||
/**
|
||||
* Load persona KB bindings and populate the picker.
|
||||
* @param {string} personaId
|
||||
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||
*/
|
||||
async function loadPersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||
if (!personaId || !kbPicker) return;
|
||||
try {
|
||||
const resp = await API._get(`${apiPrefix}/presets/${personaId}/knowledge-bases`);
|
||||
kbPicker.setValues(resp.data || []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load persona KBs:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save persona KB bindings from the picker.
|
||||
* @param {string} personaId
|
||||
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||
*/
|
||||
async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||
if (!personaId || !kbPicker) return;
|
||||
const values = kbPicker.getValues();
|
||||
try {
|
||||
await API._put(`${apiPrefix}/presets/${personaId}/knowledge-bases`, values);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save persona KBs:', e.message);
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,12 @@ async function handleSaveAdminSettings() {
|
||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
||||
|
||||
// KB direct access → kb_direct_access policy (v0.17.0)
|
||||
const kbDirect = document.getElementById('adminKBDirectAccessToggle')?.checked;
|
||||
if (kbDirect != null) {
|
||||
await API.adminUpdateSetting('kb_direct_access', { value: kbDirect ? 'true' : 'false' });
|
||||
}
|
||||
|
||||
// Banner → global_settings (JSON value)
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
@@ -532,6 +538,9 @@ function _initSettingsListeners() {
|
||||
prefix: 'teamPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
kbScope: 'team',
|
||||
kbTeamId: UI._managingTeamId,
|
||||
onSubmit: async (vals) => {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
@@ -543,6 +552,10 @@ function _initSettingsListeners() {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
|
||||
}
|
||||
if (presetId && vals._kbValues) {
|
||||
try { await API.teamSetPresetKBs(teamId, presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('Team preset KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_teamPresetForm.clearForm();
|
||||
UI.toast('Team preset created');
|
||||
@@ -576,6 +589,8 @@ function _initSettingsListeners() {
|
||||
prefix: 'userPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
kbScope: 'personal',
|
||||
onSubmit: async (vals) => {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
@@ -586,6 +601,10 @@ function _initSettingsListeners() {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
|
||||
}
|
||||
if (presetId && vals._kbValues) {
|
||||
try { await API.setUserPresetKBs(presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('User preset KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_userPresetForm.clearForm();
|
||||
UI.toast('Preset created');
|
||||
|
||||
@@ -900,6 +900,10 @@ Object.assign(UI, {
|
||||
// User presets / personas (policy: allow_user_personas)
|
||||
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||
|
||||
// KB direct access (policy: kb_direct_access — v0.17.0)
|
||||
const kbDirectEl = document.getElementById('adminKBDirectAccessToggle');
|
||||
if (kbDirectEl) kbDirectEl.checked = policies.kb_direct_access === 'true';
|
||||
|
||||
// Default model (policy: default_model) — populate from current model list
|
||||
const defModelSel = document.getElementById('adminDefaultModel');
|
||||
if (defModelSel) {
|
||||
|
||||
@@ -290,7 +290,7 @@ const UI = {
|
||||
html += `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
onclick="selectChat('${c.id}')">
|
||||
<span class="chat-item-title">${esc(c.title)}</span>
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user