// ========================================== // Chat Switchboard – UI // ========================================== // ── Avatar Helper ──────────────────────────── // Returns HTML for a message avatar: if data URI available, emoji fallback. function avatarHTML(role, avatarDataURI) { if (avatarDataURI) { return ``; } return role === 'user' ? '👤' : '🤖'; } // Look up the current model/preset avatar for assistant messages. function assistantAvatarURI(msg) { // Try to find preset avatar from the model that generated this message const modelId = msg?.model || msg?.preset_id; if (modelId) { const m = App.models.find(x => x.id === modelId || x.presetId === modelId); if (m?.presetAvatar) return m.presetAvatar; } return null; } const UI = { // ── Sidebar ────────────────────────────── toggleSidebar() { const sb = document.getElementById('sidebar'); sb.classList.toggle('collapsed'); const collapsed = sb.classList.contains('collapsed'); localStorage.setItem('sb_sidebar', collapsed ? '1' : '0'); const overlay = document.getElementById('sidebarOverlay'); if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none'); }, restoreSidebar() { if (window.innerWidth <= 768 || 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' }) : ''; let assistantLabel = 'Assistant'; if (!isUser) { // Use stored modelName, or resolve from current model list, or fall back to model id if (msg.modelName) { assistantLabel = msg.modelName; } else if (msg.model) { const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model); assistantLabel = m?.name || msg.model; } } // Branch indicator: show ‹ 2/3 › when siblings exist const hasSiblings = (msg.siblingCount || 1) > 1; const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed const sibTotal = msg.siblingCount || 1; const branchNav = hasSiblings ? `
${sibPos}/${sibTotal}
` : ''; // Action buttons const msgId = msg.id ? esc(msg.id) : ''; const editBtn = isUser && msgId ? `` : ''; const regenBtn = !isUser && msgId ? `` : ''; return `
${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}
${isUser ? 'You' : esc(assistantLabel)} ${branchNav} ${time ? `${time}` : ''}
${editBtn} ${regenBtn}
${formatMessage(msg.content)}
`; }, showEmptyState() { document.getElementById('chatMessages').innerHTML = `

Chat Switchboard

Select a model and start chatting

