// ========================================== // 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' ? '👤' : role === 'other-user' ? '👤' : '🤖'; } // Look up the current model/persona avatar for assistant messages. function assistantAvatarURI(msg) { // 1. Check channel model roster for persona avatar (v0.23.0) if (typeof ChannelModels !== 'undefined' && msg?.model) { const info = ChannelModels.resolvePersonaInfo(msg.model); if (info?.avatar) return info.avatar; } // 2. Check participant_id directly (for chained messages) if (msg?.participant_type === 'persona' && msg?.participant_id) { const m = App.models.find(x => x.personaId === msg.participant_id); if (m?.personaAvatar) return m.personaAvatar; } // 3. Standard lookup by model ID or persona ID const modelId = msg?.model || msg?.persona_id; if (modelId) { const m = App.models.find(x => x.id === modelId || x.personaId === modelId); if (m?.personaAvatar) return m.personaAvatar; } return null; } // ── Shared Persona Form Component ──────────── // Renders a persona creation/edit form into a container element. // Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel } // Returns: { getValues(), setValues(persona), clearForm(), container } function renderPersonaForm(containerEl, options = {}) { const pfx = options.prefix || 'pf'; const showAvatar = options.showAvatar !== false; const showConfig = !!options.showProviderConfig; containerEl.innerHTML = `
Auto-generated from name. Used for @mentions in chat.
${showAvatar ? `
🤖
Optional · 128×128 · shown in chat
` : ''}
${showConfig ? `
` : ''}

When enabled, the AI can save and recall facts about users across conversations.

When unchecked, restrict this persona to only the selected tools below.

