Changeset 0.17.0 (#75)

This commit is contained in:
2026-02-27 16:25:39 +00:00
parent 8bb77710b9
commit c9141a6896
37 changed files with 2778 additions and 968 deletions

135
src/css/persona-kb.css Normal file
View File

@@ -0,0 +1,135 @@
/* persona-kb.css — PersonaKnowledge Base picker styles (v0.17.0) */
.persona-kb-picker {
margin-top: 0.75rem;
}
.persona-kb-picker > label {
font-weight: 500;
margin-bottom: 0.25rem;
display: block;
}
.persona-kb-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #333);
border-radius: var(--radius, 6px);
padding: 0.25rem;
}
.persona-kb-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s;
}
.persona-kb-item:hover {
background: var(--hover-bg, rgba(255,255,255,0.05));
}
.persona-kb-item.selected {
background: var(--selected-bg, rgba(100,160,255,0.1));
}
.persona-kb-item input[type="checkbox"] {
flex-shrink: 0;
}
.persona-kb-item .kb-name {
flex: 1;
font-weight: 500;
}
.persona-kb-item .kb-meta {
color: var(--text-muted, #888);
font-size: 0.75rem;
white-space: nowrap;
}
.auto-search-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted, #888);
cursor: pointer;
white-space: nowrap;
}
.auto-search-toggle.hidden {
display: none;
}
.auto-search-toggle span {
opacity: 0.8;
}
/* ── Persona-KB Section (in preset form) ─── */
.persona-kb-section {
margin: 0.75rem 0;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
/* ── Role Fallback Alert Banner ───────────── */
.role-fallback-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--warning-bg, #fff3cd);
border: 1px solid var(--warning-border, #ffc107);
border-radius: 4px;
margin: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.role-fallback-banner .fallback-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.role-fallback-banner .fallback-msg {
flex: 1;
}
.role-fallback-banner .btn-small {
flex-shrink: 0;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
}
/* ── Chat Rename Inline Input ────────────── */
.chat-rename-input {
width: 100%;
background: var(--input-bg, #1a1a2e);
color: var(--text-color, #e0e0e0);
border: 1px solid var(--accent-color, #4a9eff);
border-radius: 3px;
padding: 1px 4px;
font-size: inherit;
font-family: inherit;
outline: none;
}
/* ── Chat Header Token Count ─────────────── */
.chat-token-count {
font-size: 0.75rem;
color: var(--text-muted, #888);
white-space: nowrap;
padding: 0 0.5rem;
flex-shrink: 0;
}
.chat-token-count.warning { color: var(--warning-color, #f0ad4e); }
.chat-token-count.danger { color: var(--danger-color, #d9534f); }

View File

@@ -19,6 +19,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
<body>
@@ -130,6 +131,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<div class="model-caps" id="modelCaps"></div>
<span class="chat-token-count" id="chatTokenCount"></span>
</div>
<div class="messages" id="chatMessages">
@@ -454,6 +456,7 @@
<p class="section-hint" style="margin-bottom:8px">Personal model personas with custom system prompts and settings.</p>
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<div id="userPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
</section>
</div>
@@ -542,6 +545,7 @@
<div class="team-tab-content" id="teamTabPresets" style="display:none">
<div id="settingsTeamPresets"></div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<div id="teamPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</div>
<!-- Usage tab -->
@@ -725,6 +729,7 @@
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Persona</button>
</div>
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
<div id="adminPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<div id="adminPresetList"></div>
</div>
<div class="admin-section-content" id="adminKnowledgeBasesTab" style="display:none">
@@ -761,6 +766,11 @@
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal personas</label>
<p class="section-hint">When disabled, users can only use admin-created global personas.</p>
</section>
<section class="settings-section">
<h3>Knowledge Base Access</h3>
<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Restrict KB access to persona bindings only</label>
<p class="section-hint">When enabled, users cannot attach KBs directly to channels — they can only use KBs bound to personas by admins.</p>
</section>
<section class="settings-section">
<h3>Default Model</h3>
<div class="form-group">
@@ -989,6 +999,7 @@
<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/persona-kb.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>

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,177 @@
// persona-kb.js — PersonaKnowledge 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -68,10 +68,11 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never cache API calls, WebSocket upgrades, or branding assets
// Never cache API calls, WebSocket upgrades, branding, or extension assets
if (url.pathname.includes('/api/') ||
url.pathname.includes('/ws') ||
url.pathname.includes('/branding/') ||
url.pathname.includes('/extensions/') ||
event.request.method !== 'GET') {
return;
}