This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-core.js
2026-02-28 23:46:23 +00:00

1084 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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-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-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;
// 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;
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);
// 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 || '';
},
clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
const modelSel = document.getElementById(`${pfx}_model`);
if (modelSel) modelSel.selectedIndex = 0;
if (showConfig) {
const cfgSel = document.getElementById(`${pfx}_config`);
if (cfgSel) cfgSel.selectedIndex = 0;
}
if (showAvatar) form.updateAvatarPreview(null);
},
updateAvatarPreview(dataURI) {
const preview = document.getElementById(`${pfx}_avatarPreview`);
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
if (!preview) return;
const existing = preview.querySelector('img');
if (dataURI) {
if (placeholder) placeholder.style.display = 'none';
if (existing) { existing.src = dataURI; }
else {
const img = document.createElement('img');
img.src = dataURI; img.alt = 'Preset avatar';
preview.insertBefore(img, placeholder);
}
if (removeBtn) removeBtn.style.display = '';
} else {
if (existing) existing.remove();
if (placeholder) placeholder.style.display = '';
if (removeBtn) removeBtn.style.display = 'none';
}
},
setSubmitLabel(label) {
const btn = document.getElementById(`${pfx}_submitBtn`);
if (btn) btn.textContent = label;
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
};
return form;
}
// ── Summary Message Helpers ─────────────────
function _isSummaryMessage(msg) {
if (!msg.metadata) return false;
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
return meta?.type === 'summary';
}
function toggleSummarizedHistory() {
const el = document.getElementById('summarizedHistory');
const btn = document.querySelector('.message-summary-divider .summary-toggle');
if (!el) return;
const show = el.style.display === 'none';
el.style.display = show ? 'block' : 'none';
if (btn) {
const count = el.querySelectorAll('.message').length;
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `${count} earlier messages summarized`;
}
}
const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
const sb = document.getElementById('sidebar');
sb.classList.toggle('collapsed');
const collapsed = sb.classList.contains('collapsed');
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
const overlay = document.getElementById('sidebarOverlay');
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
},
restoreSidebar() {
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
}
},
// ── User Menu Flyout ─────────────────────
toggleUserMenu() {
const flyout = document.getElementById('userFlyout');
const opening = !flyout.classList.contains('open');
flyout.classList.toggle('open');
// On open: refresh team admin button visibility (fire-and-forget)
if (opening) {
const menuBtn = document.getElementById('menuTeamAdmin');
if (menuBtn) {
// Show from cache immediately if available
if (UI._myTeams && UI._myTeams.length > 0) {
const cached = UI._myTeams.some(t => t.my_role === 'admin');
menuBtn.style.display = cached ? '' : 'none';
}
// Refresh in background
API.listMyTeams().then(resp => {
const teams = resp.data || [];
UI._myTeams = teams;
menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none';
}).catch(() => {});
}
}
},
closeUserMenu() {
document.getElementById('userFlyout').classList.remove('open');
},
// ── Chat List ────────────────────────────
renderChatList() {
const el = document.getElementById('chatHistory');
const searchInput = document.getElementById('chatSearchInput');
const searchWrap = document.getElementById('sidebarSearch');
const query = (searchInput?.value || '').trim().toLowerCase();
// Toggle clear button visibility
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
// Filter chats by search query
let chats = App.chats;
if (query) {
chats = chats.filter(c =>
(c.title || '').toLowerCase().includes(query)
);
}
const projects = App.projects || [];
if (chats.length === 0 && projects.length === 0 && query) {
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
return;
}
if (chats.length === 0 && projects.length === 0) {
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return;
}
let html = '';
// ── Chat item renderer ──────────────────
const renderChatItem = (c) => {
const time = _relativeTime(c.updatedAt);
return `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
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">${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>`;
};
// ── Project groups ──────────────────────
if (projects.length > 0) {
projects.forEach(proj => {
const projChats = chats.filter(c => c.projectId === proj.id);
if (projChats.length === 0 && query) return; // hide empty projects during search
const isCollapsed = App.collapsedProjects[proj.id];
const colorDot = proj.color
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
: '<span class="project-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾';
html += `<div class="project-group"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')">
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
<span class="project-arrow">${arrow}</span>
${colorDot}
<span class="project-name">${esc(proj.name)}</span>
<span class="project-count">${projChats.length}</span>
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
</div>`;
if (!isCollapsed) {
if (projChats.length === 0) {
html += '<div class="project-empty">Drop chats here</div>';
} else {
projChats.forEach(c => { html += renderChatItem(c); });
}
}
html += '</div>';
});
}
// ── Recent (unassigned) chats ───────────
const recentChats = chats.filter(c => !c.projectId);
if (recentChats.length > 0 || projects.length > 0) {
// Only show "Recent" label if there are also projects
if (projects.length > 0) {
html += `<div class="recent-section"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onRecentDrop(event)">
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
}
}
// Group recent 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: [] };
recentChats.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);
});
const renderGroup = (label, items) => {
if (items.length === 0) return;
// Only show time labels when there are no projects (preserve original look)
// or when inside the Recent section
if (projects.length > 0) {
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
} else {
html += `<div class="chat-group-label">${label}</div>`;
}
items.forEach(c => { html += renderChatItem(c); });
};
renderGroup('Today', groups.today);
renderGroup('Yesterday', groups.yesterday);
renderGroup('Previous 7 days', groups.week);
renderGroup('Older', groups.older);
if (projects.length > 0 && recentChats.length > 0) {
html += '</div>'; // close recent-section
} else if (projects.length > 0 && recentChats.length === 0) {
// Close the recent-section div we opened
html += '</div>';
}
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;
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' }) : '';
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>
<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 renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
</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();
// Live-update preview panel if open (debounced)
if (typeof _livePreviewUpdate === 'function') {
_livePreviewUpdate(content);
}
}
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;
}
el.innerHTML = renderCapBadges(caps);
},
// ── User / Connection ────────────────────
updateUser() {
const name = API.user?.display_name || API.user?.username || 'User';
const letter = (name[0] || '?').toUpperCase();
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
letterEl.style.display = 'none';
if (existingImg) {
existingImg.src = API.user.avatar;
} else {
const img = document.createElement('img');
img.src = API.user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
};
avatarEl.insertBefore(img, letterEl);
}
} else {
if (existingImg) existingImg.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
}
document.getElementById('userName').textContent = name;
},
showAdminButton(show) {
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
// Swap favicon to animated version during generation
const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
if (faviconLink) {
if (on) {
// Pulsing favicon: dots fade in/out rapidly
faviconLink._origHref = faviconLink._origHref || faviconLink.href;
const svg = `<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 === '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>';
}
}
},
// ── 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;
}
}
};