// ========================================== // Chat Switchboard – UI Core // ========================================== // UI object definition: sidebar, chat list, messages, streaming, // model selector, capability badges, user, generating state, toast, // settings modal dispatcher, export, message actions, scroll. // Extended by ui-settings.js and ui-admin.js via Object.assign. // ── 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; } // ── Shared Preset Form Component ──────────── // Renders a preset creation/edit form into a container element. // Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel } // Returns: { getValues(), setValues(preset), clearForm(), container } function renderPresetForm(containerEl, options = {}) { const pfx = options.prefix || 'pf'; const showAvatar = options.showAvatar !== false; const showConfig = !!options.showProviderConfig; containerEl.innerHTML = `
${showAvatar ? `
🤖
Optional · 128×128 · shown in chat
` : ''}
${showConfig ? `
` : ''}
`; // Avatar wiring if (showAvatar) { const uploadBtn = document.getElementById(`${pfx}_avatarUploadBtn`); const fileInput = document.getElementById(`${pfx}_avatarFileInput`); const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`); uploadBtn?.addEventListener('click', () => fileInput.click()); fileInput?.addEventListener('change', function() { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => form.updateAvatarPreview(reader.result); reader.readAsDataURL(file); this.value = ''; }); removeBtn?.addEventListener('click', () => form.updateAvatarPreview(null)); } // Cancel wiring document.getElementById(`${pfx}_cancelBtn`)?.addEventListener('click', () => { if (options.onCancel) options.onCancel(); }); // Submit wiring document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => { if (options.onSubmit) options.onSubmit(form.getValues()); }); const form = { getValues() { const v = { name: document.getElementById(`${pfx}_name`)?.value.trim() || '', description: document.getElementById(`${pfx}_desc`)?.value.trim() || '', base_model_id: document.getElementById(`${pfx}_model`)?.value || '', system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '', }; if (showConfig) { const cfgId = document.getElementById(`${pfx}_config`)?.value; if (cfgId) v.provider_config_id = cfgId; } const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value); if (!isNaN(temp)) v.temperature = temp; const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value); if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok; // Pending avatar data URI const img = document.querySelector(`#${pfx}_avatarPreview img`); if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src; return v; }, setValues(p) { const el = id => document.getElementById(`${pfx}_${id}`); if (el('name')) el('name').value = p.name || ''; if (el('desc')) el('desc').value = p.description || ''; if (el('prompt')) el('prompt').value = p.system_prompt || ''; if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : ''; if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : ''; if (el('model')) el('model').value = p.base_model_id || ''; if (showConfig && el('config')) el('config').value = p.provider_config_id || ''; if (showAvatar) form.updateAvatarPreview(p.avatar || null); }, clearForm() { ['name','desc','prompt','temp','maxTokens'].forEach(f => { const el = document.getElementById(`${pfx}_${f}`); if (el) el.value = ''; }); const modelSel = document.getElementById(`${pfx}_model`); if (modelSel) modelSel.selectedIndex = 0; if (showConfig) { const cfgSel = document.getElementById(`${pfx}_config`); if (cfgSel) cfgSel.selectedIndex = 0; } if (showAvatar) form.updateAvatarPreview(null); }, updateAvatarPreview(dataURI) { const preview = document.getElementById(`${pfx}_avatarPreview`); const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`); const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`); if (!preview) return; const existing = preview.querySelector('img'); if (dataURI) { if (placeholder) placeholder.style.display = 'none'; if (existing) { existing.src = dataURI; } else { const img = document.createElement('img'); img.src = dataURI; img.alt = 'Preset avatar'; preview.insertBefore(img, placeholder); } if (removeBtn) removeBtn.style.display = ''; } else { if (existing) existing.remove(); if (placeholder) placeholder.style.display = ''; if (removeBtn) removeBtn.style.display = 'none'; } }, setSubmitLabel(label) { const btn = document.getElementById(`${pfx}_submitBtn`); if (btn) btn.textContent = label; }, getModelSelect() { return document.getElementById(`${pfx}_model`); }, getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; }, }; return form; } // ── Summary Message Helpers ───────────────── function _isSummaryMessage(msg) { if (!msg.metadata) return false; const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; return meta?.type === 'summary'; } function toggleSummarizedHistory() { const el = document.getElementById('summarizedHistory'); const btn = document.querySelector('.message-summary-divider .summary-toggle'); if (!el) return; const show = el.style.display === 'none'; el.style.display = show ? 'block' : 'none'; if (btn) { const count = el.querySelectorAll('.message').length; btn.textContent = show ? `▾ Hide ${count} earlier messages` : `▸ ${count} earlier messages summarized`; } } 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'); const opening = !flyout.classList.contains('open'); flyout.classList.toggle('open'); // On open: refresh team admin button visibility (fire-and-forget) if (opening) { const menuBtn = document.getElementById('menuTeamAdmin'); if (menuBtn) { // Show from cache immediately if available if (UI._myTeams && UI._myTeams.length > 0) { const cached = UI._myTeams.some(t => t.my_role === 'admin'); menuBtn.style.display = cached ? '' : 'none'; } // Refresh in background API.listMyTeams().then(resp => { const teams = resp.data || []; UI._myTeams = teams; menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none'; }).catch(() => {}); } } }, closeUserMenu() { document.getElementById('userFlyout').classList.remove('open'); }, // ── Chat List ──────────────────────────── renderChatList() { const el = document.getElementById('chatHistory'); const searchInput = document.getElementById('chatSearchInput'); const searchWrap = document.getElementById('sidebarSearch'); const query = (searchInput?.value || '').trim().toLowerCase(); // Toggle clear button visibility if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0); // Filter chats by search query let chats = App.chats; if (query) { chats = chats.filter(c => (c.title || '').toLowerCase().includes(query) ); } if (chats.length === 0 && query) { el.innerHTML = ``; return; } if (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: [] }; 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; } // Find the most recent summary boundary let summaryIdx = -1; for (let i = 0; i < messages.length; i++) { if (_isSummaryMessage(messages[i])) summaryIdx = i; } // Render: if summary exists, show collapsed-history toggle + summary + messages after let html = ''; if (summaryIdx >= 0) { const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length; if (hiddenCount > 0) { html += `
`; } // Render summary node html += this._summaryHTML(messages[summaryIdx]); // Render messages after summary html += messages.slice(summaryIdx + 1) .filter(m => m.role !== 'system' && !_isSummaryMessage(m)) .map((m, i) => this._messageHTML(m, summaryIdx + 1 + i)) .join(''); } else { html = messages .filter(m => m.role !== 'system') .map((m, i) => this._messageHTML(m, i)) .join(''); } el.innerHTML = html; if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el); if (typeof loadAuthImages === 'function') loadAuthImages(el); this._scrollToBottom(true); }, _summaryHTML(msg) { const meta = msg.metadata || {}; const count = meta.summarized_count || '?'; const model = meta.utility_model || 'utility model'; const trigger = meta.trigger === 'auto' ? ' · auto' : ''; return `
📝 Conversation summary (${count} messages, by ${esc(model)}${trigger})
${formatMessage(msg.content)}
`; }, _messageHTML(msg, index, dimmed) { const isUser = msg.role === 'user'; // Skip summary messages in normal rendering — they get _summaryHTML if (_isSummaryMessage(msg)) return ''; 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}
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
${formatMessage(msg.content)}
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
`; }, 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.findModel(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 reasoning = ''; 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; } // Reasoning content delta (thinking blocks) const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || ''; if (reasoningDelta) { reasoning += reasoningDelta; // Render accumulated reasoning + content together const full = (reasoning ? '' + reasoning + '' : '') + content; document.getElementById('streamContent').innerHTML = formatMessage(full); this._scrollToBottom(); } // Normal content delta const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) { content += delta; const full = (reasoning ? '' + reasoning + '' : '') + content; document.getElementById('streamContent').innerHTML = formatMessage(full); 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); let summary = ''; if (parsed.title) summary = parsed.title; else if (parsed.count != null) summary = `${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); } // Add "View note" link for note tools if (result.name && result.name.startsWith('note_') && parsed.id) { const link = document.createElement('button'); link.className = 'tool-note-link'; link.textContent = '📝 View note'; link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); }; el.appendChild(link); } } 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(); if (typeof updateContextWarning === 'function') updateContextWarning(); if (typeof updateInputTokens === 'function') updateInputTokens(); }, 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 globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global'); const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team'); const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal'); const models = App.models.filter(m => !m.isPreset && !m.hidden); const addGroup = (label, items) => { if (items.length === 0) return; const hdr = document.createElement('div'); hdr.className = 'model-dropdown-group'; hdr.textContent = label; menu.appendChild(hdr); items.forEach(m => menu.appendChild(UI._createDropdownItem(m))); }; addGroup('⚡ Presets', globalPresets); // Group team presets by team name const teamGroups = {}; teamPresets.forEach(m => { const tn = m.presetTeamName || 'Team'; (teamGroups[tn] = teamGroups[tn] || []).push(m); }); Object.keys(teamGroups).sort().forEach(tn => { addGroup(`👥 ${tn}`, teamGroups[tn]); }); addGroup('🔧 My Presets', personalPresets); // No team model groups — team providers are for preset building only. // Team members access team models through curated presets. addGroup('Models', models.filter(m => m.source !== 'personal')); addGroup('🔑 My Providers', models.filter(m => m.source === 'personal')); // Restore selection — resolution chain: localStorage → admin default → first visible const visibleModels = App.models.filter(m => !m.hidden); const match = visibleModels.find(m => m.id === current || m.baseModelId === current); if (match) { UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); } else if (App.defaultModel && visibleModels.length > 0) { // Admin-configured default const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel); if (def) { UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : '')); } else { const first = visibleModels[0]; UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : '')); } } else if (visibleModels.length > 0) { const first = visibleModels[0]; UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : '')); } else { UI.setModelValue('', 'No visible models'); } } // Sync settings modal selector (flat select is fine there) const settingsSel = document.getElementById('settingsModel'); if (settingsSel) { settingsSel.innerHTML = ''; App.models.filter(m => !m.hidden).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 ? `` : ''; const teamBadge = m.source === 'team' && m.teamName ? `👥 ${esc(m.teamName)}` : ''; div.innerHTML = `${avatar}${esc(m.name || m.id)}${teamBadge}${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.findModel(modelId); // Model in list has resolved caps from backend (catalog → heuristic) return model?.capabilities || {}; }, updateCapabilityBadges() { const el = document.getElementById('modelCaps'); if (!el) return; const caps = this.getSelectedModelCaps(); if (!caps || Object.keys(caps).length === 0) { el.innerHTML = ''; return; } el.innerHTML = renderCapBadges(caps); }, // ── 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; // Swap favicon to animated version during generation const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]'); if (faviconLink) { if (on) { // Pulsing favicon: dots fade in/out rapidly faviconLink._origHref = faviconLink._origHref || faviconLink.href; const svg = ` `; faviconLink.href = 'data:image/svg+xml,' + svg; } else if (faviconLink._origHref) { faviconLink.href = faviconLink._origHref; } } if (on) { const container = document.getElementById('chatMessages'); const currentModel = App.findModel(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.loadMyTeams(); UI.checkUserProvidersAllowed(); UI.checkUserPresetsAllowed(); 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 === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); } if (tab === 'usage') UI.loadMyUsage(); if (tab === 'roles') UI.loadUserRoles(); if (tab === 'knowledgeBases') { if (typeof KnowledgeUI === 'undefined') { console.error('[Settings] KnowledgeUI not loaded — check js/knowledge-ui.js'); const c = document.getElementById('kbManagePanel'); if (c) c.innerHTML = '
Knowledge UI failed to load. Check browser console.
'; } else { KnowledgeUI.openManagePanel(); } } if (tab === 'appearance') UI.loadAppearanceSettings(); }, // ── 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; } } };