Upload 7 modified files from chat-switchboard-v0.5.2.zip

This commit is contained in:
2026-02-18 22:14:35 +00:00
parent a6b55a0352
commit 92f4f87b6a
5 changed files with 750 additions and 788 deletions

View File

@@ -1,25 +1,80 @@
// ==========================================
// Chat Switchboard UI (v0.5.0)
// Chat Switchboard UI (v0.5.2)
// ==========================================
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="empty-hint">No conversations yet</div>';
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return;
}
el.innerHTML = App.chats.map(c => `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')">
<span class="chat-item-title">${esc(c.title)}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();"
title="Delete">✕</button>
</div>
`).join('');
// 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 ─────────────────────────────
@@ -35,23 +90,25 @@ const UI = {
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
this._scrollToBottom();
this._scrollToBottom(true);
},
_messageHTML(msg, index) {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
return `
<div class="message ${msg.role}">
<div class="message-inner">
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="message-body">
<div class="message-head">
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
${time ? `<span class="message-time">${time}</span>` : ''}
<button class="message-copy" onclick="UI.copyMessage(${index})" title="Copy">📋</button>
<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="message-text">${formatMessage(msg.content)}</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>
</div>
</div>`;
@@ -60,9 +117,9 @@ const UI = {
showEmptyState() {
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-icon">💬</div>
<h2>Start a Conversation</h2>
<p>Type a message below to begin</p>
<div class="empty-logo">🔀</div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>`;
},
@@ -70,21 +127,16 @@ const UI = {
async streamResponse(response, messages) {
const container = document.getElementById('chatMessages');
// Remove typing indicator if present
document.getElementById('typingIndicator')?.remove();
// Create streaming message element
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="message-inner">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-head">
<span class="message-role">Assistant</span>
</div>
<div class="message-text" id="streamContent"></div>
<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);
@@ -144,7 +196,7 @@ const UI = {
const exists = [...sel.options].some(o => o.value === current);
if (exists) {
sel.value = current;
} else if (current) {
} else {
const opt = document.createElement('option');
opt.value = current;
opt.textContent = current + ' (custom)';
@@ -153,7 +205,7 @@ const UI = {
}
}
// Also update settings modal model selector if open
// Sync settings modal selector
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML;
@@ -161,18 +213,17 @@ const UI = {
}
},
// ── Connection Status ────────────────────
// ── User / Connection ────────────────────
updateConnectionStatus() {
const el = document.getElementById('connectionStatus');
const name = API.user?.display_name || API.user?.username || 'Connected';
el.className = 'connection-status online';
el.innerHTML = `<span class="status-dot"></span> ${esc(name)}`;
el.title = 'Click to sign out';
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('adminBtn').style.display = show ? '' : 'none';
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
@@ -181,20 +232,17 @@ const UI = {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
if (on) {
// Show typing indicator
const container = document.getElementById('chatMessages');
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="message-inner">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
<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();
this._scrollToBottom(true);
} else {
document.getElementById('typingIndicator')?.remove();
}
@@ -224,15 +272,11 @@ const UI = {
document.getElementById('settingsTemp').value = App.settings.temperature;
document.getElementById('settingsThinking').checked = App.settings.showThinking;
document.getElementById('profileChangePwForm').style.display = 'none';
UI.loadProfileIntoSettings();
UI.loadProviderList();
document.getElementById('settingsModal').classList.add('active');
},
closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
},
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
async loadProfileIntoSettings() {
try {
@@ -251,12 +295,12 @@ const UI = {
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. Add one to start chatting.</div>';
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div class="provider-info">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div>
@@ -266,39 +310,25 @@ const UI = {
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
: '<span class="badge-global">global</span>'}
</div>
</div>
`).join('');
</div>`).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">Failed: ${esc(e.message)}</div>`;
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
showProviderForm() {
document.getElementById('providerAddForm').style.display = '';
document.getElementById('providerName').value = '';
document.getElementById('providerEndpoint').value = '';
document.getElementById('providerApiKey').value = '';
document.getElementById('providerDefaultModel').value = '';
},
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
},
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');
},
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');
document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`).style.display = '';
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();
@@ -314,26 +344,17 @@ const UI = {
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><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>`;
}
</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 stats = await API.adminGetStats();
el.innerHTML = `<pre>${esc(JSON.stringify(stats, null, 2))}</pre>`;
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
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() {
@@ -343,14 +364,9 @@ const UI = {
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>
<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>`;
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels() {
@@ -360,15 +376,9 @@ const UI = {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(m => `
<div class="admin-model-row">
<span>${esc(m.model_id || m.id)}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<span>${m.is_enabled ? '✅' : '⬜'}</span>
</div>
`).join('') || '<div class="empty-hint">No models. Fetch from providers first.</div>';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
<div class="admin-model-row"><span>${esc(m.model_id || m.id)}</span><span class="provider-meta">${esc(m.provider_name || '')}</span><span>${m.is_enabled ? '✅' : '⬜'}</span></div>
`).join('') || '<div class="empty-hint">No models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Export ────────────────────────────────
@@ -380,28 +390,19 @@ const UI = {
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');
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');
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);
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
},
// ── Message Actions ──────────────────────
@@ -415,35 +416,35 @@ const UI = {
}
},
// ── Helpers ──────────────────────────────
// ── Scroll ───────────────────────────────
_scrollToBottom() {
_isNearBottom() {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
_scrollToBottom(force) {
if (force || this._isNearBottom()) {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
}
};
// ── Formatting ───────────────────────────────
/**
* Format message content with markdown rendering.
* Uses marked.js + DOMPurify when available, regex fallback otherwise.
*/
function formatMessage(content) {
if (!content) return '';
// ── Extract thinking blocks (both <thinking> and <think> tags) ──
const thinkingBlocks = [];
let text = content;
// Pull out thinking blocks before any processing
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}`;
});
// ── Render markdown ──
let html;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text);
@@ -451,14 +452,11 @@ function formatMessage(content) {
html = _formatBasic(text);
}
// ── Restore thinking blocks ──
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>`
: '';
// marked may wrap placeholder in <p> tags
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
@@ -466,22 +464,10 @@ function formatMessage(content) {
return html;
}
/**
* Full markdown rendering via marked.js + DOMPurify
*/
function _formatMarked(text) {
const rendered = marked.parse(text, {
breaks: true,
gfm: true
});
const rendered = marked.parse(text, { breaks: true, gfm: true });
let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
// Sanitize but allow code structure
let html = DOMPurify.sanitize(rendered, {
ADD_TAGS: ['details', 'summary'],
ADD_ATTR: ['id', 'class', 'onclick']
});
// Inject copy buttons into <pre><code> blocks
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>`;
@@ -490,18 +476,12 @@ function _formatMarked(text) {
return html;
}
/**
* Regex fallback when marked/DOMPurify unavailable
*/
function _formatBasic(text) {
let html = esc(text);
// Code blocks with copy button
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>');
@@ -515,3 +495,18 @@ function esc(s) {
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' });
}