1596 lines
74 KiB
JavaScript
1596 lines
74 KiB
JavaScript
// ==========================================
|
||
// 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: <img> if data URI available, emoji fallback.
|
||
function avatarHTML(role, avatarDataURI) {
|
||
if (avatarDataURI) {
|
||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||
}
|
||
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 = `
|
||
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
|
||
<div class="form-group"><label>@mention Handle</label><input type="text" id="${pfx}_handle" placeholder="code-reviewer" maxlength="50" class="mono-input"><div class="form-hint">Auto-generated from name. Used for @mentions in chat.</div></div>
|
||
${showAvatar ? `
|
||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
||
<span id="${pfx}_avatarPlaceholder">🤖</span>
|
||
</div>
|
||
<div class="avatar-actions">
|
||
<button class="btn-small" type="button" id="${pfx}_avatarUploadBtn">Upload Avatar</button>
|
||
<button class="btn-small btn-danger" type="button" id="${pfx}_avatarRemoveBtn" style="display:none">Remove</button>
|
||
<input type="file" id="${pfx}_avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||
<div class="form-hint">Optional · 128×128 · shown in chat</div>
|
||
</div>
|
||
</div>` : ''}
|
||
<div class="form-group"><label>Description</label><input type="text" id="${pfx}_desc" placeholder="Reviews code for best practices"></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label>Base Model</label><select id="${pfx}_model"></select></div>
|
||
${showConfig ? `<div class="form-group"><label>Provider Config</label><select id="${pfx}_config"><option value="">Auto-resolve</option></select></div>` : ''}
|
||
</div>
|
||
<div class="form-group"><label>System Prompt</label><textarea id="${pfx}_prompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
|
||
</div>
|
||
<div class="form-group memory-persona-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_memoryEnabled" checked> Enable Memory</label>
|
||
<p class="form-hint" style="margin:2px 0 6px">When enabled, the AI can save and recall facts about users across conversations.</p>
|
||
<div id="${pfx}_memoryExtractionPromptWrap">
|
||
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
|
||
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
|
||
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
|
||
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
|
||
</div>
|
||
<div class="form-row">
|
||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
|
||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
|
||
listEl.appendChild(label);
|
||
});
|
||
});
|
||
} catch (e) {
|
||
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
|
||
}
|
||
},
|
||
|
||
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 ? '' :
|
||
'<div class="sb-section-empty">No projects yet</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
visible.forEach(proj => {
|
||
const isCollapsed = App.collapsedProjects?.[proj.id];
|
||
const isActive = App.activeProjectId === proj.id;
|
||
const dot = proj.color
|
||
? `<span class="sb-proj-dot" style="background:${esc(proj.color)}"></span>`
|
||
: '<span class="sb-proj-dot"></span>';
|
||
const arrow = isCollapsed ? '▸' : '▾';
|
||
const pinIcon = isActive ? '<span class="sb-proj-pin" title="Pinned for new chats">📍</span>' : '';
|
||
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
|
||
|
||
html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
|
||
ondragover="onProjectDragOver(event)"
|
||
ondragleave="onProjectDragLeave(event)"
|
||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||
<div class="sb-proj-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||
${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`}
|
||
${!collapsed ? `<span class="sb-proj-count">${chats.length}</span>
|
||
<button class="sb-proj-menu" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Options">⋯</button>` : ''}
|
||
</div>`;
|
||
|
||
if (!isCollapsed && !collapsed) {
|
||
if (chats.length === 0) {
|
||
html += '<div class="sb-proj-empty">Drop chats here</div>';
|
||
} else {
|
||
chats.forEach(c => { html += this._chatItemHTML(c); });
|
||
}
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
if (hasArchived && !collapsed) {
|
||
html += `<button class="sb-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderProjectsSection()">
|
||
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
|
||
</button>`;
|
||
}
|
||
|
||
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 ? '' :
|
||
'<div class="sb-section-empty">No channels yet</div>';
|
||
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
|
||
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
|
||
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`;
|
||
|
||
const onlineDot = isDM && online
|
||
? '<span class="sb-presence online"></span>'
|
||
: '';
|
||
const unreadBadge = ch.unread > 0
|
||
? `<span class="sb-unread">${ch.unread > 99 ? '99+' : ch.unread}</span>`
|
||
: '';
|
||
|
||
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
||
onclick="selectChannel('${ch.id}')"
|
||
oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
|
||
title="${esc(ch.name)}">
|
||
<span class="sb-ch-icon">${icon}</span>
|
||
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}
|
||
<button class="sb-ch-menu" onclick="event.stopPropagation();showChannelContextMenu(event,'${ch.id}')" title="Options">⋯</button>` : unreadBadge}
|
||
</div>`;
|
||
});
|
||
|
||
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
|
||
? `<div class="sb-section-empty">No chats matching "${esc(query)}"</div>`
|
||
: '<div class="sb-section-empty">No chats yet</div>';
|
||
// 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 += `<div class="sb-folder-group"
|
||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||
ondragleave="this.classList.remove('drag-over')"
|
||
ondrop="onFolderDrop(event,'${f.id}')">
|
||
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||
</svg>
|
||
<span class="sb-folder-name">${esc(f.name)}</span>
|
||
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
|
||
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
||
</div>
|
||
${isOpen
|
||
? (items.length > 0
|
||
? items.map(c => this._chatItemHTML(c, true)).join('')
|
||
: '<div class="sb-folder-empty">Drop chats here</div>')
|
||
: ''}
|
||
</div>`;
|
||
});
|
||
|
||
// 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 += `<div class="sb-unfiled-zone"
|
||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||
ondragleave="this.classList.remove('drag-over')"
|
||
ondrop="onUnfiledDrop(event)">`;
|
||
if (unfiled.length === 0) {
|
||
html += '<div class="sb-unfiled-hint">Drop here to unfile</div>';
|
||
}
|
||
}
|
||
|
||
const renderTimeGroup = (label, items) => {
|
||
if (!items.length) return;
|
||
html += `<div class="chat-group-label">${label}</div>`;
|
||
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 += '</div>'; // 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'
|
||
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||
return `<div class="chat-item${active}${indent}"
|
||
onclick="selectChat('${c.id}')"
|
||
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||
draggable="true"
|
||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||
ondragend="onChatDragEnd(event)">
|
||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
|
||
title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||
<span class="chat-item-time">${time}</span>
|
||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||
</div>`;
|
||
},
|
||
|
||
// ── 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 += `<div class="message-summary-divider" id="summaryDivider">
|
||
<button class="summary-toggle" onclick="toggleSummarizedHistory()">
|
||
▸ ${hiddenCount} earlier messages summarized
|
||
</button>
|
||
</div>
|
||
<div id="summarizedHistory" style="display:none">
|
||
${messages.slice(0, summaryIdx)
|
||
.filter(m => m.role !== 'system')
|
||
.map((m, i) => this._messageHTML(m, i, true))
|
||
.join('')}
|
||
</div>`;
|
||
}
|
||
// 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 `
|
||
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
|
||
<div class="summary-header">
|
||
<span>📝 Conversation summary (${count} messages, by ${esc(model)}${trigger})</span>
|
||
</div>
|
||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||
</div>`;
|
||
},
|
||
|
||
_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 ? `
|
||
<div class="branch-nav">
|
||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
|
||
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">‹</button>
|
||
<span class="branch-pos">${sibPos}/${sibTotal}</span>
|
||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
|
||
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">›</button>
|
||
</div>` : '';
|
||
|
||
// Action buttons
|
||
const msgId = msg.id ? esc(msg.id) : '';
|
||
const editBtn = isSelf && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||
|
||
return `
|
||
<div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}</div>
|
||
<div class="msg-body">
|
||
<div class="msg-head">
|
||
<span class="msg-role">${esc(senderLabel)}</span>
|
||
${branchNav}
|
||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||
<div class="msg-actions">
|
||
${editBtn}
|
||
${regenBtn}
|
||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button>
|
||
</div>
|
||
</div>
|
||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||
${typeof renderMessageFiles === 'function' ? renderMessageFiles(msgId) : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
},
|
||
|
||
showEmptyState() {
|
||
var base = window.__BASE__ || '';
|
||
document.getElementById('chatMessages').innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-logo"><img src="${base}/favicon-256.png" class="empty-logo-img" alt=""></div>
|
||
<h2>Chat Switchboard</h2>
|
||
<p>Select a model and start chatting</p>
|
||
</div>`;
|
||
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 = `<span class="ctx-mode">DM</span>` +
|
||
`<span>with <b>${esc(name)}</b></span>` +
|
||
`<span class="ctx-hint">@mention a persona to bring in AI</span>` +
|
||
`<span style="flex:1;"></span>` +
|
||
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||
} else if (ac.type === 'group') {
|
||
html = `<span class="ctx-mode">Group</span>` +
|
||
`<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</span>` +
|
||
`<span style="flex:1;"></span>` +
|
||
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||
} 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 = `<button class="ctx-mode ctx-mode-btn" onclick="cycleChannelAiMode('${ac.id}')" title="Click to change AI mode">${esc(modeLabel)}</button>`;
|
||
if (topic) html += `<span class="ctx-topic">${esc(topic)}</span>`;
|
||
html += `<span style="flex:1;"></span>`;
|
||
html += `<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||
}
|
||
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 += '<div class="wf-bar-step ' + cls + '" title="Stage ' + i + (stages[i] ? ': ' + esc(stages[i].name) : '') + '"></div>';
|
||
}
|
||
|
||
// 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 =
|
||
'<button class="btn-small btn-primary" onclick="advanceWorkflowStage()">Advance ▸</button>' +
|
||
(current > 0 ? '<button class="btn-small btn-danger" onclick="rejectWorkflowStage()">◂ Reject</button>' : '');
|
||
}
|
||
|
||
el.innerHTML =
|
||
'<div class="wf-bar-steps">' + dots + '</div>' +
|
||
'<span class="wf-bar-stage-name">' + esc(stageName) + '</span>' +
|
||
'<span class="wf-bar-status ' + statusCls + '">' + esc(statusLabel) + '</span>' +
|
||
'<span class="wf-bar-spacer"></span>' +
|
||
'<span style="font-size:11px;color:var(--text-3)">Step ' + (current + 1) + ' of ' + totalStages + '</span>' +
|
||
(actions ? '<div class="wf-bar-actions">' + actions + '</div>' : '');
|
||
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, '<').replace(/>/g, '>');
|
||
textEl.innerHTML = `
|
||
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
|
||
<div class="msg-edit-actions">
|
||
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
|
||
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
|
||
</div>`;
|
||
|
||
// 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 = `
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||
<div class="msg-body">
|
||
<div class="msg-head"><span class="msg-role">${esc(bubbleLabel)}</span></div>
|
||
<div class="msg-tools" style="display:none"></div>
|
||
<div class="msg-text"></div>
|
||
</div>
|
||
</div>`;
|
||
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 ? '<think>' + reasoning + '</think>' : '') + 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 ? '<think>' + reasoning + '</think>' : '') + 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 = `
|
||
<span class="tool-icon">🔧</span>
|
||
<span class="tool-name">${esc(name)}</span>
|
||
<span class="tool-status tool-running">running…</span>`;
|
||
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 = `
|
||
<span class="tool-icon">🔧</span>
|
||
<span class="tool-name">${esc(name)}</span>
|
||
<span class="tool-status tool-running">running…</span>`;
|
||
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 = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||
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 ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
|
||
const handleHint = m.isPersona && m.personaHandle ? `<span class="item-handle">@${esc(m.personaHandle)}</span>` : '';
|
||
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}${handleHint}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
|
||
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
|
||
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
|
||
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
|
||
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
|
||
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
|
||
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
|
||
</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 = `
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
|
||
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||
</div>`;
|
||
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 = `<span>${esc(message)}</span><button onclick="this.parentElement.remove()">✕</button>`;
|
||
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 = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
|
||
} 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 = '<div style="padding:20px;color:var(--text-3);text-align:center">Memory UI failed to load.</div>';
|
||
}
|
||
}
|
||
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 */ }
|
||
}
|
||
};
|