// ========================================== // 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: true, systemPrompt: '', maxTokens: 0, // 0 = auto from model capabilities temperature: 0.7, }, }; // ── Init ───────────────────────────────────── async function init() { console.log('🔀 Chat Switchboard initializing...'); initBranding(); // Apply branding before splash is visible API.loadTokens(); let health = null; try { health = await API.health(); console.log('✅ Backend reachable:', health.version); } catch (e) { console.error('❌ Backend unreachable:', e.message); document.getElementById('splashError').textContent = 'Cannot reach server — check connection'; showSplash(null); return; } if (API.isAuthed) { try { await API.getProfile(); console.log('✅ Session valid for', API.user?.username); } catch (e) { console.warn('⚠️ Session expired, clearing'); API.clearTokens(); } } if (API.isAuthed) { await startApp(); } else { showSplash(health); } } async function startApp() { hideSplash(); UI.restoreSidebar(); await loadSettings(); await loadChats(); await fetchModels(); await initBanners(); UI.renderChatList(); UI.updateModelSelector(); UI.updateUser(); UI.showAdminButton(API.isAdmin); initListeners(); // Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet) try { Events.connect((window.__BASE__ || '') + '/ws'); } catch (e) { console.warn('EventBus WebSocket not available:', e.message); } console.log('✅ Chat Switchboard ready'); } // ── Settings ───────────────────────────────── async function loadSettings() { try { const remote = await API.getSettings(); if (remote && typeof remote === 'object') { if (remote.model) App.settings.model = remote.model; if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt; if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens; if (remote.temperature !== undefined) App.settings.temperature = remote.temperature; } } catch (e) { console.warn('Settings load failed:', e.message); } } async function saveSettings() { try { await API.updateSettings({ model: App.settings.model, system_prompt: App.settings.systemPrompt, max_tokens: App.settings.maxTokens, temperature: App.settings.temperature, }); } catch (e) { console.warn('Settings sync failed:', e.message); } } // ── Avatar Preview ────────────────────────── function updateAvatarPreview(dataURI) { const preview = document.getElementById('avatarPreview'); const letter = document.getElementById('avatarPreviewLetter'); const removeBtn = document.getElementById('avatarRemoveBtn'); const existingImg = preview.querySelector('img'); if (dataURI) { letter.style.display = 'none'; if (existingImg) { existingImg.src = dataURI; } else { const img = document.createElement('img'); img.src = dataURI; img.alt = 'Avatar'; preview.insertBefore(img, letter); } removeBtn.style.display = ''; } else { if (existingImg) existingImg.remove(); const name = API.user?.display_name || API.user?.username || '?'; letter.textContent = name[0].toUpperCase(); letter.style.display = ''; removeBtn.style.display = 'none'; } } // ── Models ─────────────────────────────────── // Client-side known model capabilities — used when backend hasn't synced yet. // Mirrors server/providers/capabilities.go knownModels table. const KNOWN_MODELS = { 'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 }, 'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 }, 'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 }, 'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 }, 'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 }, 'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 }, 'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 }, 'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 }, 'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 }, 'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 }, 'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 }, 'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 }, 'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 }, 'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 }, 'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 }, 'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 }, 'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 }, 'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 }, 'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 }, 'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 }, 'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 }, 'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 }, 'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 }, 'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 }, }; // Look up client-side capabilities by model ID with prefix matching function lookupKnownCaps(modelId) { const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix // Exact match if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] }; // Prefix match (longest wins) let best = null, bestLen = 0; for (const key of Object.keys(KNOWN_MODELS)) { if (id.startsWith(key) && key.length > bestLen) { best = key; bestLen = key.length; } } return best ? { ...KNOWN_MODELS[best] } : null; } // Merge: backend/provider caps are authoritative, client-side fills gaps only function resolveCapabilities(backendCaps, modelId) { const known = lookupKnownCaps(modelId) || {}; if (!backendCaps || Object.keys(backendCaps).length === 0) return known; // Backend is authoritative — start with it const caps = { ...backendCaps }; // Fill gaps (fields backend didn't report) from known table for (const [k, v] of Object.entries(known)) { if (caps[k] === undefined || caps[k] === null) { caps[k] = v; } } return caps; } async function fetchModels() { try { const data = await API.listEnabledModels(); // 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, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); } catch (e) { console.error('Failed to load messages:', e.message); UI.toast('Failed to load messages', 'error'); } } UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); } async function newChat() { App.currentChatId = null; UI.renderChatList(); UI.showEmptyState(); UI.showRegenerate(false); document.getElementById('messageInput').focus(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } } async function deleteChat(chatId) { if (!confirm('Delete this chat?')) return; try { await API.deleteChannel(chatId); App.chats = App.chats.filter(c => c.id !== chatId); if (App.currentChatId === chatId) newChat(); UI.renderChatList(); } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } } // ── Send Message ───────────────────────────── async function sendMessage() { const input = document.getElementById('messageInput'); const text = input.value.trim(); if (!text || App.isGenerating) return; input.value = ''; input.style.height = 'auto'; const selectedId = UI.getModelValue(); const modelInfo = App.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 (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings → Providers', 'error'); } else if (msg.includes('provider error') && msg.includes('429')) { UI.toast('Provider rate limit — wait and retry', 'warning'); } else if (msg.includes('no API config') || msg.includes('no model')) { UI.toast('No provider configured — add one in Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } function stopGeneration() { if (App.abortController) App.abortController.abort(); } // ── Reload Active Path from Server ────────── async function reloadActivePath() { if (!App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; try { const resp = await API.getActivePath(App.currentChatId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); } catch (e) { console.error('Failed to reload path:', e.message); } } // ── Regenerate (tree-aware: creates sibling) ─ async function regenerateMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const selectedId = UI.getModelValue(); const modelInfo = App.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); const resp = await API.streamRegenerate( App.currentChatId, messageId, App.abortController.signal, model, presetId, modelInfo?.configId ); await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name); // Reload from server to get proper tree state await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { const msg = e.message || ''; if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } // Legacy: bottom-bar regenerate button targets the last assistant message async function regenerate() { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || chat.messages.length === 0) return; // Find the last assistant message with an ID for (let i = chat.messages.length - 1; i >= 0; i--) { if (chat.messages[i].role === 'assistant' && chat.messages[i].id) { return regenerateMessage(chat.messages[i].id); } } UI.toast('No assistant message to regenerate', 'warning'); } // ── Edit Message (creates sibling, triggers completion) ── async function editMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; const msg = chat.messages.find(m => m.id === messageId); if (!msg || msg.role !== 'user') return; // Show inline edit UI UI.showEditInline(messageId, msg.content); } async function submitEdit(messageId, newContent) { if (App.isGenerating || !App.currentChatId) return; if (!newContent.trim()) return; const selectedId = UI.getModelValue(); const modelInfo = App.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(); UI.toast(`Loaded ${App.models.length} models`, 'success'); }); // Settings modal document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings); document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings); document.querySelectorAll('.settings-tab').forEach(tab => { tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab)); }); document.getElementById('settingsModel').addEventListener('change', function() { const model = App.findModel(this.value); const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; const hint = document.getElementById('settingsMaxHint'); if (hint) { hint.textContent = caps.max_output_tokens > 0 ? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : ''; } }); document.getElementById('profileChangePwBtn').addEventListener('click', () => { document.getElementById('profileChangePwForm').style.display = ''; }); document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword); // Avatar upload document.getElementById('avatarUploadBtn').addEventListener('click', () => { document.getElementById('avatarFileInput').click(); }); document.getElementById('avatarFileInput').addEventListener('change', async function() { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async () => { try { const data = await API.uploadAvatar(reader.result); if (data.avatar) { API.user.avatar = data.avatar; API.saveTokens(); updateAvatarPreview(data.avatar); UI.updateUser(); UI.toast('Avatar updated'); } } catch (e) { UI.toast(e.message || 'Upload failed', 'error'); } }; reader.readAsDataURL(file); this.value = ''; }); document.getElementById('avatarRemoveBtn').addEventListener('click', async () => { try { await API.deleteAvatar(); API.user.avatar = null; API.saveTokens(); updateAvatarPreview(null); UI.updateUser(); UI.toast('Avatar removed'); } catch (e) { UI.toast(e.message || 'Remove failed', 'error'); } }); document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm); document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm); document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider); document.getElementById('providerType').addEventListener('change', function() { const endpoints = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', venice: 'https://api.venice.ai/api/v1', openrouter: 'https://openrouter.ai/api/v1', }; const ep = document.getElementById('providerEndpoint'); if (!ep.value || Object.values(endpoints).includes(ep.value)) { ep.value = endpoints[this.value] || ''; } }); // Admin modal (elements may not exist for non-admin users) document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin); document.querySelectorAll('.admin-tab').forEach(tab => { tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab)); }); document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings); // Admin — users document.getElementById('adminAddUserBtn')?.addEventListener('click', () => { const f = document.getElementById('adminAddUserForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; }); document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser); // Admin — providers document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => { _editingProviderId = null; document.getElementById('adminCreateProvBtn').textContent = 'Save'; document.getElementById('adminProvKey').placeholder = 'sk-...'; document.getElementById('adminProvName').value = ''; document.getElementById('adminProvEndpoint').value = ''; document.getElementById('adminProvKey').value = ''; document.getElementById('adminProvModel').value = ''; 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)); // Input const input = document.getElementById('messageInput'); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; }); // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(overlay.id); }); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Escape: stop generation → close command palette → close topmost modal if (e.key === 'Escape') { if (App.isGenerating) { stopGeneration(); return; } if (document.getElementById('cmdPalette')?.classList.contains('active')) { closeCmdPalette(); 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 = '