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.js
2026-02-19 15:03:20 +00:00

591 lines
25 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 (v0.5.4)
// ==========================================
const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
},
restoreSidebar() {
if (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');
if (App.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: [] };
App.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;
}
el.innerHTML = messages
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
this._scrollToBottom(true);
},
_messageHTML(msg, index) {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
return `
<div class="message ${msg.role}">
<div class="msg-inner">
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
${time ? `<span class="msg-time">${time}</span>` : ''}
<div class="msg-actions">
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
</div>
</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>
</div>
</div>`;
},
showEmptyState() {
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-logo">🔀</div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>`;
},
// ── Streaming ────────────────────────────
async streamResponse(response, messages) {
const container = document.getElementById('chatMessages');
document.getElementById('typingIndicator')?.remove();
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">Assistant</span></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 = '';
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) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content);
this._scrollToBottom();
}
} catch (e) { /* partial JSON */ }
}
}
return content;
},
// ── Model Selector ───────────────────────
updateModelSelector() {
const sel = document.getElementById('modelSelect');
const current = App.settings.model;
sel.innerHTML = '';
if (App.models.length === 0) {
sel.innerHTML = '<option value="">No models loaded</option>';
} else {
App.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
const label = m.name || m.id;
opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
if (current) {
const exists = [...sel.options].some(o => o.value === current);
if (exists) {
sel.value = current;
} else {
const opt = document.createElement('option');
opt.value = current;
opt.textContent = current + ' (custom)';
sel.insertBefore(opt, sel.firstChild);
sel.value = current;
}
}
// Sync settings modal selector
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML;
if (current) settingsSel.value = current;
}
},
// ── Capability Badges ────────────────────
getSelectedModelCaps() {
const modelId = document.getElementById('modelSelect').value || App.settings.model;
if (!modelId) return {};
const model = App.models.find(m => m.id === modelId);
// Model in list has resolved caps; fallback to client-side lookup
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
return model.capabilities;
}
return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
},
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();
document.getElementById('avatarLetter').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;
if (on) {
const container = document.getElementById('chatMessages');
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</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) {
document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none';
},
// ── 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.loadProviderList();
document.getElementById('settingsModal').classList.add('active');
},
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
} catch (e) { /* optional */ }
},
// ── Providers ────────────────────────────
async loadProviderList() {
const el = document.getElementById('providerList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.listConfigs();
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
if (list.length === 0) {
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
${c.has_key ? '🔑' : '⚠️'}
${c.user_id
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
: '<span class="badge-global">global</span>'}
</div>
</div>`).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; },
// ── Admin Modal ──────────────────────────
openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
},
async loadAdminUsers() {
const el = document.getElementById('adminUserList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.data || [];
el.innerHTML = users.map(u => `
<div class="admin-user-row">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>`).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminProviders() {
const el = document.getElementById('adminProviderList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
<div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div>
`).join('') || '<div class="empty-hint">No global providers</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels() {
const el = document.getElementById('adminModelList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) {
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
}
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
return `<div class="admin-model-row">
<span>${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<span>${m.is_enabled ? '✅' : '⬜'}</span>
</div>`;
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</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' : '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' : '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;
}
}
};
// ── Formatting ───────────────────────────────
function formatMessage(content) {
if (!content) return '';
const thinkingBlocks = [];
let text = content;
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() });
return `THINK_PLACEHOLDER_${id}`;
});
let html;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text);
} else {
html = _formatBasic(text);
}
for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>');
const thinkHTML = App.settings.showThinking
? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`
: '';
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
return html;
}
function _formatMarked(text) {
const rendered = marked.parse(text, { breaks: true, gfm: true });
let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${codeId}"${attrs}>${code}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
});
return html;
}
function _formatBasic(text) {
let html = esc(text);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${id}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
return html;
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Helpers ──────────────────────────────────
function _relativeTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}