// ========================================== // Chat Switchboard – Application // ========================================== const App = { chats: [], currentChatId: null, models: [], isGenerating: false, abortController: null, serverSettings: {}, settings: { model: '', stream: true, showThinking: true, systemPrompt: '', maxTokens: 0, // 0 = auto from model capabilities temperature: 0.7, }, }; // ── Init ───────────────────────────────────── async function init() { console.log('🔀 Chat Switchboard initializing...'); initBranding(); // Apply branding before splash is visible API.loadTokens(); let health = null; try { health = await API.health(); console.log('✅ Backend reachable:', health.version); } catch (e) { console.error('❌ Backend unreachable:', e.message); document.getElementById('splashError').textContent = 'Cannot reach server — check connection'; showSplash(null); return; } if (API.isAuthed) { try { await API.getProfile(); console.log('✅ Session valid for', API.user?.username); } catch (e) { console.warn('⚠️ Session expired, clearing'); API.clearTokens(); } } if (API.isAuthed) { await startApp(); } else { showSplash(health); } } async function startApp() { hideSplash(); UI.restoreSidebar(); await loadSettings(); await loadChats(); await fetchModels(); await initBanners(); UI.renderChatList(); UI.updateModelSelector(); UI.updateUser(); UI.showAdminButton(API.isAdmin); initListeners(); // Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet) try { Events.connect((window.__BASE__ || '') + '/ws'); } catch (e) { console.warn('EventBus WebSocket not available:', e.message); } console.log('✅ Chat Switchboard ready'); } // ── 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'; } } // Preset avatar preview (admin form) function updatePresetAvatarPreview(dataURI) { const preview = document.getElementById('presetAvatarPreview'); const placeholder = document.getElementById('presetAvatarPlaceholder'); const removeBtn = document.getElementById('presetAvatarRemoveBtn'); if (!preview) return; const existingImg = preview.querySelector('img'); if (dataURI) { placeholder.style.display = 'none'; if (existingImg) { existingImg.src = dataURI; } else { const img = document.createElement('img'); img.src = dataURI; img.alt = 'Preset avatar'; preview.insertBefore(img, placeholder); } removeBtn.style.display = ''; } else { if (existingImg) existingImg.remove(); placeholder.style.display = ''; removeBtn.style.display = 'none'; } } // ── Models ─────────────────────────────────── // Client-side known model capabilities — used when backend hasn't synced yet. // Mirrors server/providers/capabilities.go knownModels table. const KNOWN_MODELS = { 'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 }, 'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 }, 'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 }, 'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 }, 'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 }, 'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 }, 'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 }, 'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 }, 'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 }, 'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 }, 'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 }, 'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 }, 'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 }, 'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 }, 'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 }, 'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 }, 'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 }, 'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 }, 'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 }, 'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 }, 'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 }, 'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 }, }; // Look up client-side capabilities by model ID with prefix matching function lookupKnownCaps(modelId) { const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix // Exact match if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] }; // Prefix match (longest wins) let best = null, bestLen = 0; for (const key of Object.keys(KNOWN_MODELS)) { if (id.startsWith(key) && key.length > bestLen) { best = key; bestLen = key.length; } } return best ? { ...KNOWN_MODELS[best] } : null; } // Merge: backend/provider caps are authoritative, client-side fills gaps only function resolveCapabilities(backendCaps, modelId) { const known = lookupKnownCaps(modelId) || {}; if (!backendCaps || Object.keys(backendCaps).length === 0) return known; // Backend is authoritative — start with it const caps = { ...backendCaps }; // Fill gaps (fields backend didn't report) from known table for (const [k, v] of Object.entries(known)) { if (caps[k] === undefined || caps[k] === null) { caps[k] = v; } } return caps; } async function fetchModels() { try { const data = await API.listEnabledModels(); App.models = (data.models || []).map(m => { const isPreset = !!m.is_preset; // Presets: use preset_id as selector value, base model for caps // Regular: model_id is both const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id); const baseModelId = m.model_id || m.id; return { id, baseModelId, name: m.display_name || baseModelId, provider: m.provider_name || m.provider || '', configId: m.config_id || null, capabilities: resolveCapabilities(m.capabilities, baseModelId), isPreset, presetId: m.preset_id || null, presetScope: m.preset_scope || null, presetAvatar: m.preset_avatar || null, }; }); // Sort: presets first (global before personal), then regular models App.models.sort((a, b) => { if (a.isPreset && !b.isPreset) return -1; if (!a.isPreset && b.isPreset) return 1; if (a.isPreset && b.isPreset) { if (a.presetScope !== b.presetScope) { return a.presetScope === 'global' ? -1 : 1; } } return a.name.localeCompare(b.name); }); console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets)`); } catch (e) { console.warn('Model fetch failed:', e.message); } UI.updateModelSelector(); UI.updateCapabilityBadges(); } // ── Chat Management ────────────────────────── async function loadChats() { try { const resp = await API.listChannels(1, 100, 'direct'); App.chats = (resp.data || []).map(c => ({ id: c.id, title: c.title, type: c.type || 'direct', model: c.model || '', messageCount: c.message_count || 0, messages: [], updatedAt: c.updated_at })); } catch (e) { console.error('Failed to load chats:', e.message); UI.toast('Failed to load chats', 'error'); } } async function selectChat(chatId) { App.currentChatId = chatId; UI.renderChatList(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } const chat = App.chats.find(c => c.id === chatId); if (!chat) return; if (chat.messages.length === 0 && chat.messageCount > 0) { try { const resp = await API.getActivePath(chatId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); } catch (e) { console.error('Failed to load messages:', e.message); UI.toast('Failed to load messages', 'error'); } } UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); } async function newChat() { App.currentChatId = null; UI.renderChatList(); UI.showEmptyState(); UI.showRegenerate(false); document.getElementById('messageInput').focus(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } } async function deleteChat(chatId) { if (!confirm('Delete this chat?')) return; try { await API.deleteChannel(chatId); App.chats = App.chats.filter(c => c.id !== chatId); if (App.currentChatId === chatId) newChat(); UI.renderChatList(); } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } } // ── Send Message ───────────────────────────── async function sendMessage() { const input = document.getElementById('messageInput'); const text = input.value.trim(); if (!text || App.isGenerating) return; input.value = ''; input.style.height = 'auto'; const selectedId = UI.getModelValue(); const modelInfo = App.models.find(m => m.id === selectedId); // For presets, send the base model ID; for regular models, send the model ID const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; if (!App.currentChatId) { try { const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at }; App.chats.unshift(chat); App.currentChatId = chat.id; UI.renderChatList(); } catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; } } const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; // Optimistic: show user message immediately chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 }); UI.renderMessages(chat.messages); App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId); await UI.streamResponse(resp, chat.messages, modelInfo?.name); // Reload active path from server to get proper IDs and sibling metadata await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { console.error('Completion error:', e); const msg = e.message || ''; if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings → Providers', 'error'); } else if (msg.includes('provider error') && msg.includes('429')) { UI.toast('Provider rate limit — wait and retry', 'warning'); } else if (msg.includes('no API config') || msg.includes('no model')) { UI.toast('No provider configured — add one in Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } function stopGeneration() { if (App.abortController) App.abortController.abort(); } // ── Reload Active Path from Server ────────── async function reloadActivePath() { if (!App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; try { const resp = await API.getActivePath(App.currentChatId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); } catch (e) { console.error('Failed to reload path:', e.message); } } // ── Regenerate (tree-aware: creates sibling) ─ async function regenerateMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const selectedId = UI.getModelValue(); const modelInfo = App.models.find(m => m.id === selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const chat = App.chats.find(c => c.id === App.currentChatId); const resp = await API.streamRegenerate( App.currentChatId, messageId, App.abortController.signal, model, presetId, modelInfo?.configId ); await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name); // Reload from server to get proper tree state await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { const msg = e.message || ''; if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } // Legacy: bottom-bar regenerate button targets the last assistant message async function regenerate() { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || chat.messages.length === 0) return; // Find the last assistant message with an ID for (let i = chat.messages.length - 1; i >= 0; i--) { if (chat.messages[i].role === 'assistant' && chat.messages[i].id) { return regenerateMessage(chat.messages[i].id); } } UI.toast('No assistant message to regenerate', 'warning'); } // ── Edit Message (creates sibling, triggers completion) ── async function editMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; const msg = chat.messages.find(m => m.id === messageId); if (!msg || msg.role !== 'user') return; // Show inline edit UI UI.showEditInline(messageId, msg.content); } async function submitEdit(messageId, newContent) { if (App.isGenerating || !App.currentChatId) return; if (!newContent.trim()) return; const selectedId = UI.getModelValue(); const modelInfo = App.models.find(m => m.id === selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const chat = App.chats.find(c => c.id === App.currentChatId); // Step 1: Create sibling user message via edit endpoint const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim()); // Step 2: Reload path to show the new edited message await reloadActivePath(); // Step 3: Generate assistant response as child of the new edit. // Uses regenerate endpoint (which handles user messages by creating // a child response, not a sibling). This avoids the duplicate user // message that streamCompletion would create. const resp = await API.streamRegenerate( App.currentChatId, editResp.id, App.abortController.signal, model, presetId, modelInfo?.configId ); await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name); // Step 4: Final reload to get the complete tree state await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { console.error('Edit error:', e); UI.toast(e.message || 'Edit failed', 'error'); await reloadActivePath(); } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } function cancelEdit() { // Re-render to remove the edit form const chat = App.chats.find(c => c.id === App.currentChatId); if (chat) UI.renderMessages(chat.messages); } // ── Switch Branch (sibling navigation) ────── async function switchSibling(messageId, direction) { if (App.isGenerating || !App.currentChatId) return; try { // Get siblings for this message const data = await API.listSiblings(App.currentChatId, messageId); const siblings = data.siblings || []; const currentIdx = data.current_index ?? 0; const targetIdx = currentIdx + direction; if (targetIdx < 0 || targetIdx >= siblings.length) return; const targetSibling = siblings[targetIdx]; // Update cursor to the target sibling (backend walks to leaf) const resp = await API.updateCursor(App.currentChatId, targetSibling.id); // Update local state with the new path const chat = App.chats.find(c => c.id === App.currentChatId); if (chat && resp.path) { chat.messages = resp.path.map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); } } catch (e) { console.error('Branch switch failed:', e.message); UI.toast('Failed to switch branch', 'error'); } } // ── Modal Helpers ─────────────────────────── function openModal(id) { const el = document.getElementById(id); if (!el) return; el.classList.add('active'); // Defer overflow check — browser needs a frame to lay out the modal requestAnimationFrame(() => { el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow); }); } function closeModal(id) { const el = document.getElementById(id); if (el) el.classList.remove('active'); } // Toggle .is-scrollable on tab bars that overflow horizontally // and inject arrow navigation buttons when needed. function checkTabsOverflow(tabs) { if (!tabs) return; const scrollable = tabs.scrollWidth > tabs.clientWidth + 1; if (!scrollable) { // Remove wrapper if overflow went away (e.g. zoom decreased) const wrap = tabs.parentElement; if (wrap && wrap.classList.contains('modal-tabs-wrap')) { wrap.parentElement.insertBefore(tabs, wrap); wrap.remove(); } return; } // Wrap if not already wrapped if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) { const wrap = document.createElement('div'); wrap.className = 'modal-tabs-wrap'; tabs.parentElement.insertBefore(wrap, tabs); wrap.appendChild(tabs); // Inject arrows const left = document.createElement('button'); left.className = 'tab-arrow tab-arrow-left'; left.innerHTML = '‹'; left.addEventListener('click', () => { tabs.scrollLeft -= 120; }); const right = document.createElement('button'); right.className = 'tab-arrow tab-arrow-right'; right.innerHTML = '›'; right.addEventListener('click', () => { tabs.scrollLeft += 120; }); wrap.appendChild(left); wrap.appendChild(right); // Update arrow visibility on scroll tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true }); } updateTabArrows(tabs); } function updateTabArrows(tabs) { const wrap = tabs.parentElement; if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return; const left = wrap.querySelector('.tab-arrow-left'); const right = wrap.querySelector('.tab-arrow-right'); if (!left || !right) return; const atStart = tabs.scrollLeft <= 2; const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2; left.classList.toggle('visible', !atStart); right.classList.toggle('visible', !atEnd); } // ── Auth Flow ──────────────────────────────── function showSplash(health) { document.getElementById('splashGate').style.display = 'flex'; document.getElementById('appContainer').style.display = 'none'; if (health && health.registration_enabled === false) { document.getElementById('authTabRegister').style.display = 'none'; } } function hideSplash() { document.getElementById('splashGate').style.display = 'none'; document.getElementById('appContainer').style.display = ''; } async function handleLogin() { const login = document.getElementById('authLogin').value.trim(); const password = document.getElementById('authPassword').value; if (!login || !password) return setAuthError('Fill in all fields'); setAuthLoading(true); try { await API.login(login, password); await startApp(); } catch (e) { setAuthError(e.message); } finally { setAuthLoading(false); } } async function handleRegister() { const username = document.getElementById('authUsername').value.trim(); const email = document.getElementById('authEmail').value.trim(); const password = document.getElementById('authRegPassword').value; if (!username || !email || !password) return setAuthError('Fill in all fields'); if (password.length < 8) return setAuthError('Password must be at least 8 characters'); setAuthLoading(true); try { const resp = await API.register(username, email, password); if (resp.pending) { setAuthError(''); const errEl = document.getElementById('authError'); errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.'; errEl.style.color = 'var(--accent)'; setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000); } else { await startApp(); } } catch (e) { setAuthError(e.message); } finally { setAuthLoading(false); } } async function handleLogout() { if (!confirm('Sign out?')) return; Events.disconnect(); Events.clear(); await API.logout(); App.chats = []; App.currentChatId = null; location.reload(); } function switchAuthTab(tab) { document.getElementById('authTabLogin').classList.toggle('active', tab === 'login'); document.getElementById('authTabRegister').classList.toggle('active', tab === 'register'); document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none'; document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none'; document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none'; document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none'; const hdr = document.querySelector('.auth-card-header'); if (hdr) { hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account'; hdr.querySelector('p').textContent = tab === 'login' ? 'Sign in to continue to your workspace' : 'Get started with Chat Switchboard'; } setAuthError(''); } function setAuthError(msg) { document.getElementById('authError').textContent = msg; } function setAuthLoading(on) { document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => { btn.disabled = on; btn.textContent = on ? 'Please wait...' : btn.dataset.label; }); } // ── Event Listeners ────────────────────────── let _listenersInit = false; function initListeners() { if (_listenersInit) return; _listenersInit = true; // Sidebar document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); document.getElementById('newChatBtn').addEventListener('click', newChat); // Split button dropdown document.getElementById('newChatDropBtn').addEventListener('click', (e) => { e.stopPropagation(); document.getElementById('newChatDropdown').classList.toggle('open'); }); document.addEventListener('click', (e) => { if (!e.target.closest('.split-btn')) { document.getElementById('newChatDropdown').classList.remove('open'); } }); // User flyout document.getElementById('userMenuBtn').addEventListener('click', (e) => { e.stopPropagation(); UI.toggleUserMenu(); }); document.addEventListener('click', (e) => { if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu(); }); // Flyout items document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); }); document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); }); document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); // Notes panel document.getElementById('notesBtn')?.addEventListener('click', openNotes); document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null)); document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => { if (_notesSelectMode) _exitSelectMode(); else _enterSelectMode(); }); document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); }); document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote); document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote); document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote); document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode); document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode); document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview); document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => { if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); } else showNotesList(); }); document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => { document.getElementById('notesSearchInput').value = ''; _exitSelectMode(); loadNotesList(e.target.value); }); document.getElementById('notesSortSelect')?.addEventListener('change', (e) => { _notesSort = e.target.value; _exitSelectMode(); loadNotesList(); }); document.getElementById('notesSelectAll')?.addEventListener('change', (e) => { _toggleSelectAll(e.target.checked); }); document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected); document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode); // Notes search with debounce let _notesSearchTimer; document.getElementById('notesSearchInput')?.addEventListener('input', (e) => { clearTimeout(_notesSearchTimer); const q = e.target.value.trim(); _notesSearchTimer = setTimeout(() => { _exitSelectMode(); if (q.length >= 2) { document.getElementById('notesFolderFilter').value = ''; loadNotesList(null, q); } else if (q.length === 0) { loadNotesList(); } }, 300); }); // Chat actions document.getElementById('sendBtn').addEventListener('click', sendMessage); document.getElementById('stopBtn').addEventListener('click', stopGeneration); // Model selector (custom dropdown) UI.initModelDropdown(); UI.initAppearance(); // Mobile hamburger document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar); document.getElementById('sidebarOverlay')?.addEventListener('click', () => { document.getElementById('sidebar').classList.add('collapsed'); document.getElementById('sidebarOverlay').style.display = 'none'; localStorage.setItem('sb_sidebar', '1'); }); document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => { await fetchModels(); UI.toast(`Loaded ${App.models.length} models`, 'success'); }); // 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.models.find(m => m.id === this.value); const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; 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'); } }); document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm); document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm); document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider); document.getElementById('providerType').addEventListener('change', function() { const endpoints = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', venice: 'https://api.venice.ai/api/v1', openrouter: 'https://openrouter.ai/api/v1', }; const ep = document.getElementById('providerEndpoint'); if (!ep.value || Object.values(endpoints).includes(ep.value)) { ep.value = endpoints[this.value] || ''; } }); // Admin modal (elements may not exist for non-admin users) document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin); document.querySelectorAll('.admin-tab').forEach(tab => { tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab)); }); document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings); // Admin — users document.getElementById('adminAddUserBtn')?.addEventListener('click', () => { const f = document.getElementById('adminAddUserForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; }); document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser); // Admin — providers document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => { _editingProviderId = null; document.getElementById('adminCreateProvBtn').textContent = 'Save'; document.getElementById('adminProvKey').placeholder = 'sk-...'; document.getElementById('adminProvName').value = ''; document.getElementById('adminProvEndpoint').value = ''; document.getElementById('adminProvKey').value = ''; document.getElementById('adminProvModel').value = ''; const f = document.getElementById('adminAddProviderForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; _editingProviderId = null; document.getElementById('adminCreateProvBtn').textContent = 'Save'; document.getElementById('adminProvKey').placeholder = 'sk-...'; }); document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider); // Admin — models document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels); // Admin — presets document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => { _editingPresetId = null; document.getElementById('adminCreatePresetBtn').textContent = 'Create'; document.getElementById('adminPresetName').value = ''; document.getElementById('adminPresetDesc').value = ''; document.getElementById('adminPresetPrompt').value = ''; document.getElementById('adminPresetIcon').value = ''; document.getElementById('adminPresetTemp').value = ''; document.getElementById('adminPresetMaxTokens').value = ''; updatePresetAvatarPreview(null); const f = document.getElementById('adminAddPresetForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); document.getElementById('adminCancelPresetBtn')?.addEventListener('click', () => { document.getElementById('adminAddPresetForm').style.display = 'none'; _editingPresetId = null; document.getElementById('adminCreatePresetBtn').textContent = 'Create'; updatePresetAvatarPreview(null); }); document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset); // Admin — preset avatar document.getElementById('presetAvatarUploadBtn')?.addEventListener('click', () => { document.getElementById('presetAvatarFileInput').click(); }); document.getElementById('presetAvatarFileInput')?.addEventListener('change', async function() { const file = this.files[0]; if (!file) return; if (!_editingPresetId) { // For new presets: show preview, upload after create const reader = new FileReader(); reader.onload = () => updatePresetAvatarPreview(reader.result); reader.readAsDataURL(file); this.value = ''; return; } // For existing presets: upload immediately const reader = new FileReader(); reader.onload = async () => { try { const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result); if (data.avatar) { updatePresetAvatarPreview(data.avatar); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar updated'); } } catch (e) { UI.toast(e.message || 'Upload failed', 'error'); } }; reader.readAsDataURL(file); this.value = ''; }); document.getElementById('presetAvatarRemoveBtn')?.addEventListener('click', async () => { if (!_editingPresetId) { updatePresetAvatarPreview(null); return; } try { await API.adminDeletePresetAvatar(_editingPresetId); updatePresetAvatarPreview(null); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar removed'); } catch (e) { UI.toast(e.message || 'Remove failed', 'error'); } }); // 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 (preset) { 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); }); // Sync color pickers with hex inputs 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(); }}); // Input const input = document.getElementById('messageInput'); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; }); // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(overlay.id); }); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (App.isGenerating) { stopGeneration(); return; } // Close topmost open modal const open = [...document.querySelectorAll('.modal-overlay.active')]; if (open.length) closeModal(open[open.length - 1].id); } }); // Recheck tab overflow on resize / zoom changes window.addEventListener('resize', () => { document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow); }); } // ── Settings Handlers ──────────────────────── 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(); 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'); } } async function handleCreateProvider() { const name = document.getElementById('providerName').value.trim(); const provider = document.getElementById('providerType').value; const endpoint = document.getElementById('providerEndpoint').value.trim(); const apiKey = document.getElementById('providerApiKey').value.trim(); const model = document.getElementById('providerDefaultModel').value.trim(); if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning'); try { await API.createConfig(name, provider, endpoint, apiKey, model); UI.toast('Provider added', 'success'); UI.hideProviderForm(); UI.loadProviderList(); fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteProvider(id, name) { if (!confirm(`Remove provider "${name}"?`)) return; try { await API.deleteConfig(id); UI.toast('Provider removed', 'success'); UI.loadProviderList(); fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function handleSaveAdminSettings() { try { // Registration const reg = document.getElementById('adminRegToggle').checked; await API.adminUpdateSetting('registration_enabled', { value: reg }); // Registration default state const regState = document.getElementById('adminRegDefaultState').value; await API.adminUpdateSetting('registration_default_state', { value: regState }); // User providers const userProviders = document.getElementById('adminUserProvidersToggle').checked; await API.adminUpdateSetting('user_providers_enabled', { value: userProviders }); // Banner 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 }); UI.toast('Settings saved', 'success'); // Live-apply banner initBanners(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Admin Actions ──────────────────────────── function _adminScroll() { return document.querySelector('#adminModal .modal-body'); } function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); } async function toggleUserActive(id, active) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers(); _restoreScroll(el, pos); } catch (e) { UI.toast(e.message, 'error'); } } async function toggleUserRole(id, role) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers(); _restoreScroll(el, pos); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteUser(id, username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); } catch (e) { UI.toast(e.message, 'error'); } } async function createAdminUser() { try { const u = document.getElementById('adminNewUsername').value.trim(); const e = document.getElementById('adminNewEmail').value.trim(); const p = document.getElementById('adminNewPassword').value; const r = document.getElementById('adminNewRole').value; if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; } await API.adminCreateUser(u, e, p, r); document.getElementById('adminAddUserForm').style.display = 'none'; UI.toast('User created', 'success'); await UI.loadAdminUsers(); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteGlobalProvider(id) { if (!confirm('Delete this global provider?')) return; try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); } catch (e) { UI.toast(e.message, 'error'); } } var _editingProviderId = null; function editGlobalProvider(id) { const c = UI._providerCache?.[id]; if (!c) return; _editingProviderId = id; const form = document.getElementById('adminAddProviderForm'); form.style.display = ''; document.getElementById('adminProvName').value = c.name || ''; document.getElementById('adminProvType').value = c.provider || 'openai'; document.getElementById('adminProvEndpoint').value = c.endpoint || ''; document.getElementById('adminProvKey').value = ''; document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...'; document.getElementById('adminProvModel').value = c.model_default || ''; document.getElementById('adminCreateProvBtn').textContent = 'Update'; } async function createGlobalProvider() { try { const name = document.getElementById('adminProvName').value.trim(); const prov = document.getElementById('adminProvType').value; const ep = document.getElementById('adminProvEndpoint').value.trim(); const key = document.getElementById('adminProvKey').value; const model = document.getElementById('adminProvModel').value.trim(); if (_editingProviderId) { // Update mode const updates = {}; if (name) updates.name = name; if (ep) updates.endpoint = ep; if (key) updates.api_key = key; updates.model_default = model; await API.adminUpdateGlobalConfig(_editingProviderId, updates); UI.toast('Provider updated', 'success'); } else { // Create mode if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; } await API.adminCreateGlobalConfig(name, prov, ep, key, model); UI.toast('Provider added', 'success'); } _editingProviderId = null; document.getElementById('adminAddProviderForm').style.display = 'none'; document.getElementById('adminCreateProvBtn').textContent = 'Save'; document.getElementById('adminProvKey').placeholder = 'sk-...'; await UI.loadAdminProviders(); } catch (e) { UI.toast(e.message, 'error'); } } async function fetchAdminModels() { const hint = document.getElementById('adminModelsHint'); hint.textContent = 'Fetching...'; try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } async function toggleModel(id, enabled) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminUpdateModel(id, { is_enabled: enabled }); await UI.loadAdminModels(); _restoreScroll(el, pos); } catch (e) { UI.toast(e.message, 'error'); } } async function bulkToggleModels(enabled) { const hint = document.getElementById('adminModelsHint'); hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...'; try { const resp = await API.adminBulkUpdateModels(enabled); hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`; setTimeout(() => { hint.textContent = ''; }, 3000); await UI.loadAdminModels(); } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } // ── Admin Presets ──────────────────────────── var _editingPresetId = null; function editAdminPreset(id) { const p = UI._presetCache?.[id]; if (!p) return; _editingPresetId = id; const form = document.getElementById('adminAddPresetForm'); form.style.display = ''; document.getElementById('adminPresetName').value = p.name || ''; document.getElementById('adminPresetIcon').value = p.icon || ''; document.getElementById('adminPresetDesc').value = p.description || ''; document.getElementById('adminPresetPrompt').value = p.system_prompt || ''; document.getElementById('adminPresetTemp').value = p.temperature != null ? p.temperature : ''; document.getElementById('adminPresetMaxTokens').value = p.max_tokens != null ? p.max_tokens : ''; // Set base model and config dropdowns if present const modelSel = document.getElementById('adminPresetModel'); if (modelSel) modelSel.value = p.base_model_id || ''; const cfgSel = document.getElementById('adminPresetConfig'); if (cfgSel) cfgSel.value = p.api_config_id || ''; // Avatar preview updatePresetAvatarPreview(p.avatar || null); document.getElementById('adminCreatePresetBtn').textContent = 'Update'; } async function createAdminPreset() { const name = document.getElementById('adminPresetName').value.trim(); const baseModelId = document.getElementById('adminPresetModel').value; if (!name || !baseModelId) { UI.toast('Name and base model are required', 'warning'); return; } const preset = { name, base_model_id: baseModelId, description: document.getElementById('adminPresetDesc').value.trim(), icon: document.getElementById('adminPresetIcon').value.trim(), system_prompt: document.getElementById('adminPresetPrompt').value, }; const configId = document.getElementById('adminPresetConfig').value; if (configId) preset.api_config_id = configId; const temp = parseFloat(document.getElementById('adminPresetTemp').value); if (!isNaN(temp)) preset.temperature = temp; const maxTok = parseInt(document.getElementById('adminPresetMaxTokens').value); if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok; try { let presetId = _editingPresetId; if (_editingPresetId) { await API.adminUpdatePreset(_editingPresetId, preset); UI.toast('Preset updated', 'success'); } else { const result = await API.adminCreatePreset(preset); presetId = result?.id || result?.preset?.id; UI.toast('Preset created', 'success'); } // Upload pending avatar for new presets const pendingImg = document.querySelector('#presetAvatarPreview img'); if (!_editingPresetId && presetId && pendingImg?.src?.startsWith('data:')) { try { await API.adminUploadPresetAvatar(presetId, pendingImg.src); } catch (e) { console.warn('Preset avatar upload failed:', e.message); } } _editingPresetId = null; document.getElementById('adminAddPresetForm').style.display = 'none'; document.getElementById('adminCreatePresetBtn').textContent = 'Create'; document.getElementById('adminPresetName').value = ''; document.getElementById('adminPresetDesc').value = ''; document.getElementById('adminPresetPrompt').value = ''; document.getElementById('adminPresetIcon').value = ''; document.getElementById('adminPresetTemp').value = ''; document.getElementById('adminPresetMaxTokens').value = ''; updatePresetAvatarPreview(null); await UI.loadAdminPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function toggleAdminPreset(id, active) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminUpdatePreset(id, { is_active: active }); await UI.loadAdminPresets(true); _restoreScroll(el, pos); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteAdminPreset(id, name) { if (!confirm(`Delete preset "${name}"?`)) return; try { await API.adminDeletePreset(id); UI.toast('Preset deleted', 'success'); await UI.loadAdminPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Notes Panel ───────────────────────────── var _editingNoteId = null; var _notesSelectMode = false; var _selectedNoteIds = new Set(); var _notesSort = 'updated_desc'; async function openNotes() { openModal('notesModal'); _exitSelectMode(); await loadNotesList(); await loadNoteFolders(); } async function loadNotesList(folder, searchQuery) { const list = document.getElementById('notesList'); list.innerHTML = '