// ========================================== // Chat Switchboard – UI (v0.5.4) // ========================================== const UI = { // ── Sidebar ────────────────────────────── toggleSidebar() { document.getElementById('sidebar').classList.toggle('collapsed'); localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0'); }, restoreSidebar() { if (localStorage.getItem('sb_sidebar') === '1') { document.getElementById('sidebar').classList.add('collapsed'); } }, // ── User Menu Flyout ───────────────────── toggleUserMenu() { const flyout = document.getElementById('userFlyout'); flyout.classList.toggle('open'); }, closeUserMenu() { document.getElementById('userFlyout').classList.remove('open'); }, // ── Chat List ──────────────────────────── renderChatList() { const el = document.getElementById('chatHistory'); if (App.chats.length === 0) { el.innerHTML = ''; return; } // Group by time const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today - 86400000); const weekAgo = new Date(today - 7 * 86400000); const groups = { today: [], yesterday: [], week: [], older: [] }; App.chats.forEach(c => { const d = new Date(c.updatedAt); if (d >= today) groups.today.push(c); else if (d >= yesterday) groups.yesterday.push(c); else if (d >= weekAgo) groups.week.push(c); else groups.older.push(c); }); let html = ''; const renderGroup = (label, chats) => { if (chats.length === 0) return; html += `
${label}
`; chats.forEach(c => { const time = _relativeTime(c.updatedAt); html += `
${esc(c.title)} ${time}
`; }); }; renderGroup('Today', groups.today); renderGroup('Yesterday', groups.yesterday); renderGroup('Previous 7 days', groups.week); renderGroup('Older', groups.older); el.innerHTML = html; }, // ── Messages ───────────────────────────── renderMessages(messages) { const el = document.getElementById('chatMessages'); if (!messages || messages.length === 0) { this.showEmptyState(); return; } el.innerHTML = messages .filter(m => m.role !== 'system') .map((m, i) => this._messageHTML(m, i)) .join(''); this._scrollToBottom(true); }, _messageHTML(msg, index) { const isUser = msg.role === 'user'; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; return `
${isUser ? '👤' : '🤖'}
${isUser ? 'You' : 'Assistant'} ${time ? `${time}` : ''}
${formatMessage(msg.content)}
`; }, showEmptyState() { document.getElementById('chatMessages').innerHTML = `

Chat Switchboard

Select a model and start chatting

