591 lines
25 KiB
JavaScript
591 lines
25 KiB
JavaScript
// ==========================================
|
||
// 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' });
|
||
}
|