Changeset 0.10.3 (#59)

This commit is contained in:
2026-02-24 21:32:07 +00:00
parent 4061e4a145
commit ba2cd42428
17 changed files with 5723 additions and 2458 deletions

974
src/js/ui-core.js Normal file
View File

@@ -0,0 +1,974 @@
// ==========================================
// 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' ? '👤' : '🤖';
}
// Look up the current model/preset avatar for assistant messages.
function assistantAvatarURI(msg) {
// Try to find preset avatar from the model that generated this message
const modelId = msg?.model || msg?.preset_id;
if (modelId) {
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
if (m?.presetAvatar) return m.presetAvatar;
}
return null;
}
// ── Shared Preset Form Component ────────────
// Renders a preset creation/edit form into a container element.
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
// Returns: { getValues(), setValues(preset), clearForm(), container }
function renderPresetForm(containerEl, options = {}) {
const pfx = options.prefix || 'pf';
const showAvatar = options.showAvatar !== false;
const showConfig = !!options.showProviderConfig;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></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-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();
});
// Submit wiring
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
if (options.onSubmit) options.onSubmit(form.getValues());
});
const form = {
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
};
if (showConfig) {
const cfgId = document.getElementById(`${pfx}_config`)?.value;
if (cfgId) v.provider_config_id = cfgId;
}
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
if (!isNaN(temp)) v.temperature = temp;
const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value);
if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok;
// Pending avatar data URI
const img = document.querySelector(`#${pfx}_avatarPreview img`);
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
return v;
},
setValues(p) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = p.name || '';
if (el('desc')) el('desc').value = p.description || '';
if (el('prompt')) el('prompt').value = p.system_prompt || '';
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
if (el('model')) el('model').value = p.base_model_id || '';
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
},
clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
const modelSel = document.getElementById(`${pfx}_model`);
if (modelSel) modelSel.selectedIndex = 0;
if (showConfig) {
const cfgSel = document.getElementById(`${pfx}_config`);
if (cfgSel) cfgSel.selectedIndex = 0;
}
if (showAvatar) form.updateAvatarPreview(null);
},
updateAvatarPreview(dataURI) {
const preview = document.getElementById(`${pfx}_avatarPreview`);
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
if (!preview) return;
const existing = preview.querySelector('img');
if (dataURI) {
if (placeholder) placeholder.style.display = 'none';
if (existing) { existing.src = dataURI; }
else {
const img = document.createElement('img');
img.src = dataURI; img.alt = 'Preset avatar';
preview.insertBefore(img, placeholder);
}
if (removeBtn) removeBtn.style.display = '';
} else {
if (existing) existing.remove();
if (placeholder) placeholder.style.display = '';
if (removeBtn) removeBtn.style.display = 'none';
}
},
setSubmitLabel(label) {
const btn = document.getElementById(`${pfx}_submitBtn`);
if (btn) btn.textContent = label;
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
};
return form;
}
// ── Summary Message Helpers ─────────────────
function _isSummaryMessage(msg) {
if (!msg.metadata) return false;
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
return meta?.type === 'summary';
}
function toggleSummarizedHistory() {
const el = document.getElementById('summarizedHistory');
const btn = document.querySelector('.message-summary-divider .summary-toggle');
if (!el) return;
const show = el.style.display === 'none';
el.style.display = show ? 'block' : 'none';
if (btn) {
const count = el.querySelectorAll('.message').length;
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `${count} earlier messages summarized`;
}
}
const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
const sb = document.getElementById('sidebar');
sb.classList.toggle('collapsed');
const collapsed = sb.classList.contains('collapsed');
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
const overlay = document.getElementById('sidebarOverlay');
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
},
restoreSidebar() {
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
}
},
// ── User Menu Flyout ─────────────────────
toggleUserMenu() {
const flyout = document.getElementById('userFlyout');
flyout.classList.toggle('open');
},
closeUserMenu() {
document.getElementById('userFlyout').classList.remove('open');
},
// ── Chat List ────────────────────────────
renderChatList() {
const el = document.getElementById('chatHistory');
const searchInput = document.getElementById('chatSearchInput');
const searchWrap = document.getElementById('sidebarSearch');
const query = (searchInput?.value || '').trim().toLowerCase();
// Toggle clear button visibility
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
// Filter chats by search query
let chats = App.chats;
if (query) {
chats = chats.filter(c =>
(c.title || '').toLowerCase().includes(query)
);
}
if (chats.length === 0 && query) {
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
return;
}
if (chats.length === 0) {
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return;
}
// Group by time
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today - 86400000);
const weekAgo = new Date(today - 7 * 86400000);
const groups = { today: [], yesterday: [], week: [], older: [] };
chats.forEach(c => {
const d = new Date(c.updatedAt);
if (d >= today) groups.today.push(c);
else if (d >= yesterday) groups.yesterday.push(c);
else if (d >= weekAgo) groups.week.push(c);
else groups.older.push(c);
});
let html = '';
const renderGroup = (label, chats) => {
if (chats.length === 0) return;
html += `<div class="chat-group-label">${label}</div>`;
chats.forEach(c => {
const time = _relativeTime(c.updatedAt);
html += `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')">
<span class="chat-item-title">${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>`;
});
};
renderGroup('Today', groups.today);
renderGroup('Yesterday', groups.yesterday);
renderGroup('Previous 7 days', groups.week);
renderGroup('Older', groups.older);
el.innerHTML = html;
},
// ── Messages ─────────────────────────────
renderMessages(messages) {
const el = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
this.showEmptyState();
return;
}
// Find the most recent summary boundary
let summaryIdx = -1;
for (let i = 0; i < messages.length; i++) {
if (_isSummaryMessage(messages[i])) summaryIdx = i;
}
// Render: if summary exists, show collapsed-history toggle + summary + messages after
let html = '';
if (summaryIdx >= 0) {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
if (hiddenCount > 0) {
html += `<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;
this._scrollToBottom(true);
},
_summaryHTML(msg) {
const meta = msg.metadata || {};
const count = meta.summarized_count || '?';
const model = meta.utility_model || 'utility model';
return `
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
<div class="summary-header">
<span>📝 Conversation summary (${count} messages, by ${esc(model)})</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' }) : '';
let assistantLabel = 'Assistant';
if (!isUser) {
// Use stored modelName, or resolve from current model list, or fall back to model id
if (msg.modelName) {
assistantLabel = msg.modelName;
} else if (msg.model) {
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
assistantLabel = m?.name || msg.model;
}
}
// Branch indicator: show 2/3 when siblings exist
const hasSiblings = (msg.siblingCount || 1) > 1;
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
const sibTotal = msg.siblingCount || 1;
const branchNav = hasSiblings ? `
<div class="branch-nav">
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
<span class="branch-pos">${sibPos}/${sibTotal}</span>
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
</div>` : '';
// Action buttons
const msgId = msg.id ? esc(msg.id) : '';
const editBtn = isUser && 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 ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</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>
</div>
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>
</div>
</div>`;
},
showEmptyState() {
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>`;
},
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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);
const streamAvatar = currentModel?.presetAvatar || null;
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(label)}</span></div>
<div class="msg-tools" id="streamTools" style="display:none"></div>
<div class="msg-text" id="streamContent"></div>
</div>
</div>`;
container.appendChild(div);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let reasoning = '';
let currentEvent = ''; // SSE event type
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
// SSE event type line
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
continue;
}
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') { currentEvent = ''; continue; }
try {
const parsed = JSON.parse(data);
if (currentEvent === 'tool_use') {
// parsed = array of tool calls
this._renderToolUse(parsed);
currentEvent = '';
continue;
}
if (currentEvent === 'tool_result') {
// parsed = { tool_call_id, name, content, is_error }
this._renderToolResult(parsed);
currentEvent = '';
continue;
}
// Reasoning content delta (thinking blocks)
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
if (reasoningDelta) {
reasoning += reasoningDelta;
// Render accumulated reasoning + content together
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
this._scrollToBottom();
}
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
this._scrollToBottom();
}
currentEvent = '';
} catch (e) { /* partial JSON */ }
}
}
return content;
},
_renderToolUse(toolCalls) {
const el = document.getElementById('streamTools');
if (!el) return;
el.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.id = `tool_${tc.id}`;
toolDiv.innerHTML = `
<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) {
const el = document.getElementById(`tool_${result.tool_call_id}`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
// Parse tool result content for a brief summary
try {
const parsed = JSON.parse(result.content);
let summary = '';
if (parsed.title) summary = parsed.title;
else if (parsed.count != null) summary = `${parsed.count} results`;
if (summary) {
const hint = document.createElement('span');
hint.className = 'tool-hint';
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
el.appendChild(hint);
}
// Add "View note" link for note tools
if (result.name && result.name.startsWith('note_') && parsed.id) {
const link = document.createElement('button');
link.className = 'tool-note-link';
link.textContent = '📝 View note';
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
el.appendChild(link);
}
} catch (e) { /* not JSON or no useful summary */ }
}
this._scrollToBottom();
},
// ── Model Selector (Custom Dropdown) ────
_modelValue: '',
getModelValue() { return UI._modelValue || App.settings.model || ''; },
setModelValue(val, label) {
UI._modelValue = val;
if (val) { App.settings.model = val; saveSettings(); }
const btn = document.getElementById('modelDropdownLabel');
if (btn) btn.textContent = label || val || 'Select a model';
// Highlight selected item
document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === val);
});
UI.updateCapabilityBadges();
if (typeof updateContextWarning === 'function') updateContextWarning();
if (typeof updateInputTokens === 'function') updateInputTokens();
},
updateModelSelector() {
const menu = document.getElementById('modelDropdownMenu');
if (!menu) return;
const current = App.settings.model;
menu.innerHTML = '';
if (App.models.length === 0) {
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
UI.setModelValue('', 'No models loaded');
} else {
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
const models = App.models.filter(m => !m.isPreset && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = label;
menu.appendChild(hdr);
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
};
addGroup('⚡ Presets', globalPresets);
// Group team presets by team name
const teamGroups = {};
teamPresets.forEach(m => {
const tn = m.presetTeamName || 'Team';
(teamGroups[tn] = teamGroups[tn] || []).push(m);
});
Object.keys(teamGroups).sort().forEach(tn => {
addGroup(`👥 ${tn}`, teamGroups[tn]);
});
addGroup('🔧 My Presets', personalPresets);
// No team model groups — team providers are for preset building only.
// Team members access team models through curated presets.
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
// Restore selection — resolution chain: localStorage → admin default → first visible
const visibleModels = App.models.filter(m => !m.hidden);
const match = visibleModels.find(m => m.id === current || m.baseModelId === current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.defaultModel && visibleModels.length > 0) {
// Admin-configured default
const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel);
if (def) {
UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : ''));
} else {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
}
} else if (visibleModels.length > 0) {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
} else {
UI.setModelValue('', 'No visible models');
}
}
// Sync settings modal selector (flat select is fine there)
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = '';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
settingsSel.appendChild(opt);
});
if (current) settingsSel.value = current;
}
},
_createDropdownItem(m) {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
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)}</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;
}
const badges = [];
// Output tokens
if (caps.max_output_tokens > 0) {
const k = caps.max_output_tokens >= 1000
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
: caps.max_output_tokens;
badges.push(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
}
// Context window
if (caps.max_context > 0) {
const k = (caps.max_context / 1000).toFixed(0) + 'K';
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
}
// Capability flags
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
el.innerHTML = badges.join('');
},
// ── User / Connection ────────────────────
updateUser() {
const name = API.user?.display_name || API.user?.username || 'User';
const letter = (name[0] || '?').toUpperCase();
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
letterEl.style.display = 'none';
if (existingImg) {
existingImg.src = API.user.avatar;
} else {
const img = document.createElement('img');
img.src = API.user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
};
avatarEl.insertBefore(img, letterEl);
}
} else {
if (existingImg) existingImg.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
}
document.getElementById('userName').textContent = name;
},
showAdminButton(show) {
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
// 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?.presetAvatar || 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() {
document.getElementById('settingsModel').value = App.settings.model;
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
document.getElementById('settingsTemp').value = App.settings.temperature;
document.getElementById('settingsThinking').checked = App.settings.showThinking;
document.getElementById('profileChangePwForm').style.display = 'none';
// Show model's max output in the hint
const caps = this.getSelectedModelCaps();
const hint = document.getElementById('settingsMaxHint');
if (hint) {
if (caps.max_output_tokens > 0) {
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
hint.textContent = `(model max: ${k})`;
} else {
hint.textContent = '';
}
}
UI.loadProfileIntoSettings();
UI.loadMyTeams();
UI.checkUserProvidersAllowed();
UI.checkUserPresetsAllowed();
UI.switchSettingsTab('general');
openModal('settingsModal');
},
closeSettings() { closeModal('settingsModal'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'providers') {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'roles') UI.loadUserRoles();
if (tab === 'appearance') UI.loadAppearanceSettings();
},
// ── Export ────────────────────────────────
exportChat(format) {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return UI.toast('No chat to export', 'warning');
let content, ext, mime;
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
else if (format === 'md') {
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
ext = 'txt'; mime = 'text/plain';
}
const blob = new Blob([content], { type: mime });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
},
// ── Message Actions ──────────────────────
copyMessage(index) {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat?.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => UI.toast('Copied', 'success'))
.catch(() => UI.toast('Copy failed', 'error'));
}
},
// ── Scroll ───────────────────────────────
_isNearBottom() {
const el = document.getElementById('chatMessages');
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
_scrollToBottom(force) {
if (force || this._isNearBottom()) {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
}
};