Changeset 0.10.3 (#59)
This commit is contained in:
974
src/js/ui-core.js
Normal file
974
src/js/ui-core.js
Normal 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">‹</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 = 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, '&').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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user