// ========================================== // Chat Switchboard – Application // ========================================== const App = { chats: [], currentChatId: null, models: [], hiddenModels: new Set(), isGenerating: false, abortController: null, serverSettings: {}, policies: {}, // Find model by composite ID, with fallback to bare model_id match findModel(id) { if (!id) return null; // Exact match on composite ID (configId:modelId) let m = App.models.find(m => m.id === id); if (m) return m; // Fallback: bare model_id (backward compat with stored settings) return App.models.find(m => m.baseModelId === id) || null; }, settings: { model: '', stream: true, showThinking: false, systemPrompt: '', maxTokens: 0, // 0 = auto from model capabilities temperature: 0.7, }, }; // ── Token Estimation + Context Tracking ───── const Tokens = { // Rough heuristic: ~4 chars per token for English (GPT/Claude average). // Not exact, but good enough for a UI indicator. estimate(text) { if (!text) return 0; return Math.ceil(text.length / 4); }, // Estimate tokens for the full conversation context sent to the model estimateConversation(messages, systemPrompt) { let total = 0; // System prompt if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters // Messages for (const m of messages) { total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters) } return total; }, // Get context budget for current model getContextBudget() { const caps = UI.getSelectedModelCaps(); return { maxContext: caps.max_context || 0, maxOutput: caps.max_output_tokens || 0, }; }, // Format token count for display format(n) { if (n >= 100000) return (n / 1000).toFixed(0) + 'K'; if (n >= 10000) return (n / 1000).toFixed(1) + 'K'; if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; return String(n); }, _warningDismissed: false, }; // Update the token counter below the input function updateInputTokens() { const el = document.getElementById('inputTokenCount'); if (!el) return; const input = document.getElementById('messageInput'); const inputText = input?.value || ''; const inputTokens = Tokens.estimate(inputText); if (!inputText.trim()) { el.textContent = ''; el.className = 'input-token-count'; return; } const budget = Tokens.getContextBudget(); if (budget.maxContext > 0) { // Show relative to available context const chat = App.chats.find(c => c.id === App.currentChatId); const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt); const totalWithInput = convTokens + inputTokens; const pct = totalWithInput / budget.maxContext; el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`; el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : ''); } else { el.textContent = `~${Tokens.format(inputTokens)} tokens`; el.className = 'input-token-count'; } } // Check conversation length and show/hide warning function updateContextWarning() { const warning = document.getElementById('contextWarning'); const text = document.getElementById('contextWarningText'); if (!warning || !text) return; const budget = Tokens.getContextBudget(); if (budget.maxContext <= 0) { warning.style.display = 'none'; return; } const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || !chat.messages?.length) { warning.style.display = 'none'; return; } const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt); const pct = convTokens / budget.maxContext; if (pct >= 0.9 && !Tokens._warningDismissed) { warning.style.display = 'flex'; warning.className = 'context-warning danger'; text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`; } else if (pct >= 0.75 && !Tokens._warningDismissed) { warning.style.display = 'flex'; warning.className = 'context-warning'; text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`; } else { warning.style.display = 'none'; } } function dismissContextWarning() { Tokens._warningDismissed = true; const el = document.getElementById('contextWarning'); if (el) el.style.display = 'none'; } 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); const splashErr = document.getElementById('splashError'); if (e.proxyBlocked) { splashErr.innerHTML = `Network proxy blocked this request
` + `Proxy response: "${API._esc(e.proxyTitle)}"
` + `Ask your network admin to whitelist this domain. ` + `Run diagnostics`; } else if (e.name === 'TimeoutError' || e.name === 'AbortError') { splashErr.innerHTML = `Connection timed out
` + `Server may be starting up, or a proxy is blocking the connection. ` + `Run diagnostics`; } else { splashErr.innerHTML = `Cannot reach server
` + `${API._esc(e.message)}. ` + `Run diagnostics`; } 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'; } } // ── Models ─────────────────────────────────── // ── Capability Resolution ─────────────────────────────────────── // The backend is the source of truth for model capabilities. // Resolution chain on the server: catalog (provider API sync) → heuristic. // The frontend does NOT maintain a static model table — the same model // can have different capabilities on different providers (e.g. DeepSeek // on Venice has no tool_calling, same model on OpenRouter does). // resolveCapabilities returns backend caps as-is. No client-side override. function resolveCapabilities(backendCaps, modelId) { return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {}; } async function fetchModels() { try { const data = await API.listEnabledModels(); App.defaultModel = data.default_model || ''; // Load user model preferences try { const prefData = await API.getModelPreferences(); App.hiddenModels = new Set( (prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) ); } catch (e) { App.hiddenModels = new Set(); } App.models = (data.models || []).map(m => { const isPreset = !!m.is_preset; const baseModelId = m.model_id || m.id; // Presets use preset_id; base models use configId:modelId to avoid // collisions when the same model exists across team/personal/global providers const id = isPreset ? (m.preset_id || m.id) : ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId); return { id, baseModelId, name: m.display_name || baseModelId, provider: m.provider_name || m.provider || '', configId: m.config_id || m.provider_config_id || null, capabilities: resolveCapabilities(m.capabilities, baseModelId), isPreset, presetId: m.preset_id || m.persona_id || null, presetScope: m.preset_scope || (isPreset ? m.scope : null) || null, presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null, presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null, source: m.source || (isPreset ? 'preset' : 'global'), teamName: m.preset_team_name || null, hidden: !isPreset && App.hiddenModels.has(baseModelId), }; }); // Sort: presets first (global > team > personal), then regular models const scopeOrder = { global: 0, team: 1, personal: 2 }; App.models.sort((a, b) => { if (a.isPreset && !b.isPreset) return -1; if (!a.isPreset && b.isPreset) return 1; if (a.isPreset && b.isPreset) { const sa = scopeOrder[a.presetScope] ?? 9; const sb = scopeOrder[b.presetScope] ?? 9; if (sa !== sb) return sa - sb; } return a.name.localeCompare(b.name); }); console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`); } 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, tool_calls: m.tool_calls || null, 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')); Tokens._warningDismissed = false; updateContextWarning(); updateInputTokens(); } async function newChat() { App.currentChatId = null; UI.renderChatList(); UI.showEmptyState(); UI.showRegenerate(false); Tokens._warningDismissed = false; updateContextWarning(); updateInputTokens(); 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.findModel(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 (e.proxyBlocked) { UI.toast('Network proxy blocked this request — contact your network admin', 'error'); } else 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, tool_calls: m.tool_calls || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); updateContextWarning(); } 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.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; // Truncate display to the parent of the message being regenerated. // This makes the stream appear in the correct position — as a fresh // response to the parent user message, not appended at the bottom. const chat = App.chats.find(c => c.id === App.currentChatId); if (chat) { const msgIdx = chat.messages.findIndex(m => m.id === messageId); if (msgIdx > 0) { const truncated = chat.messages.slice(0, msgIdx); UI.renderMessages(truncated); } } App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { 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 (e.proxyBlocked) { UI.toast('Network proxy blocked this request', 'error'); } else 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.findModel(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); }); // Sidebar chat search const _chatSearchInput = document.getElementById('chatSearchInput'); _chatSearchInput?.addEventListener('input', () => UI.renderChatList()); document.getElementById('chatSearchClear')?.addEventListener('click', () => { if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); } }); // 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(); const visible = App.models.filter(m => !m.hidden).length; UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, '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.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'); } }); 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 = ''; document.getElementById('adminProvPrivate').checked = false; 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 (shared form) document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => { const form = ensureAdminPresetForm(); if (!form) return; _editingPresetId = null; form.setSubmitLabel('Create'); form.clearForm(); const f = document.getElementById('adminAddPresetForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); // Admin — Teams document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => { document.getElementById('adminAddTeamForm').style.display = ''; document.getElementById('adminTeamName').focus(); }); document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => { document.getElementById('adminAddTeamForm').style.display = 'none'; document.getElementById('adminTeamName').value = ''; document.getElementById('adminTeamDesc').value = ''; }); document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => { const name = document.getElementById('adminTeamName').value.trim(); const desc = document.getElementById('adminTeamDesc').value.trim(); if (!name) return UI.toast('Team name required', 'error'); try { await API.adminCreateTeam(name, desc); document.getElementById('adminAddTeamForm').style.display = 'none'; document.getElementById('adminTeamName').value = ''; document.getElementById('adminTeamDesc').value = ''; UI.toast('Team created'); await UI.loadAdminTeams(true); } catch (e) { UI.toast(e.message, 'error'); } }); document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => { document.getElementById('adminTeamDetail').style.display = 'none'; document.getElementById('adminTeamList').style.display = ''; UI.loadAdminTeams(true); }); document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => { document.getElementById('adminAddMemberForm').style.display = ''; await UI.loadMemberUserDropdown(UI._teamEditId); }); document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() { try { await API.adminUpdateTeam(UI._teamEditId, { settings: JSON.stringify({ require_private_providers: this.checked }) }); UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed'); } catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; } }); document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() { try { await API.adminUpdateTeam(UI._teamEditId, { settings: JSON.stringify({ allow_team_providers: this.checked }) }); UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled'); } catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; } }); document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => { document.getElementById('adminAddMemberForm').style.display = 'none'; }); document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => { const userId = document.getElementById('adminMemberUser').value; const role = document.getElementById('adminMemberRole').value; if (!userId) return UI.toast('Select a user', 'error'); try { await API.adminAddMember(UI._teamEditId, userId, role); document.getElementById('adminAddMemberForm').style.display = 'none'; UI.toast('Member added'); await UI.loadTeamMembers(UI._teamEditId); } catch (e) { UI.toast(e.message, 'error'); } }); // Settings — Team management (team admin self-service) document.getElementById('settingsTeamBackBtn')?.addEventListener('click', () => { UI.loadTeamsTab(); }); document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => { document.getElementById('settingsTeamAddMember').style.display = ''; // Load available users (requires sys-admin; team admins use their own knowledge) const sel = document.getElementById('settingsTeamMemberUser'); sel.innerHTML = ''; try { const resp = await API.teamListMembers(UI._managingTeamId); const existingIds = new Set((resp.data || []).map(m => m.user_id)); // Team admins can't list all users — provide an email input fallback sel.innerHTML = ''; } catch (e) { sel.innerHTML = ''; } }); document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => { document.getElementById('settingsTeamAddMember').style.display = 'none'; }); var _teamPresetForm = null; document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => { const container = document.getElementById('settingsTeamAddPreset'); container.style.display = ''; if (!_teamPresetForm) { _teamPresetForm = renderPresetForm(container, { prefix: 'teamPreset', showAvatar: true, showProviderConfig: false, 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.teamCreatePreset(teamId, vals); // Upload pending avatar const presetId = result?.id; if (presetId && vals._pendingAvatar) { try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); } catch (e) { console.warn('Team preset avatar upload failed:', e.message); } } container.style.display = 'none'; _teamPresetForm.clearForm(); UI.toast('Team preset created'); await UI.loadTeamManagePresets(teamId); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } }, onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); } }); } else { _teamPresetForm.clearForm(); } UI.loadTeamPresetModelDropdown(UI._managingTeamId); }); // Team — providers const defaultEndpoints = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', venice: 'https://api.venice.ai/api/v1', openrouter: 'https://openrouter.ai/api/v1', }; document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => { const form = document.getElementById('settingsTeamAddProvider'); form.style.display = form.style.display === 'none' ? '' : 'none'; // Populate provider type dropdown const sel = document.getElementById('teamProviderType'); if (sel && sel.options.length === 0) { ['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => { const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' }; const opt = document.createElement('option'); opt.value = p; opt.textContent = labels[p] || p; sel.appendChild(opt); }); } }); document.getElementById('teamProviderType')?.addEventListener('change', function() { const ep = document.getElementById('teamProviderEndpoint'); if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) { ep.value = defaultEndpoints[this.value] || ''; } }); document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => { document.getElementById('settingsTeamAddProvider').style.display = 'none'; }); document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => { const teamId = UI._managingTeamId; const name = document.getElementById('teamProviderName').value.trim(); const provider = document.getElementById('teamProviderType').value; const endpoint = document.getElementById('teamProviderEndpoint').value.trim(); const apiKey = document.getElementById('teamProviderKey').value; const isPrivate = document.getElementById('teamProviderPrivate').checked; if (!name) return UI.toast('Name required', 'error'); if (!endpoint) return UI.toast('Endpoint required', 'error'); try { await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate }); document.getElementById('settingsTeamAddProvider').style.display = 'none'; document.getElementById('teamProviderName').value = ''; document.getElementById('teamProviderEndpoint').value = ''; document.getElementById('teamProviderKey').value = ''; document.getElementById('teamProviderPrivate').checked = false; UI.toast('Provider added'); await UI.loadTeamManageProviders(teamId); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } }); // Team — edit provider document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => { document.getElementById('settingsTeamEditProvider').style.display = 'none'; }); document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => { const teamId = UI._managingTeamId; const provId = UI._editingTeamProviderId; if (!provId) return; const updates = {}; const name = document.getElementById('teamProviderEditName').value.trim(); const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim(); const apiKey = document.getElementById('teamProviderEditKey').value; const defaultModel = document.getElementById('teamProviderEditDefault').value.trim(); const isPrivate = document.getElementById('teamProviderEditPrivate').checked; if (name) updates.name = name; if (endpoint) updates.endpoint = endpoint; if (apiKey) updates.api_key = apiKey; if (defaultModel) updates.model_default = defaultModel; updates.is_private = isPrivate; try { await API.teamUpdateProvider(teamId, provId, updates); document.getElementById('settingsTeamEditProvider').style.display = 'none'; UI.toast('Provider updated'); await UI.loadTeamManageProviders(teamId); } catch (e) { UI.toast(e.message, 'error'); } }); // User — personal presets var _userPresetForm = null; document.getElementById('userAddPresetBtn')?.addEventListener('click', () => { const container = document.getElementById('userAddPresetForm'); container.style.display = container.style.display === 'none' ? '' : 'none'; if (!_userPresetForm) { _userPresetForm = renderPresetForm(container, { prefix: 'userPreset', showAvatar: true, showProviderConfig: false, 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.createUserPreset(vals); const presetId = result?.id; if (presetId && vals._pendingAvatar) { try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); } catch (e) { console.warn('User preset avatar upload failed:', e.message); } } container.style.display = 'none'; _userPresetForm.clearForm(); UI.toast('Preset created'); await UI.loadUserPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } }, onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); } }); } // Populate model dropdown from user's available models const sel = _userPresetForm.getModelSelect(); if (sel) { sel.innerHTML = ''; App.models.filter(m => !m.isPreset).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 (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(); }}); // 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()); // 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'; updateInputTokens(); }); // 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) => { // Escape: stop generation → close command palette → close side panel → close topmost modal if (e.key === 'Escape') { if (App.isGenerating) { stopGeneration(); return; } if (document.getElementById('cmdPalette')?.classList.contains('active')) { closeCmdPalette(); return; } if (document.getElementById('sidePanel')?.classList.contains('open')) { closeSidePanel(); return; } const open = [...document.querySelectorAll('.modal-overlay.active')]; if (open.length) closeModal(open[open.length - 1].id); return; } // Ctrl/Cmd+K: command palette if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); toggleCmdPalette(); return; } // Ctrl/Cmd+Shift+S: focus sidebar search if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { e.preventDefault(); const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); return; } }); // Recheck tab overflow on resize / zoom changes window.addEventListener('resize', () => { document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow); }); } // ── Command Palette ────────────────────────── const _cmdCommands = [ // Navigation { id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '', action: () => newChat() }, { id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '', 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: '', action: () => UI.toggleSidebar() }, // Tools { id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '', action: () => openNotes() }, { id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('md') }, { id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('json') }, // Settings { id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '', action: () => UI.openSettings() }, { id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' }, { id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '', action: () => openDebugModal() }, // Account { id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '', 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: '', 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 = '
No matching commands
'; 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 += `
${c.group}
`; } html += `
${c.icon} ${c.label} ${c.hint ? `${c.hint}` : ''}
`; }); 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 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(); const editId = UI._editingProviderId; if (editId) { // Update mode — api_key optional (blank = keep existing) if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning'); const patch = { name, provider, endpoint, model_default: model }; if (apiKey) patch.api_key = apiKey; try { await API.updateConfig(editId, patch); UI.toast('Provider updated', 'success'); UI._editingProviderId = null; document.getElementById('providerApiKey').placeholder = 'API key'; UI.hideProviderForm(); UI.loadProviderList(); fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } else { // Create mode if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning'); try { const result = await API.createConfig(name, provider, endpoint, apiKey, model); 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'); } 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 refreshProviderModels(id, name) { try { UI.toast(`Fetching models for ${name}...`, 'info'); const result = await API.fetchProviderModels(id); const total = result.total || 0; UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success'); fetchModels(); // refresh the model selector } catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); } } async function editProvider(id) { try { const cfg = await API.getConfig(id); // Populate the add form with existing values document.getElementById('providerName').value = cfg.name || ''; document.getElementById('providerType').value = cfg.provider || 'openai'; document.getElementById('providerEndpoint').value = cfg.endpoint || ''; document.getElementById('providerApiKey').value = ''; // Don't expose key document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key'; document.getElementById('providerDefaultModel').value = cfg.model_default || ''; // Show form and switch to edit mode UI.showProviderForm(); UI._editingProviderId = id; } catch (e) { UI.toast(e.message, 'error'); } } async function handleSaveAdminSettings() { try { // 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 presets → allow_user_personas policy const userPresets = document.getElementById('adminUserPresetsToggle').checked; await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' }); // Default model → default_model policy const defaultModel = document.getElementById('adminDefaultModel').value; await API.adminUpdateSetting('default_model', { value: defaultModel }); // 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 }); UI.toast('Settings saved', 'success'); // Live-apply: refresh policies and dependent UI await initBanners(); UI.checkUserProvidersAllowed(); UI.checkUserPresetsAllowed(); fetchModels(); } 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 showApproveForm(userId, username) { const form = document.getElementById(`approveForm-${userId}`); const teamsEl = document.getElementById(`approveTeams-${userId}`); if (!form || !teamsEl) return; // Load teams for checkboxes teamsEl.innerHTML = 'Loading teams...'; form.style.display = ''; try { const resp = await API.adminListTeams(); const teams = (resp.data || []).filter(t => t.is_active); if (teams.length === 0) { teamsEl.innerHTML = 'No teams — user will be activated without team assignment'; } else { teamsEl.innerHTML = '' + teams.map(t => ``).join(''); } } catch (e) { teamsEl.innerHTML = `Could not load teams`; } } function hideApproveForm(userId) { const form = document.getElementById(`approveForm-${userId}`); if (form) form.style.display = 'none'; } async function submitApproval(userId) { const form = document.getElementById(`approveForm-${userId}`); const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value); try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminToggleActive(userId, true, teamIds, 'member'); const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated'; UI.toast(msg, '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 adminResetUserPassword(id, username) { const pw = prompt( `Reset password for "${username}"?\n\n` + `⚠️ WARNING: This will DESTROY the user's personal vault.\n` + `All personal API keys (BYOK) will be permanently deleted.\n` + `The user will need to re-add any personal provider keys.\n\n` + `Enter new password (min 8 chars):` ); if (!pw) return; if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; } if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return; try { await API.adminResetPassword(id, pw); UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success'); } 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('adminProvPrivate').checked = !!c.is_private; 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(); const isPrivate = document.getElementById('adminProvPrivate').checked; 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; updates.is_private = isPrivate; 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, isPrivate); 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 { const resp = await API.adminFetchModels(); const errs = resp.errors || []; if (errs.length > 0) { UI.toast('Fetch errors: ' + errs.join('; '), 'error'); } else { UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success'); } hint.textContent = ''; await UI.loadAdminModels(); } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } async function cycleModelVisibility(id, current) { // Cycle: disabled → enabled → team → disabled const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled'; try { const el = _adminScroll(), pos = el?.scrollTop || 0; // Send both for backward compat with pre-0.8.3 backends await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' }); await UI.loadAdminModels(); _restoreScroll(el, pos); } catch (e) { UI.toast(e.message, 'error'); } } async function bulkSetVisibility(visibility) { const hint = document.getElementById('adminModelsHint'); const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' }; hint.textContent = `${labels[visibility] || 'Updating'} all...`; try { // Send both for backward compat with pre-0.8.3 backends const resp = await API.adminBulkUpdateModels(visibility); const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' }; hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`; setTimeout(() => { hint.textContent = ''; }, 3000); await UI.loadAdminModels(); } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } // ── Admin Roles ──────────────────────────── async function adminRoleProviderChanged(role, slot) { const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value; const modelSelect = document.getElementById(`role-${role}-${slot}-model`); if (!modelSelect) return; modelSelect.innerHTML = ''; if (!provId || !window._adminModelList) return; const models = window._adminModelList.filter(m => m.provider_config_id === provId); models.forEach(m => { const opt = document.createElement('option'); opt.value = m.model_id; opt.textContent = m.display_name || m.model_id; modelSelect.appendChild(opt); }); } async function adminSaveRole(role) { const status = document.getElementById(`role-${role}-status`); try { const getBinding = (slot) => { const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value; const model = document.getElementById(`role-${role}-${slot}-model`)?.value; return (prov && model) ? { provider_config_id: prov, model_id: model } : null; }; await API.adminUpdateRole(role, { primary: getBinding('primary'), fallback: getBinding('fallback') }); if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; } setTimeout(() => { if (status) status.textContent = ''; }, 3000); } catch (e) { if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; } } } async function adminTestRole(role) { const status = document.getElementById(`role-${role}-status`); if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; } try { const result = await API.adminTestRole(role); if (status) { const fb = result.used_fallback ? ' (fallback)' : ''; status.textContent = `✓ ${result.model}${fb}`; status.style.color = 'var(--success)'; } } catch (e) { if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; } } } // ── User Model Preferences ────────────────── async function toggleUserModelVisibility(modelId, currentlyHidden) { try { await API.setModelPreference(modelId, !currentlyHidden); if (currentlyHidden) App.hiddenModels.delete(modelId); else App.hiddenModels.add(modelId); await UI.loadUserModels(); await fetchModels(); // refresh model selector } catch (e) { UI.toast(e.message, 'error'); } } async function bulkSetUserModelVisibility(show) { const models = App.models.filter(m => !m.isPreset); if (!models.length) return; const shouldHide = !show; // Only update models not already in the desired state const toUpdate = models .map(m => m.baseModelId || m.id) .filter(mid => App.hiddenModels.has(mid) !== shouldHide); if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; } try { await API.bulkSetModelPreferences(toUpdate, shouldHide); toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid)); await UI.loadUserModels(); await fetchModels(); UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteUserPreset(id, name) { if (!confirm(`Delete preset "${name}"?`)) return; try { await API.deleteUserPreset(id); UI.toast('Preset deleted'); await UI.loadUserPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Admin Presets ──────────────────────────── var _adminPresetForm = null; function ensureAdminPresetForm() { if (_adminPresetForm) return _adminPresetForm; const container = document.getElementById('adminAddPresetForm'); if (!container) return null; _adminPresetForm = renderPresetForm(container, { prefix: 'adminPreset', showAvatar: true, showProviderConfig: true, onSubmit: (vals) => createAdminPreset(vals), onCancel: () => { container.style.display = 'none'; _editingPresetId = null; _adminPresetForm.setSubmitLabel('Create'); _adminPresetForm.clearForm(); } }); // Override avatar handlers for edit-mode (upload immediately for existing presets) const fileInput = document.getElementById('adminPreset_avatarFileInput'); if (fileInput) { // Remove default handler set by renderPresetForm, add edit-aware one const newInput = fileInput.cloneNode(true); fileInput.parentNode.replaceChild(newInput, fileInput); newInput.addEventListener('change', async function() { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async () => { if (_editingPresetId) { try { const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result); if (data.avatar) { _adminPresetForm.updateAvatarPreview(data.avatar); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar updated'); } } catch (e) { UI.toast(e.message || 'Upload failed', 'error'); } } else { _adminPresetForm.updateAvatarPreview(reader.result); } }; reader.readAsDataURL(file); this.value = ''; }); document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click()); } const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn'); if (removeBtn) { const newBtn = removeBtn.cloneNode(true); removeBtn.parentNode.replaceChild(newBtn, removeBtn); newBtn.addEventListener('click', async () => { if (_editingPresetId) { try { await API.adminDeletePresetAvatar(_editingPresetId); _adminPresetForm.updateAvatarPreview(null); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar removed'); } catch (e) { UI.toast(e.message || 'Remove failed', 'error'); } } else { _adminPresetForm.updateAvatarPreview(null); } }); } return _adminPresetForm; } var _editingPresetId = null; function editAdminPreset(id) { const p = UI._presetCache?.[id]; if (!p) return; _editingPresetId = id; ensureAdminPresetForm(); const form = _adminPresetForm; if (!form) return; document.getElementById('adminAddPresetForm').style.display = ''; form.setValues(p); form.setSubmitLabel('Update'); } async function createAdminPreset(vals) { if (!vals) { if (_adminPresetForm) vals = _adminPresetForm.getValues(); else return; } if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; } const preset = { name: vals.name, base_model_id: vals.base_model_id, description: vals.description || '', system_prompt: vals.system_prompt || '', }; if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id; if (vals.temperature != null) preset.temperature = vals.temperature; if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens; 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 if (!_editingPresetId && presetId && vals._pendingAvatar) { try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); } catch (e) { console.warn('Preset avatar upload failed:', e.message); } } _editingPresetId = null; document.getElementById('adminAddPresetForm').style.display = 'none'; if (_adminPresetForm) { _adminPresetForm.setSubmitLabel('Create'); _adminPresetForm.clearForm(); } 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'); } } // ── Team Admin Functions ──────────────────── async function toggleTeamActive(id, active) { try { await API.adminUpdateTeam(id, { is_active: active }); UI.toast(active ? 'Team activated' : 'Team deactivated'); await UI.loadAdminTeams(true); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteTeam(id, name) { if (!confirm(`Delete team "${name}"? All members will be removed.`)) return; try { await API.adminDeleteTeam(id); UI.toast('Team deleted'); await UI.loadAdminTeams(true); } catch (e) { UI.toast(e.message, 'error'); } } async function updateTeamMember(teamId, memberId, role) { try { await API.adminUpdateMember(teamId, memberId, role); UI.toast('Role updated'); } catch (e) { UI.toast(e.message, 'error'); await UI.loadTeamMembers(teamId); } } async function removeTeamMember(teamId, memberId, email) { if (!confirm(`Remove ${email} from team?`)) return; try { await API.adminRemoveMember(teamId, memberId); UI.toast('Member removed'); await UI.loadTeamMembers(teamId); } catch (e) { UI.toast(e.message, 'error'); } } // Settings-side team management (uses team admin API, not sys-admin) async function settingsUpdateTeamMember(teamId, memberId, role) { try { await API.teamUpdateMember(teamId, memberId, role); UI.toast('Role updated'); } catch (e) { UI.toast(e.message, 'error'); await UI.loadTeamManageMembers(teamId); } } async function settingsRemoveTeamMember(teamId, memberId, email) { if (!confirm(`Remove ${email} from team?`)) return; try { await API.teamRemoveMember(teamId, memberId); UI.toast('Member removed'); await UI.loadTeamManageMembers(teamId); } catch (e) { UI.toast(e.message, 'error'); } } async function settingsDeleteTeamPreset(teamId, presetId, name) { if (!confirm(`Delete team preset "${name}"?`)) return; try { await API.teamDeletePreset(teamId, presetId); UI.toast('Preset deleted'); await UI.loadTeamManagePresets(teamId); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) { try { await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive }); UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated'); await UI.loadTeamManageProviders(teamId); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function settingsDeleteTeamProvider(teamId, providerId, name) { if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return; try { await API.teamDeleteProvider(teamId, providerId); UI.toast('Provider deleted'); await UI.loadTeamManageProviders(teamId); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) { UI._editingTeamProviderId = provId; document.getElementById('settingsTeamAddProvider').style.display = 'none'; const form = document.getElementById('settingsTeamEditProvider'); form.style.display = ''; document.getElementById('teamProviderEditName').value = name; document.getElementById('teamProviderEditEndpoint').value = endpoint; document.getElementById('teamProviderEditKey').value = ''; document.getElementById('teamProviderEditDefault').value = defaultModel; document.getElementById('teamProviderEditPrivate').checked = isPrivate; } // ── Notes Panel ───────────────────────────── var _editingNoteId = null; var _notesSelectMode = false; var _selectedNoteIds = new Set(); var _notesSort = 'updated_desc'; async function openNotes() { openSidePanel('notes'); _exitSelectMode(); await loadNotesList(); await loadNoteFolders(); } async function loadNotesList(folder, searchQuery) { const list = document.getElementById('notesList'); list.innerHTML = '
Loading…
'; try { let data; if (searchQuery) { data = await API.searchNotes(searchQuery); const results = data.data || []; if (results.length === 0) { list.innerHTML = '
No results found
'; return; } list.innerHTML = results.map(n => _noteListItem(n, true)).join(''); } else { const folderVal = folder || document.getElementById('notesFolderFilter')?.value || ''; data = await API.listNotes(100, 0, folderVal, '', _notesSort); const notes = data.data || []; if (notes.length === 0) { list.innerHTML = `
${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}
`; return; } list.innerHTML = notes.map(n => _noteListItem(n, false)).join(''); } } catch (e) { list.innerHTML = `
Failed to load: ${esc(e.message)}
`; } } function _noteListItem(note, isSearch) { const tags = (note.tags || []).map(t => `${esc(t)}`).join(''); const folder = note.folder_path && note.folder_path !== '/' ? `${esc(note.folder_path)}` : ''; const preview = isSearch && note.headline ? `
${_highlightHeadline(note.headline)}
` : `
${esc((note.preview || note.content || '').slice(0, 120))}
`; const time = _relativeTime(note.updated_at); const checked = _selectedNoteIds.has(note.id) ? 'checked' : ''; return `
${esc(note.title)} ${time}
${preview}
${folder}${tags}
`; } function _highlightHeadline(headline) { const escaped = esc(headline); return escaped.replace(/\*\*(.+?)\*\*/g, '$1'); } // ── Multi-select ──────────────── function _enterSelectMode() { _notesSelectMode = true; _selectedNoteIds.clear(); document.getElementById('notesSelectionBar').style.display = ''; document.querySelectorAll('.note-select-col').forEach(el => el.style.display = ''); _updateSelectedCount(); } function _exitSelectMode() { _notesSelectMode = false; _selectedNoteIds.clear(); document.getElementById('notesSelectionBar').style.display = 'none'; document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none'); document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected')); document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false); const sa = document.getElementById('notesSelectAll'); if (sa) sa.checked = false; } function _toggleNoteSelect(id, forceState) { if (!_notesSelectMode) { _enterSelectMode(); } const has = _selectedNoteIds.has(id); const newState = forceState !== undefined ? forceState : !has; if (newState) _selectedNoteIds.add(id); else _selectedNoteIds.delete(id); const item = document.querySelector(`.note-item[data-note-id="${id}"]`); if (item) { item.classList.toggle('selected', newState); const cb = item.querySelector('.note-checkbox'); if (cb) cb.checked = newState; } _updateSelectedCount(); } function _toggleSelectAll(checked) { document.querySelectorAll('.note-checkbox').forEach(cb => { const id = cb.dataset.id; if (checked) _selectedNoteIds.add(id); else _selectedNoteIds.delete(id); cb.checked = checked; cb.closest('.note-item')?.classList.toggle('selected', checked); }); _updateSelectedCount(); } function _updateSelectedCount() { const el = document.getElementById('notesSelectedCount'); if (el) el.textContent = _selectedNoteIds.size; } async function _bulkDeleteSelected() { if (_selectedNoteIds.size === 0) return; const count = _selectedNoteIds.size; if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return; try { const resp = await API.bulkDeleteNotes([..._selectedNoteIds]); UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success'); _exitSelectMode(); await loadNotesList(); await loadNoteFolders(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Folders / Editor / CRUD ───── async function loadNoteFolders() { const sel = document.getElementById('notesFolderFilter'); if (!sel) return; try { const data = await API.listNoteFolders(); const folders = data.folders || []; sel.innerHTML = ''; folders.forEach(f => { sel.innerHTML += ``; }); } catch (e) { /* non-critical */ } } function showNotesList() { document.getElementById('notesListView').style.display = ''; document.getElementById('notesEditorView').style.display = 'none'; _editingNoteId = null; } var _currentNote = null; // cached note for read mode function copyNoteContent() { if (!_currentNote) return; const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`; navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success')); } async function openNoteEditor(noteId) { document.getElementById('notesListView').style.display = 'none'; document.getElementById('notesEditorView').style.display = ''; if (noteId) { _editingNoteId = noteId; try { _currentNote = await API.getNote(noteId); _populateEditFields(_currentNote); _showNoteReadMode(); } catch (e) { UI.toast('Failed to load note: ' + e.message, 'error'); showNotesList(); } } else { // New note — straight to edit mode _editingNoteId = null; _currentNote = null; _clearEditFields(); _showNoteEditMode(); document.getElementById('noteEditorTitle').focus(); } } function _populateEditFields(note) { document.getElementById('noteEditorTitle').value = note.title || ''; document.getElementById('noteEditorFolder').value = note.folder_path || ''; document.getElementById('noteEditorTags').value = (note.tags || []).join(', '); document.getElementById('noteEditorContent').value = note.content || ''; } function _clearEditFields() { document.getElementById('noteEditorTitle').value = ''; document.getElementById('noteEditorFolder').value = ''; document.getElementById('noteEditorTags').value = ''; document.getElementById('noteEditorContent').value = ''; } function _showNoteReadMode() { if (!_currentNote) return; const n = _currentNote; // Title document.getElementById('noteReadTitle').textContent = n.title || ''; // Meta: folder + tags const parts = []; if (n.folder_path && n.folder_path !== '/') parts.push(`${esc(n.folder_path)}`); (n.tags || []).forEach(t => parts.push(`${esc(t)}`)); document.getElementById('noteReadMeta').innerHTML = parts.join(' '); // Rendered markdown content — reuse the chat formatter document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || ''); // Show/hide delete document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none'; // Toggle visibility document.getElementById('noteReadMode').style.display = ''; document.getElementById('noteEditMode').style.display = 'none'; document.getElementById('noteEditBtn').style.display = ''; document.getElementById('notePreviewBtn').style.display = 'none'; } function _showNoteEditMode() { document.getElementById('noteReadMode').style.display = 'none'; document.getElementById('noteEditMode').style.display = ''; document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none'; document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none'; document.getElementById('noteEditBtn').style.display = 'none'; document.getElementById('notePreviewBtn').style.display = ''; } function _showNotePreview() { // Live preview from textarea without saving const title = document.getElementById('noteEditorTitle').value.trim(); const content = document.getElementById('noteEditorContent').value; const folderVal = document.getElementById('noteEditorFolder').value.trim(); const tagsStr = document.getElementById('noteEditorTags').value.trim(); const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []; document.getElementById('noteReadTitle').textContent = title || 'Untitled'; const parts = []; if (folderVal) parts.push(`${esc(folderVal)}`); tags.forEach(t => parts.push(`${esc(t)}`)); document.getElementById('noteReadMeta').innerHTML = parts.join(' '); document.getElementById('noteReadContent').innerHTML = formatMessage(content || ''); document.getElementById('noteReadMode').style.display = ''; document.getElementById('noteEditMode').style.display = 'none'; document.getElementById('noteEditBtn').style.display = ''; document.getElementById('notePreviewBtn').style.display = 'none'; // Keep delete hidden in preview-from-edit document.getElementById('noteDeleteBtn2').style.display = 'none'; } async function saveNote() { const title = document.getElementById('noteEditorTitle').value.trim(); const content = document.getElementById('noteEditorContent').value; const folder = document.getElementById('noteEditorFolder').value.trim(); const tagsStr = document.getElementById('noteEditorTags').value.trim(); const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []; if (!title) { UI.toast('Title is required', 'warning'); return; } if (!content) { UI.toast('Content is required', 'warning'); return; } try { if (_editingNoteId) { const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' }); _currentNote = updated; UI.toast('Note updated', 'success'); _showNoteReadMode(); } else { const created = await API.createNote(title, content, folder, tags); _editingNoteId = created.id; _currentNote = created; UI.toast('Note created', 'success'); _showNoteReadMode(); } } catch (e) { UI.toast(e.message, 'error'); } } async function deleteNote() { if (!_editingNoteId) return; if (!confirm('Delete this note?')) return; try { await API.deleteNote(_editingNoteId); UI.toast('Note deleted', 'success'); showNotesList(); await loadNotesList(); await loadNoteFolders(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Branding ──────────────────────────────── function initBranding() { const b = window.__BRANDING__; if (!b || typeof b !== 'object') return; const brandBase = (window.__BASE__ || '') + '/branding/'; // Accent color → CSS custom property if (b.accent_color) { document.documentElement.style.setProperty('--accent-color', b.accent_color); } // Page title if (b.org_name) { document.title = b.org_name; } // Sidebar brand text const sidebarText = document.getElementById('brandSidebarText'); if (sidebarText && b.org_name) sidebarText.textContent = b.org_name; // Splash hero wordmark const wordmark = document.getElementById('brandWordmark'); if (wordmark && b.org_name) wordmark.textContent = b.org_name; // Splash headline — replace if branding provides it if (b.headline) { const headline = document.getElementById('brandHeadline'); if (headline) headline.textContent = b.headline; } // Splash tagline const tagline = document.getElementById('brandTagline'); if (tagline && b.tagline) tagline.textContent = b.tagline; // Auth card text if (b.org_name) { const authHeader = document.querySelector('.auth-card-header h2'); if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name; const authSub = document.querySelector('.auth-card-header p'); if (authSub) authSub.textContent = 'Sign in to continue'; } const authFooter = document.getElementById('brandAuthFooter'); if (authFooter && b.tagline) authFooter.textContent = b.tagline; // Logo — replace SVG with in splash hero if (b.logo) { const logoUrl = brandBase + b.logo; const logoEl = document.getElementById('brandLogo'); if (logoEl) { const img = document.createElement('img'); img.src = logoUrl; img.alt = b.org_name || 'Logo'; img.className = 'hero-logo-img'; img.onerror = function() { this.style.display = 'none'; const svg = logoEl.querySelector('svg'); if (svg) svg.style.display = ''; }; const svg = logoEl.querySelector('svg'); if (svg) svg.style.display = 'none'; logoEl.appendChild(img); } // Sidebar logo — replace emoji with small img const sidebarLogo = document.getElementById('brandSidebarLogo'); if (sidebarLogo) { const originalEmoji = sidebarLogo.textContent; const img = document.createElement('img'); img.src = logoUrl; img.alt = ''; img.className = 'brand-logo-img'; img.onerror = function() { this.remove(); sidebarLogo.textContent = originalEmoji; }; sidebarLogo.textContent = ''; sidebarLogo.appendChild(img); } } // Favicon if (b.favicon) { const favUrl = brandBase + b.favicon; document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => { link.href = favUrl; }); } // Feature pills — custom array replaces defaults, empty array hides them if (b.pills !== undefined) { const pillsEl = document.getElementById('brandPills'); if (pillsEl) { if (!Array.isArray(b.pills) || b.pills.length === 0) { pillsEl.style.display = 'none'; } else { pillsEl.innerHTML = b.pills.map(p => { const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : ''; return `
${p.icon || ''} ${p.text}
`; }).join(''); } } } console.log('🎨 Branding applied:', b.org_name || '(defaults)'); } // ── Banners ────────────────────────────────── async function initBanners() { try { const data = await API.getPublicSettings?.() || {}; // Store policies for user-facing checks (allow_user_byok, etc.) App.policies = data.policies || {}; // Also flatten into serverSettings for backward compat App.serverSettings = {}; if (data.banner) App.serverSettings.banner = data.banner; if (data.branding) App.serverSettings.branding = data.branding; const root = document.documentElement; const banner = data.banner; // Clear previous banner state ['bannerTop', 'bannerBottom'].forEach(id => { const el = document.getElementById(id); if (el) { el.classList.remove('active'); el.textContent = ''; } }); root.style.setProperty('--banner-top-height', '0px'); root.style.setProperty('--banner-bottom-height', '0px'); if (!banner || !banner.enabled) return; root.style.setProperty('--banner-bg', banner.bg || '#007a33'); root.style.setProperty('--banner-fg', banner.fg || '#ffffff'); const text = banner.text || ''; const pos = banner.position || 'both'; if (pos === 'top' || pos === 'both') { const el = document.getElementById('bannerTop'); el.textContent = text; el.classList.add('active'); root.style.setProperty('--banner-top-height', '22px'); } if (pos === 'bottom' || pos === 'both') { const el = document.getElementById('bannerBottom'); el.textContent = text; el.classList.add('active'); root.style.setProperty('--banner-bottom-height', '22px'); } } catch (e) { // Banners are optional — non-admin users may not have access to settings console.debug('Banner init skipped:', e.message); } } // ── Boot ───────────────────────────────────── document.addEventListener('DOMContentLoaded', init);