`; }, // ── Streaming ──────────────────────────── async streamResponse(response, messages) { const container = document.getElementById('chatMessages'); document.getElementById('typingIndicator')?.remove(); const div = document.createElement('div'); div.className = 'message assistant'; div.innerHTML = `
🤖
Assistant
`; container.appendChild(div); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let content = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) { content += delta; document.getElementById('streamContent').innerHTML = formatMessage(content); this._scrollToBottom(); } } catch (e) { /* partial JSON */ } } } return content; }, // ── Model Selector ─────────────────────── updateModelSelector() { const sel = document.getElementById('modelSelect'); const current = App.settings.model; sel.innerHTML = ''; if (App.models.length === 0) { sel.innerHTML = ''; } else { App.models.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; const label = m.name || m.id; opt.textContent = label + (m.provider ? ` (${m.provider})` : ''); sel.appendChild(opt); }); } if (current) { const exists = [...sel.options].some(o => o.value === current); if (exists) { sel.value = current; } else { const opt = document.createElement('option'); opt.value = current; opt.textContent = current + ' (custom)'; sel.insertBefore(opt, sel.firstChild); sel.value = current; } } // Sync settings modal selector const settingsSel = document.getElementById('settingsModel'); if (settingsSel) { settingsSel.innerHTML = sel.innerHTML; if (current) settingsSel.value = current; } }, // ── Capability Badges ──────────────────── getSelectedModelCaps() { const modelId = document.getElementById('modelSelect').value || App.settings.model; if (!modelId) return {}; const model = App.models.find(m => m.id === modelId); // Model in list has resolved caps; fallback to client-side lookup if (model?.capabilities && Object.keys(model.capabilities).length > 0) { return model.capabilities; } return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {}; }, updateCapabilityBadges() { const el = document.getElementById('modelCaps'); if (!el) return; const caps = this.getSelectedModelCaps(); if (!caps || Object.keys(caps).length === 0) { el.innerHTML = ''; return; } const badges = []; // Output tokens if (caps.max_output_tokens > 0) { const k = caps.max_output_tokens >= 1000 ? (caps.max_output_tokens / 1000).toFixed(0) + 'K' : caps.max_output_tokens; badges.push(`${k} out`); } // Context window if (caps.max_context > 0) { const k = (caps.max_context / 1000).toFixed(0) + 'K'; badges.push(`${k} ctx`); } // Capability flags if (caps.tool_calling) badges.push('🔧 tools'); if (caps.vision) badges.push('👁 vision'); if (caps.thinking) badges.push('💭 thinking'); if (caps.reasoning) badges.push('🧠 reasoning'); if (caps.code_optimized) badges.push('⟨/⟩ code'); if (caps.web_search) badges.push('🔍 search'); el.innerHTML = badges.join(''); }, // ── User / Connection ──────────────────── updateUser() { const name = API.user?.display_name || API.user?.username || 'User'; const letter = (name[0] || '?').toUpperCase(); document.getElementById('avatarLetter').textContent = letter; document.getElementById('userName').textContent = name; }, showAdminButton(show) { document.getElementById('menuAdmin').style.display = show ? '' : 'none'; }, // ── Generating State ───────────────────── setGenerating(on) { document.getElementById('stopBtn').classList.toggle('visible', on); document.getElementById('sendBtn').disabled = on; if (on) { const container = document.getElementById('chatMessages'); const typing = document.createElement('div'); typing.id = 'typingIndicator'; typing.className = 'message assistant'; typing.innerHTML = `
🤖
`; container.appendChild(typing); this._scrollToBottom(true); } else { document.getElementById('typingIndicator')?.remove(); } }, showRegenerate(show) { document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none'; }, // ── Toast ──────────────────────────────── toast(message, type = 'success') { const container = document.getElementById('toastContainer'); const el = document.createElement('div'); el.className = `toast ${type}`; el.innerHTML = `${esc(message)}`; container.appendChild(el); setTimeout(() => el.remove(), 5000); }, // ── Settings Modal ─────────────────────── openSettings() { document.getElementById('settingsModel').value = App.settings.model; document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt; document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || ''; document.getElementById('settingsTemp').value = App.settings.temperature; document.getElementById('settingsThinking').checked = App.settings.showThinking; document.getElementById('profileChangePwForm').style.display = 'none'; // Show model's max output in the hint const caps = this.getSelectedModelCaps(); const hint = document.getElementById('settingsMaxHint'); if (hint) { if (caps.max_output_tokens > 0) { const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K'; hint.textContent = `(model max: ${k})`; } else { hint.textContent = ''; } } UI.loadProfileIntoSettings(); UI.loadProviderList(); document.getElementById('settingsModal').classList.add('active'); }, closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }, async loadProfileIntoSettings() { try { const p = await API.getProfile(); document.getElementById('profileDisplayName').value = p.display_name || ''; document.getElementById('profileEmail').value = p.email || ''; } catch (e) { /* optional */ } }, // ── Providers ──────────────────────────── async loadProviderList() { const el = document.getElementById('providerList'); el.innerHTML = '
Loading...
'; try { const data = await API.listConfigs(); const list = Array.isArray(data) ? data : (data.configs || data.data || []); if (list.length === 0) { el.innerHTML = '
No providers configured.
'; return; } el.innerHTML = list.map(c => `
${esc(c.name)} ${esc(c.provider)} · ${esc(c.model_default || 'no default')}
${c.has_key ? '🔑' : '⚠️'} ${c.user_id ? `` : 'global'}
`).join(''); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, showProviderForm() { document.getElementById('providerAddForm').style.display = ''; }, hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; }, // ── Admin Modal ────────────────────────── openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); }, closeAdmin() { document.getElementById('adminModal').classList.remove('active'); }, async switchAdminTab(tab) { document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none'); const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); if (panel) panel.style.display = ''; if (tab === 'users') await this.loadAdminUsers(); if (tab === 'stats') await this.loadAdminStats(); if (tab === 'providers') await this.loadAdminProviders(); if (tab === 'models') await this.loadAdminModels(); }, async loadAdminUsers() { const el = document.getElementById('adminUserList'); el.innerHTML = '
Loading...
'; try { const resp = await API.adminListUsers(); const users = resp.data || []; el.innerHTML = users.map(u => `
${esc(u.username)} ${u.role} ${!u.is_active ? 'disabled' : ''}
${esc(u.email)}
`).join('') || '
No users
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminStats() { const el = document.getElementById('adminStats'); try { const s = await API.adminGetStats(); el.innerHTML = `
${esc(JSON.stringify(s, null, 2))}
`; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminProviders() { const el = document.getElementById('adminProviderList'); el.innerHTML = '
Loading...
'; try { const data = await API.adminListGlobalConfigs(); const list = data.configs || data.data || data || []; el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
${esc(c.name)}${esc(c.provider)}
`).join('') || '
No global providers
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminModels() { const el = document.getElementById('adminModelList'); el.innerHTML = '
Loading...
'; try { const data = await API.adminListModels(); const list = data.models || data.data || data || []; el.innerHTML = (Array.isArray(list) ? list : []).map(m => { const caps = m.capabilities || {}; const badges = []; if (caps.max_output_tokens > 0) { badges.push(`${(caps.max_output_tokens/1000).toFixed(0)}K out`); } if (caps.tool_calling) badges.push('🔧'); if (caps.vision) badges.push('👁'); if (caps.thinking) badges.push('💭'); if (caps.reasoning) badges.push('🧠'); return `
${esc(m.model_id || m.id)} ${badges.join('')} ${esc(m.provider_name || '')} ${m.is_enabled ? '✅' : '⬜'}
`; }).join('') || '
No models — use Fetch Models to sync from providers
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, // ── Export ──────────────────────────────── exportChat(format) { const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return UI.toast('No chat to export', 'warning'); let content, ext, mime; const safeName = chat.title.replace(/[^a-z0-9]/gi, '_'); if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; } else if (format === 'md') { content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system') .map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n'); ext = 'md'; mime = 'text/markdown'; } else { content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n'); ext = 'txt'; mime = 'text/plain'; } const blob = new Blob([content], { type: mime }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href); }, // ── Message Actions ────────────────────── copyMessage(index) { const chat = App.chats.find(c => c.id === App.currentChatId); if (chat?.messages[index]) { navigator.clipboard.writeText(chat.messages[index].content) .then(() => UI.toast('Copied', 'success')) .catch(() => UI.toast('Copy failed', 'error')); } }, // ── Scroll ─────────────────────────────── _isNearBottom() { const el = document.getElementById('chatMessages'); return el.scrollHeight - el.scrollTop - el.clientHeight < 80; }, _scrollToBottom(force) { if (force || this._isNearBottom()) { const el = document.getElementById('chatMessages'); el.scrollTop = el.scrollHeight; } } }; // ── Formatting ─────────────────────────────── function formatMessage(content) { if (!content) return ''; const thinkingBlocks = []; let text = content; text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { const id = 'think-' + Math.random().toString(36).slice(2, 9); thinkingBlocks.push({ id, content: inner.trim() }); return `THINK_PLACEHOLDER_${id}`; }); let html; if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { html = _formatMarked(text); } else { html = _formatBasic(text); } for (const b of thinkingBlocks) { const inner = esc(b.content).replace(/\n/g, '
'); const thinkHTML = App.settings.showThinking ? `
💭 Thinking
${inner}
` : ''; html = html.replace(new RegExp(`

\\s*THINK_PLACEHOLDER_${b.id}\\s*

`, 'g'), thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); } return html; } function _formatMarked(text) { const rendered = marked.parse(text, { breaks: true, gfm: true }); let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] }); html = html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
        const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
        return `
${code}
`; }); return html; } function _formatBasic(text) { let html = esc(text); html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const id = 'code-' + Math.random().toString(36).slice(2, 9); return `
${code.trim()}
`; }); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/\*([^*]+)\*/g, '$1'); html = html.replace(/\n/g, '
'); return html; } function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ── Helpers ────────────────────────────────── function _relativeTime(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); const now = new Date(); const diff = (now - d) / 1000; if (diff < 60) return 'now'; if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; if (diff < 604800) return `${Math.floor(diff / 86400)}d`; return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); }