690 lines
35 KiB
JavaScript
690 lines
35 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Settings Handlers
|
||
// ==========================================
|
||
// Settings save, provider CRUD, avatar, command palette,
|
||
// user model roles.
|
||
|
||
// ── Settings ─────────────────────────────────
|
||
|
||
async function loadSettings() {
|
||
try {
|
||
const remote = await API.getSettings();
|
||
if (remote && typeof remote === 'object') {
|
||
if (remote.model) App.settings.model = remote.model;
|
||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||
}
|
||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||
}
|
||
|
||
async function saveSettings() {
|
||
try {
|
||
await API.updateSettings({
|
||
model: App.settings.model,
|
||
system_prompt: App.settings.systemPrompt,
|
||
max_tokens: App.settings.maxTokens,
|
||
temperature: App.settings.temperature,
|
||
});
|
||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||
}
|
||
|
||
// ── Avatar Preview ──────────────────────────
|
||
function updateAvatarPreview(dataURI) {
|
||
const preview = document.getElementById('avatarPreview');
|
||
const letter = document.getElementById('avatarPreviewLetter');
|
||
const removeBtn = document.getElementById('avatarRemoveBtn');
|
||
const existingImg = preview.querySelector('img');
|
||
|
||
if (dataURI) {
|
||
letter.style.display = 'none';
|
||
if (existingImg) {
|
||
existingImg.src = dataURI;
|
||
} else {
|
||
const img = document.createElement('img');
|
||
img.src = dataURI;
|
||
img.alt = 'Avatar';
|
||
preview.insertBefore(img, letter);
|
||
}
|
||
removeBtn.style.display = '';
|
||
} else {
|
||
if (existingImg) existingImg.remove();
|
||
const name = API.user?.display_name || API.user?.username || '?';
|
||
letter.textContent = name[0].toUpperCase();
|
||
letter.style.display = '';
|
||
removeBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Settings Handlers ────────────────────────
|
||
|
||
async function handleSaveSettings() {
|
||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
|
||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||
saveSettings();
|
||
|
||
// ── Save profile fields (display name, email) ──
|
||
const displayName = document.getElementById('profileDisplayName').value.trim();
|
||
const email = document.getElementById('profileEmail').value.trim();
|
||
const profileUpdates = {};
|
||
if (displayName !== (API.user?.display_name || '')) profileUpdates.display_name = displayName;
|
||
if (email !== (API.user?.email || '')) profileUpdates.email = email;
|
||
if (Object.keys(profileUpdates).length > 0) {
|
||
try {
|
||
const updated = await API.updateProfile(profileUpdates);
|
||
// Sync local user object so sidebar/avatar reflect the change
|
||
if (updated) {
|
||
if (updated.display_name !== undefined) API.user.display_name = updated.display_name;
|
||
if (updated.email !== undefined) API.user.email = updated.email;
|
||
API.saveTokens();
|
||
UI.updateUser();
|
||
}
|
||
} catch (e) {
|
||
console.warn('Profile save failed:', e.message);
|
||
UI.toast('Profile update failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
UI.updateModelSelector();
|
||
UI.updateCapabilityBadges();
|
||
UI.closeSettings();
|
||
UI.toast('Settings saved', 'success');
|
||
}
|
||
|
||
async function handleChangePassword() {
|
||
const cur = document.getElementById('profileCurrentPw').value;
|
||
const pw = document.getElementById('profileNewPw').value;
|
||
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
|
||
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
|
||
try {
|
||
await API.changePassword(cur, pw);
|
||
UI.toast('Password updated', 'success');
|
||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||
document.getElementById('profileCurrentPw').value = '';
|
||
document.getElementById('profileNewPw').value = '';
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
}
|
||
|
||
// ── User BYOK Provider — primitive instances (initialized in _initSettingsListeners) ──
|
||
var _userProvForm = null;
|
||
var _userProvList = null;
|
||
|
||
function _initUserProviderPrimitives() {
|
||
const formEl = document.getElementById('providerAddForm');
|
||
const listEl = document.getElementById('providerList');
|
||
if (!formEl || !listEl) return;
|
||
|
||
_userProvForm = renderProviderForm(formEl, {
|
||
prefix: 'userProv',
|
||
showPrivate: false,
|
||
showDefaultModel: true,
|
||
onSubmit: async (vals, isEdit) => {
|
||
try {
|
||
if (isEdit) {
|
||
if (!vals.name || !vals.endpoint) return UI.toast('Fill in required fields', 'warning');
|
||
const patch = { name: vals.name, provider: vals.provider, endpoint: vals.endpoint, model_default: vals.model_default };
|
||
if (vals.api_key) patch.api_key = vals.api_key;
|
||
await API.updateConfig(vals._editId, patch);
|
||
UI.toast('Provider updated', 'success');
|
||
} else {
|
||
if (!vals.name || !vals.endpoint || !vals.api_key) return UI.toast('Fill in required fields', 'warning');
|
||
const result = await API.createConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default);
|
||
if (result.warning) UI.toast(`Provider added — ${result.warning}`, 'warning');
|
||
else {
|
||
const count = result.models_fetched || 0;
|
||
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
|
||
}
|
||
}
|
||
formEl.style.display = 'none';
|
||
_userProvForm.setCreateMode();
|
||
_userProvList.refresh();
|
||
fetchModels();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onCancel: () => {
|
||
formEl.style.display = 'none';
|
||
_userProvForm.setCreateMode();
|
||
},
|
||
});
|
||
|
||
_userProvList = renderProviderList(listEl, {
|
||
apiFetch: () => API.listConfigs(),
|
||
showRefresh: true,
|
||
emptyMsg: 'No providers configured.',
|
||
onEdit: async (prov) => {
|
||
try {
|
||
const cfg = await API.getConfig(prov.id);
|
||
_userProvForm.setEditMode(cfg.id, cfg);
|
||
formEl.style.display = '';
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onDelete: async (prov) => {
|
||
if (!await showConfirm(`Remove provider "${prov.name}"?`)) return;
|
||
try {
|
||
await API.deleteConfig(prov.id);
|
||
UI.toast('Provider removed', 'success');
|
||
_userProvList.refresh();
|
||
fetchModels();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onRefresh: async (prov) => {
|
||
try {
|
||
UI.toast(`Fetching models for ${prov.name}...`, 'info');
|
||
const result = await API.fetchProviderModels(prov.id);
|
||
const total = result.total || 0;
|
||
UI.toast(`${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
|
||
fetchModels();
|
||
} catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); }
|
||
},
|
||
});
|
||
}
|
||
|
||
async function handleSaveAdminSettings() {
|
||
try {
|
||
// Admin system prompt → global_settings (JSON value)
|
||
const sysPromptContent = document.getElementById('adminSystemPrompt').value.trim();
|
||
await API.adminUpdateSetting('system_prompt', { value: { content: sysPromptContent } });
|
||
|
||
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
||
const reg = document.getElementById('adminRegToggle').checked;
|
||
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
||
|
||
// Registration default state → default_user_active policy
|
||
const regState = document.getElementById('adminRegDefaultState').value;
|
||
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
|
||
|
||
// User BYOK providers → allow_user_byok policy
|
||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
|
||
|
||
// User personas → allow_user_personas policy
|
||
const userPersonas = document.getElementById('adminUserPersonasToggle').checked;
|
||
await API.adminUpdateSetting('allow_user_personas', { value: userPersonas ? 'true' : 'false' });
|
||
|
||
// Default model → default_model policy
|
||
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,
|
||
text: document.getElementById('adminBannerText').value,
|
||
position: document.getElementById('adminBannerPosition').value,
|
||
bg: document.getElementById('adminBannerBg').value,
|
||
fg: document.getElementById('adminBannerFg').value,
|
||
};
|
||
await API.adminUpdateSetting('banner', { value: banner });
|
||
|
||
// Web Search config → global_settings (JSON value)
|
||
const searchConfig = {
|
||
provider: document.getElementById('adminSearchProvider').value,
|
||
endpoint: document.getElementById('adminSearchEndpoint').value.trim(),
|
||
api_key: document.getElementById('adminSearchApiKey').value.trim(),
|
||
max_results: parseInt(document.getElementById('adminSearchMaxResults').value) || 5,
|
||
};
|
||
await API.adminUpdateSetting('search_config', { value: searchConfig });
|
||
|
||
// Auto-Compaction config → global_settings
|
||
const compactionEnabled = document.getElementById('adminCompactionEnabled')?.checked || false;
|
||
const compactionThreshold = parseInt(document.getElementById('adminCompactionThreshold')?.value) || 70;
|
||
const compactionCooldown = parseInt(document.getElementById('adminCompactionCooldown')?.value) || 30;
|
||
await API.adminUpdateSetting('auto_compaction', { value: {
|
||
enabled: compactionEnabled,
|
||
threshold: compactionThreshold,
|
||
cooldown: compactionCooldown,
|
||
}});
|
||
// Scanner reads these individual keys each tick
|
||
await API.adminUpdateSetting('auto_compaction_enabled', { value: compactionEnabled });
|
||
await API.adminUpdateSetting('auto_compaction_threshold', { value: compactionThreshold / 100 });
|
||
await API.adminUpdateSetting('auto_compaction_cooldown_minutes', { value: compactionCooldown });
|
||
|
||
// Memory extraction (v0.18.0)
|
||
const memExtractionEnabled = document.getElementById('adminMemoryExtractionEnabled')?.checked || false;
|
||
const memAutoApprove = document.getElementById('adminMemoryAutoApprove')?.checked || false;
|
||
await API.adminUpdateSetting('memory_extraction', { value: {
|
||
enabled: memExtractionEnabled,
|
||
auto_approve: memAutoApprove,
|
||
}});
|
||
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
|
||
|
||
// Email / SMTP config (v0.20.0 Phase 3)
|
||
if (typeof NotifPrefs !== 'undefined' && NotifPrefs._getAdminSmtp) {
|
||
const smtpConfig = NotifPrefs._getAdminSmtp();
|
||
await API.adminUpdateSetting('notifications', { value: smtpConfig });
|
||
}
|
||
|
||
UI.toast('Settings saved', 'success');
|
||
// Live-apply: refresh policies and dependent UI
|
||
await initBanners();
|
||
UI.checkUserProvidersAllowed();
|
||
UI.checkUserPersonasAllowed();
|
||
fetchModels();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
}
|
||
|
||
// ── User Model Role — primitive instance (initialized in _initSettingsListeners) ──
|
||
var _userRoleConfig = null;
|
||
|
||
function _initUserRolePrimitive() {
|
||
const el = document.getElementById('userRolesConfig');
|
||
if (!el) return;
|
||
|
||
_userRoleConfig = renderRoleConfig(el, {
|
||
scope: 'personal',
|
||
showFallback: false,
|
||
modelIdField: 'baseModelId',
|
||
modelNameField: 'name',
|
||
providerIdField: 'configId',
|
||
noneLabel: '— use org default —',
|
||
fetchProviders: async () => {
|
||
const configs = await API.listConfigs();
|
||
const list = configs.configs || configs.data || [];
|
||
return list.filter(c => c.scope === 'personal');
|
||
},
|
||
fetchModels: async () => {
|
||
// Refresh App.models to ensure personal provider models are current.
|
||
// Without this, newly added providers may not appear in the dropdown.
|
||
await fetchModels();
|
||
return App.models || [];
|
||
},
|
||
fetchRoles: async () => {
|
||
const settings = await API.getSettings();
|
||
return settings.model_roles || {};
|
||
},
|
||
onSave: async (roleId, config) => {
|
||
if (!config.primary) throw new Error('Select both a provider and model');
|
||
const settings = await API.getSettings();
|
||
const roles = settings.model_roles || {};
|
||
roles[roleId] = config;
|
||
await API.updateSettings({ model_roles: roles });
|
||
UI.toast(`${roleId} role override saved`, 'success');
|
||
},
|
||
onClear: async (roleId) => {
|
||
const settings = await API.getSettings();
|
||
const roles = settings.model_roles || {};
|
||
delete roles[roleId];
|
||
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
|
||
UI.toast(`${roleId} role override removed — using org default`, 'success');
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Command Palette ──────────────────────────
|
||
|
||
const _cmdCommands = [
|
||
// Navigation
|
||
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
|
||
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
|
||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>', action: () => UI.toggleSidebar() },
|
||
|
||
// Tools
|
||
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
|
||
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
|
||
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
|
||
|
||
// Settings
|
||
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
|
||
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
|
||
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
|
||
|
||
// Account
|
||
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', action: () => handleLogout() },
|
||
];
|
||
|
||
let _cmdActiveIndex = 0;
|
||
|
||
function toggleCmdPalette() {
|
||
const el = document.getElementById('cmdPalette');
|
||
if (el.classList.contains('active')) { closeCmdPalette(); return; }
|
||
openCmdPalette();
|
||
}
|
||
|
||
function openCmdPalette() {
|
||
const el = document.getElementById('cmdPalette');
|
||
const input = document.getElementById('cmdInput');
|
||
el.classList.add('active');
|
||
input.value = '';
|
||
_cmdActiveIndex = 0;
|
||
_renderCmdResults('');
|
||
input.focus();
|
||
|
||
// Wire up input events (use named handler to avoid duplicates)
|
||
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
|
||
input.onkeydown = _handleCmdKey;
|
||
|
||
// Close on overlay click
|
||
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
|
||
}
|
||
|
||
function closeCmdPalette() {
|
||
const el = document.getElementById('cmdPalette');
|
||
el.classList.remove('active');
|
||
document.getElementById('cmdInput').oninput = null;
|
||
document.getElementById('cmdInput').onkeydown = null;
|
||
}
|
||
|
||
function _handleCmdKey(e) {
|
||
const results = document.getElementById('cmdResults');
|
||
const items = results.querySelectorAll('.cmd-item');
|
||
if (!items.length) return;
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
|
||
_highlightCmdItem(items);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
|
||
_highlightCmdItem(items);
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
items[_cmdActiveIndex]?.click();
|
||
}
|
||
}
|
||
|
||
function _highlightCmdItem(items) {
|
||
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
|
||
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
function _getVisibleCommands() {
|
||
return _cmdCommands.filter(c => !c.visible || c.visible());
|
||
}
|
||
|
||
function _renderCmdResults(query) {
|
||
const el = document.getElementById('cmdResults');
|
||
const q = query.trim().toLowerCase();
|
||
let commands = _getVisibleCommands();
|
||
|
||
// Add recent chats as commands when searching
|
||
if (q) {
|
||
const chatMatches = App.chats
|
||
.filter(c => (c.title || '').toLowerCase().includes(q))
|
||
.slice(0, 5)
|
||
.map(c => ({
|
||
id: 'chat-' + c.id,
|
||
label: c.title || 'Untitled',
|
||
group: 'Chats',
|
||
hint: _relativeTime(c.updatedAt),
|
||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||
action: () => selectChat(c.id),
|
||
}));
|
||
commands = [...chatMatches, ...commands];
|
||
}
|
||
|
||
// Filter by query
|
||
if (q) {
|
||
commands = commands.filter(c => c.label.toLowerCase().includes(q));
|
||
}
|
||
|
||
if (commands.length === 0) {
|
||
el.innerHTML = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
|
||
return;
|
||
}
|
||
|
||
// Clamp active index
|
||
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
|
||
|
||
// Render grouped
|
||
let html = '';
|
||
let lastGroup = '';
|
||
commands.forEach((c, i) => {
|
||
if (c.group !== lastGroup) {
|
||
lastGroup = c.group;
|
||
html += `<div class="cmd-group-label">${c.group}</div>`;
|
||
}
|
||
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
|
||
${c.icon}
|
||
<span class="cmd-item-label">${c.label}</span>
|
||
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
|
||
</div>`;
|
||
});
|
||
el.innerHTML = html;
|
||
|
||
// Attach click handlers
|
||
el.querySelectorAll('.cmd-item').forEach((item, i) => {
|
||
item.addEventListener('click', () => {
|
||
closeCmdPalette();
|
||
commands[i].action();
|
||
});
|
||
item.addEventListener('mouseenter', () => {
|
||
_cmdActiveIndex = i;
|
||
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Settings Listeners (extracted from initListeners) ──
|
||
|
||
function _initSettingsListeners() {
|
||
// Settings modal
|
||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||
});
|
||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||
const model = App.findModel(this.value);
|
||
const caps = model?.capabilities || {};
|
||
const hint = document.getElementById('settingsMaxHint');
|
||
if (hint) {
|
||
hint.textContent = caps.max_output_tokens > 0
|
||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
|
||
: '';
|
||
}
|
||
});
|
||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||
document.getElementById('profileChangePwForm').style.display = '';
|
||
});
|
||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||
|
||
// Avatar upload
|
||
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
|
||
document.getElementById('avatarFileInput').click();
|
||
});
|
||
document.getElementById('avatarFileInput').addEventListener('change', async function() {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
try {
|
||
const data = await API.uploadAvatar(reader.result);
|
||
if (data.avatar) {
|
||
API.user.avatar = data.avatar;
|
||
API.saveTokens();
|
||
updateAvatarPreview(data.avatar);
|
||
UI.updateUser();
|
||
UI.toast('Avatar updated');
|
||
}
|
||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||
};
|
||
reader.readAsDataURL(file);
|
||
this.value = '';
|
||
});
|
||
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
|
||
try {
|
||
await API.deleteAvatar();
|
||
API.user.avatar = null;
|
||
API.saveTokens();
|
||
updateAvatarPreview(null);
|
||
UI.updateUser();
|
||
UI.toast('Avatar removed');
|
||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||
});
|
||
|
||
// User BYOK provider primitives
|
||
_initUserProviderPrimitives();
|
||
_initUserRolePrimitive();
|
||
document.getElementById('providerShowAddBtn').addEventListener('click', () => {
|
||
const formEl = document.getElementById('providerAddForm');
|
||
if (_userProvForm) _userProvForm.setCreateMode();
|
||
formEl.style.display = '';
|
||
});
|
||
|
||
// Settings — Team management (team admin self-service)
|
||
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
||
document.getElementById('settingsTeamAddMember').style.display = '';
|
||
const sel = document.getElementById('settingsTeamMemberUser');
|
||
sel.innerHTML = '<option value="">Loading...</option>';
|
||
try {
|
||
const resp = await API.teamListMembers(UI._managingTeamId);
|
||
const existingIds = new Set((resp.data || []).map(m => m.user_id));
|
||
sel.innerHTML = '<option value="">Enter user ID or ask sys-admin</option>';
|
||
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
|
||
});
|
||
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
|
||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||
});
|
||
var _teamPersonaForm = null;
|
||
document.getElementById('settingsTeamAddPersonaBtn')?.addEventListener('click', () => {
|
||
const container = document.getElementById('settingsTeamAddPersona');
|
||
container.style.display = '';
|
||
if (!_teamPersonaForm) {
|
||
_teamPersonaForm = renderPersonaForm(container, {
|
||
prefix: 'teamPersona',
|
||
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');
|
||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||
try {
|
||
const result = await API.teamCreatePersona(teamId, vals);
|
||
const personaId = result?.id;
|
||
if (personaId && vals._pendingAvatar) {
|
||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
|
||
}
|
||
if (personaId && vals._kbValues) {
|
||
try { await API.teamSetPresetKBs(teamId, personaId, vals._kbValues); }
|
||
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
|
||
}
|
||
container.style.display = 'none';
|
||
_teamPersonaForm.clearForm();
|
||
UI.toast('Team persona created');
|
||
await UI.loadTeamManagePersonas(teamId);
|
||
await fetchModels();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onCancel: () => { container.style.display = 'none'; _teamPersonaForm.clearForm(); }
|
||
});
|
||
} else {
|
||
_teamPersonaForm.clearForm();
|
||
}
|
||
UI.loadTeamPersonaModelDropdown(UI._managingTeamId);
|
||
});
|
||
|
||
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
|
||
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
|
||
const formEl = document.getElementById('settingsTeamProviderForm');
|
||
if (!formEl) return;
|
||
if (UI._teamProvForm) UI._teamProvForm.setCreateMode();
|
||
formEl.style.display = formEl.style.display === 'none' ? '' : 'none';
|
||
});
|
||
|
||
// User — personal presets
|
||
var _userPersonaForm = null;
|
||
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
|
||
const container = document.getElementById('userAddPersonaForm');
|
||
container.style.display = container.style.display === 'none' ? '' : 'none';
|
||
if (!_userPersonaForm) {
|
||
_userPersonaForm = renderPersonaForm(container, {
|
||
prefix: 'userPersona',
|
||
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');
|
||
try {
|
||
const result = await API.createUserPersona(vals);
|
||
const personaId = result?.id;
|
||
if (personaId && vals._pendingAvatar) {
|
||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
|
||
}
|
||
if (personaId && vals._kbValues) {
|
||
try { await API.setUserPresetKBs(personaId, vals._kbValues); }
|
||
catch (e) { console.warn('User persona KB binding failed:', e.message); }
|
||
}
|
||
container.style.display = 'none';
|
||
_userPersonaForm.clearForm();
|
||
UI.toast('Persona created');
|
||
await UI.loadUserPersonas();
|
||
await fetchModels();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onCancel: () => { container.style.display = 'none'; _userPersonaForm.clearForm(); }
|
||
});
|
||
}
|
||
const sel = _userPersonaForm.getModelSelect();
|
||
if (sel) {
|
||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||
App.models.filter(m => !m.isPersona).forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.baseModelId || m.id;
|
||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Admin — banner controls
|
||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||
});
|
||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||
const preset = UI._bannerPresets[e.target.value];
|
||
if (persona) {
|
||
document.getElementById('adminBannerText').value = preset.text || '';
|
||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
|
||
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
|
||
UI.updateBannerPreview();
|
||
}
|
||
});
|
||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||
});
|
||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||
|
||
// Admin — auto-compaction controls
|
||
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
|
||
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
|
||
});
|
||
|
||
// Admin — search provider controls
|
||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||
});
|
||
|
||
// Admin — audit log
|
||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
|
||
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
|
||
|
||
// Admin — usage
|
||
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
|
||
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
|
||
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
|
||
}
|