`; }, showEditInline(messageId, currentContent) { const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`); if (!msgEl) return; const textEl = msgEl.querySelector('.msg-text'); if (!textEl) return; // Replace message text with an edit form const escaped = currentContent.replace(/&/g, '&').replace(//g, '>'); textEl.innerHTML = `
`; // Focus and select all const textarea = document.getElementById(`editInput_${messageId}`); if (textarea) { textarea.focus(); textarea.selectionStart = textarea.value.length; // Auto-resize textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; }); // Ctrl+Enter to submit textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitEdit(messageId, textarea.value); } if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); } }); } }, // ── Streaming ──────────────────────────── async streamResponse(response, messages, modelName) { const container = document.getElementById('chatMessages'); document.getElementById('typingIndicator')?.remove(); const label = modelName || 'Assistant'; const currentModel = App.models.find(m => m.id === App.settings.model); const streamAvatar = currentModel?.presetAvatar || null; const div = document.createElement('div'); div.className = 'message assistant'; div.innerHTML = `
${avatarHTML('assistant', streamAvatar)}
${esc(label)}
`; container.appendChild(div); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let content = ''; let currentEvent = ''; // SSE event type 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) { // SSE event type line if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim(); continue; } if (!line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') { currentEvent = ''; continue; } try { const parsed = JSON.parse(data); if (currentEvent === 'tool_use') { // parsed = array of tool calls this._renderToolUse(parsed); currentEvent = ''; continue; } if (currentEvent === 'tool_result') { // parsed = { tool_call_id, name, content, is_error } this._renderToolResult(parsed); currentEvent = ''; continue; } // Normal content delta const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) { content += delta; document.getElementById('streamContent').innerHTML = formatMessage(content); this._scrollToBottom(); } currentEvent = ''; } catch (e) { /* partial JSON */ } } } return content; }, _renderToolUse(toolCalls) { const el = document.getElementById('streamTools'); if (!el) return; el.style.display = ''; for (const tc of toolCalls) { const name = tc.function?.name || tc.name || 'unknown'; const toolDiv = document.createElement('div'); toolDiv.className = 'tool-activity'; toolDiv.id = `tool_${tc.id}`; toolDiv.innerHTML = ` 🔧 ${esc(name)} running…`; el.appendChild(toolDiv); } this._scrollToBottom(); }, _renderToolResult(result) { const el = document.getElementById(`tool_${result.tool_call_id}`); if (el) { const statusEl = el.querySelector('.tool-status'); if (statusEl) { statusEl.textContent = result.is_error ? 'error' : 'done'; statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`; } // Parse tool result content for a brief summary try { const parsed = JSON.parse(result.content); const summary = parsed.title || parsed.id || parsed.count != null ? `${parsed.count} results` : ''; if (summary) { const hint = document.createElement('span'); hint.className = 'tool-hint'; hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary); el.appendChild(hint); } } catch (e) { /* not JSON or no useful summary */ } } this._scrollToBottom(); }, // ── Model Selector (Custom Dropdown) ──── _modelValue: '', getModelValue() { return UI._modelValue || App.settings.model || ''; }, setModelValue(val, label) { UI._modelValue = val; if (val) { App.settings.model = val; saveSettings(); } const btn = document.getElementById('modelDropdownLabel'); if (btn) btn.textContent = label || val || 'Select a model'; // Highlight selected item document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => { el.classList.toggle('selected', el.dataset.value === val); }); UI.updateCapabilityBadges(); }, updateModelSelector() { const menu = document.getElementById('modelDropdownMenu'); if (!menu) return; const current = App.settings.model; menu.innerHTML = ''; if (App.models.length === 0) { menu.innerHTML = '
No models loaded
'; UI.setModelValue('', 'No models loaded'); } else { const presets = App.models.filter(m => m.isPreset); const models = App.models.filter(m => !m.isPreset); if (presets.length > 0) { const hdr = document.createElement('div'); hdr.className = 'model-dropdown-group'; hdr.textContent = '⚡ Presets'; menu.appendChild(hdr); presets.forEach(m => menu.appendChild(UI._createDropdownItem(m))); } if (models.length > 0) { const hdr = document.createElement('div'); hdr.className = 'model-dropdown-group'; hdr.textContent = 'Models'; menu.appendChild(hdr); models.forEach(m => menu.appendChild(UI._createDropdownItem(m))); } // Restore selection const match = App.models.find(m => m.id === current); if (match) { UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); } else if (App.models.length > 0) { const first = App.models[0]; UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : '')); } } // Sync settings modal selector (flat select is fine there) const settingsSel = document.getElementById('settingsModel'); if (settingsSel) { settingsSel.innerHTML = ''; App.models.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); settingsSel.appendChild(opt); }); if (current) settingsSel.value = current; } }, _createDropdownItem(m) { const div = document.createElement('div'); div.className = 'model-dropdown-item'; div.dataset.value = m.id; const avatar = m.presetAvatar ? `` : ''; div.innerHTML = `${avatar}${esc(m.name || m.id)}${m.provider ? `${esc(m.provider)}` : ''}`; div.addEventListener('click', () => { UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : '')); UI._closeModelDropdown(); }); return div; }, _closeModelDropdown() { document.getElementById('modelDropdownMenu')?.classList.remove('open'); }, initModelDropdown() { const btn = document.getElementById('modelDropdownBtn'); const menu = document.getElementById('modelDropdownMenu'); if (!btn || !menu) return; btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); }); document.addEventListener('click', (e) => { if (!e.target.closest('#modelDropdown')) menu.classList.remove('open'); }); }, // ── Capability Badges ──────────────────── getSelectedModelCaps() { const modelId = UI.getModelValue(); 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(); const avatarEl = document.getElementById('userAvatar'); const letterEl = document.getElementById('avatarLetter'); // Show avatar image or initial letter let existingImg = avatarEl.querySelector('.user-avatar-img'); if (API.user?.avatar) { letterEl.style.display = 'none'; if (existingImg) { existingImg.src = API.user.avatar; } else { const img = document.createElement('img'); img.src = API.user.avatar; img.alt = ''; img.className = 'user-avatar-img'; img.onerror = function() { this.remove(); letterEl.style.display = ''; letterEl.textContent = letter; }; avatarEl.insertBefore(img, letterEl); } } else { if (existingImg) existingImg.remove(); letterEl.style.display = ''; letterEl.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 currentModel = App.models.find(m => m.id === App.settings.model); const typingAvatar = currentModel?.presetAvatar || null; const typing = document.createElement('div'); typing.id = 'typingIndicator'; typing.className = 'message assistant'; typing.innerHTML = `
${avatarHTML('assistant', typingAvatar)}
`; container.appendChild(typing); this._scrollToBottom(true); } else { document.getElementById('typingIndicator')?.remove(); } }, showRegenerate(show) { // Bottom-bar regenerate button removed in v0.7.1. // Regen is now per-message via inline buttons. }, // ── 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.switchSettingsTab('general'); openModal('settingsModal'); }, closeSettings() { closeModal('settingsModal'); }, switchSettingsTab(tab) { document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab)); document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none'); const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); if (panel) panel.style.display = ''; if (tab === 'providers') { UI.loadProviderList(); UI.checkUserProvidersAllowed(); } if (tab === 'models') UI.loadUserModels(); if (tab === 'appearance') UI.loadAppearanceSettings(); }, // ── Appearance Settings ───────────────── loadAppearanceSettings() { const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const scale = prefs.scale || 100; const msgFont = prefs.msgFont || 14; const scaleEl = document.getElementById('settingsScale'); const msgFontEl = document.getElementById('settingsMsgFont'); if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; } if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; } }, initAppearance() { // Load saved prefs on startup const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14); // Live preview on slider change const scaleEl = document.getElementById('settingsScale'); const msgFontEl = document.getElementById('settingsMsgFont'); if (scaleEl) scaleEl.addEventListener('input', () => { const v = parseInt(scaleEl.value); document.getElementById('scaleValue').textContent = v + '%'; UI.applyAppearance(v, parseInt(msgFontEl?.value || 14)); }); if (msgFontEl) msgFontEl.addEventListener('input', () => { const v = parseInt(msgFontEl.value); document.getElementById('msgFontValue').textContent = v + 'px'; UI.applyAppearance(parseInt(scaleEl?.value || 100), v); }); }, applyAppearance(scale, msgFont) { const z = scale === 100 ? '' : scale / 100; // Zoom content areas + modals (but NOT .app or banners) document.querySelectorAll('.sidebar, .chat-area, .modal-overlay').forEach(el => el.style.zoom = z); const splash = document.getElementById('splashGate'); if (splash) splash.style.zoom = z; document.documentElement.style.setProperty('--msg-font', msgFont + 'px'); localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont })); // Recheck tab overflow after layout settles with new zoom requestAnimationFrame(() => { document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow); }); }, async checkUserProvidersAllowed() { const notice = document.getElementById('userProvidersDisabled'); const addBtn = document.getElementById('providerShowAddBtn'); if (!notice) return; const allowed = App.serverSettings?.user_providers_enabled !== false; notice.style.display = allowed ? 'none' : ''; if (addBtn) addBtn.style.display = allowed ? '' : 'none'; }, async loadProfileIntoSettings() { try { const p = await API.getProfile(); document.getElementById('profileDisplayName').value = p.display_name || ''; document.getElementById('profileEmail').value = p.email || ''; updateAvatarPreview(p.avatar || null); } 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() { openModal('adminModal'); UI.switchAdminTab('users'); }, closeAdmin() { closeModal('adminModal'); }, 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(); if (tab === 'presets') await this.loadAdminPresets(); if (tab === 'settings') await this.loadAdminSettings(); }, async loadAdminUsers(quiet) { const el = document.getElementById('adminUserList'); if (!quiet) 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 ? 'pending' : ''}
${esc(u.email)}
`).join('') || '
No users
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminStats() { const el = document.getElementById('adminStats'); el.innerHTML = '
Loading...
'; try { const s = await API.adminGetStats(); const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' }; el.innerHTML = '
' + Object.entries(s).map(([k, v]) => `
${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}
${labels[k] || k.replace(/_/g, ' ')}
`).join('') + '
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminProviders(quiet) { const el = document.getElementById('adminProviderList'); if (!quiet) el.innerHTML = '
Loading...
'; try { const data = await API.adminListGlobalConfigs(); const list = data.configs || data.data || data || []; const arr = Array.isArray(list) ? list : []; UI._providerCache = {}; el.innerHTML = arr.map(c => { UI._providerCache[c.id] = c; return `
${esc(c.name)} ${esc(c.provider)} ${esc(c.endpoint || '')}
`; }).join('') || '
No global providers — add one above
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminModels(quiet) { const el = document.getElementById('adminModelList'); if (!quiet) el.innerHTML = '
Loading...
'; try { const data = await API.adminListModels(); const list = data.models || data.data || data || []; const arr = Array.isArray(list) ? list : []; el.innerHTML = arr.map(m => { const caps = m.capabilities || {}; const badges = []; if (caps.max_output_tokens > 0) badges.push(`${(caps.max_output_tokens/1000).toFixed(0)}K`); if (caps.tool_calling) badges.push('🔧'); if (caps.vision) badges.push('👁'); if (caps.thinking) badges.push('💭'); return `
${esc(m.model_id || m.id)} ${badges.join('')} ${esc(m.provider_name || '')}
`; }).join('') || '
No models — add a provider first, then click Fetch Models
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminPresets(quiet) { const el = document.getElementById('adminPresetList'); if (!quiet) el.innerHTML = '
Loading...
'; try { const data = await API.adminListPresets(); const list = data.presets || []; // Populate model dropdown from admin model list (all synced models) const modelSel = document.getElementById('adminPresetModel'); if (modelSel) { modelSel.innerHTML = ''; try { const modelData = await API.adminListModels(); const allModels = modelData.models || modelData.data || []; const arr = Array.isArray(allModels) ? allModels : []; arr.forEach(m => { const opt = document.createElement('option'); const mid = m.model_id || m.id; opt.value = mid; opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : ''); modelSel.appendChild(opt); }); } catch (e) { console.warn('Failed to load models for preset form:', e.message); } } // Populate config dropdown const cfgSel = document.getElementById('adminPresetConfig'); if (cfgSel && cfgSel.options.length <= 1) { try { const cfgData = await API.adminListGlobalConfigs(); const cfgs = cfgData.configs || cfgData.data || []; (Array.isArray(cfgs) ? cfgs : []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.name + ' (' + c.provider + ')'; cfgSel.appendChild(opt); }); } catch (e) { /* optional */ } } UI._presetCache = {}; el.innerHTML = list.map(p => { UI._presetCache[p.id] = p; const avatarEl = p.avatar ? `` : (p.icon ? `${p.icon}` : ''); const scope = p.scope === 'global' ? 'global' : 'personal'; const status = p.is_active ? '' : 'inactive'; return `
${avatarEl}${esc(p.name)} ${scope} ${status}
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
${p.description ? `
${esc(p.description)}
` : ''}
`; }).join('') || '
No presets — create one to give users preconfigured model experiences
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminSettings() { try { const data = await API.adminGetSettings(); const settings = data.settings || data || []; const arr = Array.isArray(settings) ? settings : []; // Unwrap {value: X} wrapper from backend storage const get = (key, fallback) => { const s = arr.find(s => s.key === key); if (!s) return fallback; const v = s.value; return (v && typeof v === 'object' && 'value' in v) ? v.value : v; }; // Registration document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false; // Registration default state document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active'; // User providers document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false; // Banner const banner = get('banner', {}) || {}; document.getElementById('adminBannerEnabled').checked = !!banner.enabled; document.getElementById('adminBannerText').value = banner.text || ''; document.getElementById('adminBannerPosition').value = banner.position || 'both'; document.getElementById('adminBannerBg').value = banner.bg || '#007a33'; document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33'; document.getElementById('adminBannerFg').value = banner.fg || '#ffffff'; document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff'; document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none'; UI.updateBannerPreview(); // Load presets const presets = get('banner_presets', {}) || {}; const sel = document.getElementById('adminBannerPreset'); sel.innerHTML = ''; Object.entries(presets).forEach(([key, p]) => { sel.innerHTML += ``; }); UI._bannerPresets = presets; } catch (e) { console.debug('Failed to load admin settings:', e); } }, _bannerPresets: {}, updateBannerPreview() { const prev = document.getElementById('bannerPreview'); if (!prev) return; const text = document.getElementById('adminBannerText').value || 'PREVIEW'; const bg = document.getElementById('adminBannerBg').value; const fg = document.getElementById('adminBannerFg').value; prev.textContent = text; prev.style.background = bg; prev.style.color = fg; }, // ── User Model List ───────────────────── async loadUserModels() { const el = document.getElementById('userModelList'); if (!el) return; el.innerHTML = '
Loading models...
'; try { const data = await API.listEnabledModels(); const models = data.models || []; if (!models.length) { el.innerHTML = '
No models available — add a provider in the section above
'; return; } el.innerHTML = models.map(m => { const caps = m.capabilities || {}; const badges = []; if (caps.max_output_tokens > 0) badges.push(`${(caps.max_output_tokens/1000).toFixed(0)}K`); if (caps.tool_calling) badges.push('🔧'); if (caps.vision) badges.push('👁'); if (caps.thinking) badges.push('💭'); return `
${esc(m.model_id || m.id)} ${badges.join('')} ${esc(m.provider_name || m.provider || '')}
`; }).join(''); } 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' : (m.modelName || m.model || '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' : (m.modelName || m.model || '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' }); }