`; // 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(); }); // Auto-generate handle from name let _handleManuallyEdited = false; document.getElementById(`${pfx}_handle`)?.addEventListener('input', () => { _handleManuallyEdited = true; }); document.getElementById(`${pfx}_name`)?.addEventListener('input', () => { if (_handleManuallyEdited) return; const name = document.getElementById(`${pfx}_name`)?.value || ''; const handle = name.toLowerCase().trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 50); const hEl = document.getElementById(`${pfx}_handle`); if (hEl) hEl.value = handle; }); // Submit wiring document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => { if (options.onSubmit) options.onSubmit(form.getValues()); }); // v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list const toolsAllCb = document.getElementById(`${pfx}_toolsAll`); const toolsListEl = document.getElementById(`${pfx}_toolsList`); if (toolsAllCb && toolsListEl) { toolsAllCb.addEventListener('change', () => { toolsListEl.style.display = toolsAllCb.checked ? 'none' : ''; if (!toolsAllCb.checked && toolsListEl.children.length === 0) { form.loadToolList(); } }); } const form = { getValues() { const v = { name: document.getElementById(`${pfx}_name`)?.value.trim() || '', handle: document.getElementById(`${pfx}_handle`)?.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; // Memory fields (v0.18.0) const memCb = document.getElementById(`${pfx}_memoryEnabled`); if (memCb) v.memory_enabled = memCb.checked; const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim(); if (memPrompt) v.memory_extraction_prompt = memPrompt; // v0.25.0: Tool grants const toolsAllCb = document.getElementById(`${pfx}_toolsAll`); if (toolsAllCb && !toolsAllCb.checked) { const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`); v.tool_grants = Array.from(checked).map(cb => cb.value); } else { v.tool_grants = []; // empty = inherit all } return v; }, setValues(p) { const el = id => document.getElementById(`${pfx}_${id}`); if (el('name')) el('name').value = p.name || ''; if (el('handle')) { el('handle').value = p.handle || ''; _handleManuallyEdited = !!p.handle; } 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); // Memory fields (v0.18.0) if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false; if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || ''; // v0.25.0: Tool grants — load and apply if (p.id) form.loadToolGrants(p.id); }, clearForm() { ['name','handle','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 = 'Persona 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; }, // v0.25.0: Tool grants management async loadToolList() { const listEl = document.getElementById(`${pfx}_toolsList`); if (!listEl) return; try { const resp = await API._get('/api/v1/tools'); const tools = resp.tools || resp.data || resp || []; listEl.innerHTML = ''; // Group by category const groups = {}; tools.forEach(t => { const cat = t.category || 'other'; (groups[cat] = groups[cat] || []).push(t); }); Object.keys(groups).sort().forEach(cat => { const hdr = document.createElement('div'); hdr.className = 'tool-grants-category'; hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1); listEl.appendChild(hdr); groups[cat].forEach(t => { const label = document.createElement('label'); label.className = 'tool-grants-item'; label.innerHTML = ` ${t.display_name || t.name}`; listEl.appendChild(label); }); }); } catch (e) { listEl.innerHTML = '
Failed to load tools
'; } }, async loadToolGrants(personaId) { const toolsAllCb = document.getElementById(`${pfx}_toolsAll`); const listEl = document.getElementById(`${pfx}_toolsList`); if (!toolsAllCb || !listEl) return; try { const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`); const grants = resp.data || []; if (grants.length === 0) { toolsAllCb.checked = true; listEl.style.display = 'none'; } else { toolsAllCb.checked = false; listEl.style.display = ''; await form.loadToolList(); const grantSet = new Set(grants); listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = grantSet.has(cb.value); }); } } catch (_) { // Endpoint may not exist yet — default to all tools toolsAllCb.checked = true; listEl.style.display = 'none'; } }, }; 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() { const el = document.getElementById('sidebar'); if (!el) return; // not all surfaces have a sidebar (e.g. editor) if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') { el.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() { const el = document.getElementById('userFlyout'); if (el) el.classList.remove('open'); }, // ── Sidebar Section Collapse ────────────── // State persisted in localStorage per section key. _sidebarSections: null, _getSidebarSections() { if (!this._sidebarSections) { try { this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}'); } catch (_) { this._sidebarSections = {}; } } return this._sidebarSections; }, toggleSidebarSection(key) { const sections = this._getSidebarSections(); sections[key] = !sections[key]; // true = collapsed try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {} this._applySidebarSection(key, sections[key]); }, _applySidebarSection(key, collapsed) { const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1)); const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1)); if (body) body.style.display = collapsed ? 'none' : ''; if (arrow) arrow.textContent = collapsed ? '▸' : '▾'; }, restoreSidebarSections() { const sections = this._getSidebarSections(); ['projects', 'channels', 'chats'].forEach(k => { this._applySidebarSection(k, !!sections[k]); }); }, // ── Projects Section ────────────────────── renderProjectsSection() { const el = document.getElementById('sbBodyProjects'); if (!el) return; const projects = App.projects || []; const showArchived = App.showArchivedProjects || false; const visible = showArchived ? projects : projects.filter(p => !p.isArchived); const hasArchived = projects.some(p => p.isArchived); const sidebar = document.getElementById('sidebar'); const collapsed = sidebar?.classList.contains('collapsed'); if (visible.length === 0 && !hasArchived) { el.innerHTML = collapsed ? '' : '
No projects yet
'; return; } let html = ''; visible.forEach(proj => { const isCollapsed = App.collapsedProjects?.[proj.id]; const isActive = App.activeProjectId === proj.id; const dot = proj.color ? `` : ''; const arrow = isCollapsed ? '▸' : '▾'; const pinIcon = isActive ? '📍' : ''; const chats = (App.chats || []).filter(c => c.projectId === proj.id); html += `
${collapsed ? dot : `${arrow}${dot}${esc(proj.name)}${pinIcon}`} ${!collapsed ? `${chats.length} ` : ''}
`; if (!isCollapsed && !collapsed) { if (chats.length === 0) { html += '
Drop chats here
'; } else { chats.forEach(c => { html += this._chatItemHTML(c); }); } } html += '
'; }); if (hasArchived && !collapsed) { html += ``; } el.innerHTML = html; }, // ── Channels Section ────────────────────── renderChannelsSection() { const el = document.getElementById('sbBodyChannels'); if (!el) return; const channels = App.channels || []; const sidebar = document.getElementById('sidebar'); const collapsed = sidebar?.classList.contains('collapsed'); if (channels.length === 0) { el.innerHTML = collapsed ? '' : '
No channels yet
'; return; } let html = ''; channels.forEach(ch => { const isActive = App.activeId === ch.id; const isDM = ch.type === 'dm'; const isChannel = ch.type === 'channel'; const online = App.presence?.[ch.dmPartnerId] === 'online'; // Icon: @ for DM, # for channel const icon = isDM ? `` : ``; const onlineDot = isDM && online ? '' : ''; const unreadBadge = ch.unread > 0 ? `${ch.unread > 99 ? '99+' : ch.unread}` : ''; html += `
${icon} ${!collapsed ? `${esc(ch.name)}${onlineDot}${unreadBadge} ` : unreadBadge}
`; }); el.innerHTML = html; }, // ── Chats Section (renderChatList) ──────── renderChatList() { // Target the new section body; fall back to legacy id during transition. const el = document.getElementById('sbBodyChats') || document.getElementById('chatHistory'); if (!el) return; const searchInput = document.getElementById('chatSearchInput'); const searchWrap = document.getElementById('sidebarSearch'); const query = (searchInput?.value || '').trim().toLowerCase(); if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0); // Chats section shows only direct/group chats not in a project. // type='channel' and type='dm' belong to the Channels section (App.channels). // type='workflow' belongs to the Queue section (WorkflowQueue). // Project chats rendered by renderProjectsSection. let chats = (App.chats || []).filter(c => !c.projectId && c.type !== 'channel' && c.type !== 'dm' && c.type !== 'workflow' ); if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query)); const folders = App.folders || []; if (chats.length === 0 && folders.length === 0) { el.innerHTML = query ? `
No chats matching "${esc(query)}"
` : '
No chats yet
'; // Still re-render projects in case search is cross-cutting this.renderProjectsSection(); return; } // Group by folder, then by time for unfiled const folderMap = {}; folders.forEach(f => { folderMap[f.id] = []; }); const unfiled = []; chats.forEach(c => { if (c.folderId && folderMap[c.folderId]) { folderMap[c.folderId].push(c); } else { unfiled.push(c); } }); let html = ''; // Folder groups — always render, even when empty (with drag handlers) folders.forEach(f => { const items = folderMap[f.id] || []; const isOpen = !App.collapsedFolders?.[f.id]; html += `
${esc(f.name)} ${isOpen ? '▾' : '▸'}
${isOpen ? (items.length > 0 ? items.map(c => this._chatItemHTML(c, true)).join('') : '
Drop chats here
') : ''}
`; }); // Unfiled chats grouped by time const now = Date.now(); const DAY = 86400000; const groups = { today: [], yesterday: [], week: [], older: [] }; unfiled.forEach(c => { const age = now - new Date(c.updatedAt).getTime(); if (age < DAY) groups.today.push(c); else if (age < 2*DAY) groups.yesterday.push(c); else if (age < 7*DAY) groups.week.push(c); else groups.older.push(c); }); // When folders exist, wrap unfiled chats in a drop zone if (folders.length > 0) { html += `
`; if (unfiled.length === 0) { html += '
Drop here to unfile
'; } } const renderTimeGroup = (label, items) => { if (!items.length) return; html += `
${label}
`; items.forEach(c => { html += this._chatItemHTML(c); }); }; renderTimeGroup('Today', groups.today); renderTimeGroup('Yesterday', groups.yesterday); renderTimeGroup('Previous 7 days', groups.week); renderTimeGroup('Older', groups.older); if (folders.length > 0) { html += '
'; // close .sb-unfiled-zone } el.innerHTML = html; // Always keep projects section in sync this.renderProjectsSection(); // Refresh workflow queue section (workflow channels filtered out of chat list) if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh(); }, toggleFolder(folderId) { if (!App.collapsedFolders) App.collapsedFolders = {}; App.collapsedFolders[folderId] = !App.collapsedFolders[folderId]; this.renderChatList(); }, _chatItemHTML(c, indented = false) { const time = _relativeTime(c.updatedAt); const active = c.id === App.activeId ? ' active' : ''; const indent = indented ? ' indented' : ''; const typeIcon = c.type === 'group' ? '👥 ' : c.type === 'workflow' ? ' ' : ''; return `
${typeIcon}${esc(c.title)} ${time}
`; }, // ── 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' }) : ''; // v0.23.2: Sender attribution — distinguish self from other humans const isSelf = !isUser ? false : (!msg.participant_id || msg.participant_id === API.user?.id); const isOtherUser = isUser && !isSelf; let senderLabel = 'You'; let senderAvatarURI = API.user?.avatar || null; if (isOtherUser) { senderLabel = msg.sender_name || 'User'; senderAvatarURI = msg.sender_avatar || null; } else if (!isUser) { // Assistant message — use model/persona label senderLabel = 'Assistant'; senderAvatarURI = assistantAvatarURI(msg); if (msg.sender_name) { senderLabel = msg.sender_name; } else if (msg.modelName) { senderLabel = msg.modelName; } else if (typeof ChannelModels !== 'undefined' && msg.model) { senderLabel = ChannelModels.resolveDisplayName(msg.model); } else if (msg.model) { const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model); senderLabel = m?.name || msg.model; } if (msg.sender_avatar) { senderAvatarURI = msg.sender_avatar; } } // CSS class: self = right-aligned "user", other humans = left-aligned "other-user", AI = "assistant" const roleClass = isSelf ? 'user' : isOtherUser ? 'other-user' : msg.role; // 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 = isSelf && msgId ? `` : ''; const regenBtn = !isUser && msgId ? `` : ''; return `
${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}
${esc(senderLabel)} ${branchNav} ${time ? `${time}` : ''}
${editBtn} ${regenBtn}
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
${formatMessage(msg.content)}
${typeof renderMessageFiles === 'function' ? renderMessageFiles(msgId) : ''}
`; }, showEmptyState() { var base = window.__BASE__ || ''; document.getElementById('chatMessages').innerHTML = `

Chat Switchboard

Select a model and start chatting

`; this.updateContextBanner(); }, // v0.23.2: Show/hide context banner based on active conversation type updateContextBanner() { const el = document.getElementById('channelContextBanner'); if (!el) return; const ac = App.activeConversation; if (!ac || ac.type === 'direct') { el.style.display = 'none'; this.updateWorkflowStageBar(); return; } let html = ''; if (ac.type === 'dm') { const ch = (App.channels || []).find(c => c.id === ac.id); const name = ch?.name || 'someone'; html = `DM` + `with ${esc(name)}` + `@mention a persona to bring in AI` + `` + ``; } else if (ac.type === 'group') { html = `Group` + `No @mention = leader responds · @mention to route · @all for everyone` + `` + ``; } else if (ac.type === 'channel') { const ch = (App.channels || []).find(c => c.id === ac.id); const mode = ch?.aiMode || 'auto'; const topic = ch?.topic || ''; const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off'; html = ``; if (topic) html += `${esc(topic)}`; html += ``; html += ``; } el.innerHTML = html; el.style.display = ''; this.updateWorkflowStageBar(); }, // Fetches workflow status and renders stage dots, name, advance/reject buttons. updateWorkflowStageBar() { const el = document.getElementById('workflowStageBar'); if (!el) return; const ac = App.activeConversation; if (!ac || ac.type !== 'workflow') { el.style.display = 'none'; return; } // Fetch status (async, non-blocking — renders on completion) const base = window.__BASE__ || ''; API._get(`/api/v1/channels/${ac.id}/workflow/status`).then(ws => { if (!ws || !ws.workflow_id) { el.style.display = 'none'; return; } // Cache on App for WS event handler access App._workflowStatus = ws; // Load stage definitions for name display API._get(`/api/v1/workflows/${ws.workflow_id}`).then(wf => { const stages = wf.stages || []; const current = ws.current_stage || 0; const status = ws.status || 'active'; const totalStages = stages.length || 1; const stageName = stages[current] ? stages[current].name : 'Stage ' + current; // Step dots let dots = ''; for (let i = 0; i < totalStages; i++) { const cls = i < current ? 'completed' : i === current ? 'current' : ''; dots += '
'; } // Status badge const statusCls = status === 'completed' ? 'completed' : status === 'stale' ? 'stale' : 'active'; const statusLabel = status.charAt(0).toUpperCase() + status.slice(1); // Advance/reject buttons (only for active workflows, admin or team members) let actions = ''; if (status === 'active' && API.isAdmin) { actions = '' + (current > 0 ? '' : ''); } el.innerHTML = '
' + dots + '
' + '' + esc(stageName) + '' + '' + esc(statusLabel) + '' + '' + 'Step ' + (current + 1) + ' of ' + totalStages + '' + (actions ? '
' + actions + '
' : ''); el.style.display = ''; }).catch(() => { el.style.display = 'none'; }); }).catch(() => { el.style.display = 'none'; }); }, 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); // Resolve avatar: channel roster persona → selected model → null let streamAvatar = null; if (typeof ChannelModels !== 'undefined') { const info = ChannelModels.resolvePersonaInfo(currentModel?.baseModelId); if (info?.avatar) streamAvatar = info.avatar; } if (!streamAvatar) { streamAvatar = currentModel?.personaAvatar || null; } // Multi-model state: when model_start events arrive, we create // separate message bubbles per model. Without them, single-bubble. let multiModel = false; let activeContentEl = null; let activeToolsEl = null; let content = ''; let reasoning = ''; // Create initial single-model bubble (replaced if multi-model) const _createBubble = (bubbleLabel) => { const div = document.createElement('div'); div.className = 'message assistant'; div.innerHTML = `
${avatarHTML('assistant', streamAvatar)}
${esc(bubbleLabel)}
`; container.appendChild(div); activeContentEl = div.querySelector('.msg-text'); activeToolsEl = div.querySelector('.msg-tools'); return div; }; _createBubble(label); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; 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); // ── Multi-model: model_start creates a new bubble ── if (currentEvent === 'model_start') { if (!multiModel) { // First model_start: remove the initial empty bubble multiModel = true; container.lastElementChild?.remove(); } content = ''; reasoning = ''; const dn = parsed.display_name || parsed.model_id || 'Model'; _createBubble(dn); currentEvent = ''; continue; } if (currentEvent === 'model_end') { currentEvent = ''; continue; } if (currentEvent === 'tool_use') { this._renderToolUseInEl(activeToolsEl, parsed); currentEvent = ''; continue; } if (currentEvent === 'tool_result') { this._renderToolResultInEl(activeToolsEl, parsed); currentEvent = ''; continue; } // Reasoning content delta (thinking blocks) const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || ''; if (reasoningDelta) { reasoning += reasoningDelta; const full = (reasoning ? '' + reasoning + '' : '') + content; if (activeContentEl) activeContentEl.innerHTML = formatMessage(full); this._scrollToBottom(); } // Normal content delta const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) { content += delta; const full = (reasoning ? '' + reasoning + '' : '') + content; if (activeContentEl) activeContentEl.innerHTML = formatMessage(full); this._scrollToBottom(); // Live-update preview panel if open (debounced) if (typeof _livePreviewUpdate === 'function') { _livePreviewUpdate(content); } } currentEvent = ''; } catch (e) { /* partial JSON */ } } } return content; }, // Tool rendering helpers that take explicit element refs (for multi-model) _renderToolUseInEl(toolsEl, toolCalls) { if (!toolsEl) { // Fallback to id-based (single model compat) this._renderToolUse(toolCalls); return; } toolsEl.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.dataset.toolId = tc.id; toolDiv.innerHTML = ` 🔧 ${esc(name)} running…`; toolsEl.appendChild(toolDiv); } this._scrollToBottom(); }, _renderToolResultInEl(toolsEl, result) { if (!toolsEl) { this._renderToolResult(result); return; } const el = toolsEl.querySelector(`[data-tool-id="${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'}`; } } }, _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.dataset.toolId = tc.id; toolDiv.innerHTML = ` 🔧 ${esc(name)} running…`; el.appendChild(toolDiv); } this._scrollToBottom(); }, _renderToolResult(result) { // Search all tool-activity elements by data-tool-id const el = document.querySelector(`[data-tool-id="${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 globalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'global'); const teamPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'team'); const personalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'personal'); const models = App.models.filter(m => !m.isPersona && !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('⚡ Personas', globalPersonas); // Group team personas by team name const teamGroups = {}; teamPersonas.forEach(m => { const tn = m.personaTeamName || 'Team'; (teamGroups[tn] = teamGroups[tn] || []).push(m); }); Object.keys(teamGroups).sort().forEach(tn => { addGroup(`👥 ${tn}`, teamGroups[tn]); }); addGroup('🔧 My Personas', personalPersonas); // No team model groups — team providers are for persona building only. // Team members access team models through curated personas. 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.personaAvatar ? `` : ''; const handleHint = m.isPersona && m.personaHandle ? `@${esc(m.personaHandle)}` : ''; const teamBadge = m.source === 'team' && m.teamName ? `👥 ${esc(m.teamName)}` : ''; div.innerHTML = `${avatar}${esc(m.name || m.id)}${handleHint}${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'); if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM // 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; } const nameEl = document.getElementById('userName'); if (nameEl) nameEl.textContent = name; }, showAdminButton(show) { const adminEl = document.getElementById('menuAdmin'); if (adminEl) adminEl.style.display = show ? '' : 'none'; var dbg = document.getElementById('menuDebug'); if (dbg) dbg.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?.personaAvatar || 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(section) { const base = window.__BASE__ || ''; window.location.href = base + '/settings/' + (section || 'general'); }, closeSettings() { const base = window.__BASE__ || ''; const returnURL = sessionStorage.getItem('sb_settings_return'); sessionStorage.removeItem('sb_settings_return'); window.location.href = returnURL || (base + '/'); }, 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.loadUserPersonas(); UI.checkUserPersonasAllowed(); } 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(); if (tab === 'memory') { if (typeof MemoryUI !== 'undefined') { MemoryUI.openSettingsPanel(); } else { const c = document.getElementById('settingsMemoryContent'); if (c) c.innerHTML = '
Memory UI failed to load.
'; } } if (tab === 'notifPrefs') { if (typeof NotifPrefs !== 'undefined') { NotifPrefs.load(); } } }, // ── Export ──────────────────────────────── exportChat(format) { const chat = App.getActiveChat(); 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.getActiveChat(); 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; } }, // ── Appearance (scale + font) ──────────── // Shared across ALL surfaces. Previously in ui-settings.js // which only loaded on chat/settings — editor surface was skipped. applyAppearance(scale, msgFont) { // Scale #surfaceInner — the generic wrapper injected by base.html // around every surface template. No surface-specific selectors. // #surface stays as flex child, banners are siblings — untouched. const inner = document.getElementById('surfaceInner'); if (inner) { if (scale === 100) { inner.style.transform = ''; inner.style.width = ''; inner.style.height = ''; } else { const z = scale / 100; inner.style.transform = `scale(${z})`; inner.style.width = (100 / z) + '%'; inner.style.height = (100 / z) + '%'; } } document.documentElement.style.setProperty('--msg-font', msgFont + 'px'); localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont })); requestAnimationFrame(() => { document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); }); }); }, restoreAppearance() { // Clear stale zoom/transform from any previous scaling approaches document.body.style.zoom = ''; document.documentElement.style.zoom = ''; try { const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const scale = prefs.scale || 100; const msgFont = prefs.msgFont || 14; if (scale !== 100 || msgFont !== 14) { UI.applyAppearance(scale, msgFont); } else { document.documentElement.style.setProperty('--msg-font', msgFont + 'px'); } } catch (_) { /* corrupt localStorage — ignore */ } } };