Upload 7 modified files from chat-switchboard-v0.5.2.zip
This commit is contained in:
242
src/js/app.js
242
src/js/app.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (v0.5.0)
|
||||
// Chat Switchboard – Application (v0.5.2)
|
||||
// ==========================================
|
||||
|
||||
const App = {
|
||||
@@ -25,7 +25,6 @@ async function init() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
API.loadTokens();
|
||||
|
||||
// Always check backend health first
|
||||
let health = null;
|
||||
try {
|
||||
health = await API.health();
|
||||
@@ -37,20 +36,13 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have tokens, validate them
|
||||
if (API.isAuthed) {
|
||||
try {
|
||||
await API.getProfile();
|
||||
console.log('✅ Session valid for', API.user?.username);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
console.warn('⚠️ Session expired, clearing');
|
||||
API.clearTokens();
|
||||
} else {
|
||||
// 404 = profile not created yet, other errors = transient
|
||||
// Token is still valid, proceed
|
||||
console.log('ℹ️ Profile fetch returned', e.status || e.message, '— session OK');
|
||||
}
|
||||
console.warn('⚠️ Session expired, clearing');
|
||||
API.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +55,13 @@ async function init() {
|
||||
|
||||
async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateConnectionStatus();
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
@@ -85,9 +78,7 @@ async function loadSettings() {
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Settings load failed:', e.message);
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
@@ -98,9 +89,7 @@ async function saveSettings() {
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Settings sync failed:', e.message);
|
||||
}
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
@@ -114,7 +103,6 @@ async function fetchModels() {
|
||||
configId: m.config_id || null
|
||||
}));
|
||||
|
||||
// Fallback to raw aggregation if no curated models
|
||||
if (App.models.length === 0) {
|
||||
const raw = await API.listAllModels();
|
||||
App.models = (raw.models || []).map(m => ({
|
||||
@@ -126,9 +114,7 @@ async function fetchModels() {
|
||||
|
||||
App.models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
console.log(`📋 Loaded ${App.models.length} models`);
|
||||
} catch (e) {
|
||||
console.warn('Model fetch failed:', e.message);
|
||||
}
|
||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||
UI.updateModelSelector();
|
||||
}
|
||||
|
||||
@@ -142,7 +128,7 @@ async function loadChats() {
|
||||
title: c.title,
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [], // lazy loaded
|
||||
messages: [],
|
||||
updatedAt: c.updated_at
|
||||
}));
|
||||
} catch (e) {
|
||||
@@ -158,15 +144,11 @@ async function selectChat(chatId) {
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
// Lazy-load messages
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
try {
|
||||
const resp = await API.listMessages(chatId);
|
||||
chat.messages = (resp.data || []).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
timestamp: m.created_at
|
||||
role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
@@ -182,6 +164,7 @@ async function newChat() {
|
||||
App.currentChatId = null;
|
||||
UI.renderChatList();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
document.getElementById('messageInput').focus();
|
||||
}
|
||||
|
||||
@@ -190,13 +173,9 @@ async function deleteChat(chatId) {
|
||||
try {
|
||||
await API.deleteChat(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
if (App.currentChatId === chatId) {
|
||||
newChat();
|
||||
}
|
||||
if (App.currentChatId === chatId) newChat();
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete: ' + e.message, 'error');
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Send Message ─────────────────────────────
|
||||
@@ -211,69 +190,45 @@ async function sendMessage() {
|
||||
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
|
||||
// Create chat on first message
|
||||
if (!App.currentChatId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChat(title, model, App.settings.systemPrompt);
|
||||
const chat = {
|
||||
id: resp.id,
|
||||
title: resp.title,
|
||||
model: model,
|
||||
messages: [],
|
||||
messageCount: 0,
|
||||
updatedAt: resp.updated_at
|
||||
};
|
||||
const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create chat: ' + e.message, 'error');
|
||||
return;
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
// Add user message to local state + render
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
// Stream the completion
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(
|
||||
App.currentChatId, text, model, App.abortController.signal
|
||||
);
|
||||
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||
|
||||
chat.messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
model: model,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.showRegenerate(true);
|
||||
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
} else {
|
||||
console.error('Completion error:', e);
|
||||
// Distinguish provider-side auth errors from session issues
|
||||
const msg = e.message || '';
|
||||
if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check your key in Settings → Providers', 'error');
|
||||
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('429')) {
|
||||
UI.toast('Provider rate limit hit — wait a moment and retry', 'warning');
|
||||
UI.toast('Provider rate limit — wait and retry', 'warning');
|
||||
} else if (msg.includes('no API config') || msg.includes('no model')) {
|
||||
UI.toast('No provider configured — add one in Settings → Providers', 'error');
|
||||
UI.toast('No provider configured — add one in Settings', 'error');
|
||||
} else {
|
||||
UI.toast(msg, 'error');
|
||||
}
|
||||
@@ -285,39 +240,27 @@ async function sendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (App.abortController) {
|
||||
App.abortController.abort();
|
||||
}
|
||||
}
|
||||
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Pop trailing assistant messages
|
||||
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
||||
chat.messages.pop();
|
||||
}
|
||||
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
|
||||
if (chat.messages.length === 0) return;
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
// Re-send the last user message
|
||||
const lastUser = chat.messages[chat.messages.length - 1];
|
||||
if (lastUser.role !== 'user') return;
|
||||
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(
|
||||
App.currentChatId, lastUser.content, model, App.abortController.signal
|
||||
);
|
||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
|
||||
const content = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||
UI.showRegenerate(true);
|
||||
@@ -325,10 +268,8 @@ async function regenerate() {
|
||||
if (e.name !== 'AbortError') {
|
||||
const msg = e.message || '';
|
||||
if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
|
||||
} else {
|
||||
UI.toast(msg, 'error');
|
||||
}
|
||||
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||
} else { UI.toast(msg, 'error'); }
|
||||
}
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
@@ -342,7 +283,6 @@ async function regenerate() {
|
||||
function showSplash(health) {
|
||||
document.getElementById('splashGate').style.display = 'flex';
|
||||
document.getElementById('appContainer').style.display = 'none';
|
||||
|
||||
if (health && health.registration_enabled === false) {
|
||||
document.getElementById('authTabRegister').style.display = 'none';
|
||||
}
|
||||
@@ -357,16 +297,10 @@ async function handleLogin() {
|
||||
const login = document.getElementById('authLogin').value.trim();
|
||||
const password = document.getElementById('authPassword').value;
|
||||
if (!login || !password) return setAuthError('Fill in all fields');
|
||||
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
await API.login(login, password);
|
||||
await startApp();
|
||||
} catch (e) {
|
||||
setAuthError(e.message);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
try { await API.login(login, password); await startApp(); }
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
@@ -375,16 +309,10 @@ async function handleRegister() {
|
||||
const password = document.getElementById('authRegPassword').value;
|
||||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||||
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
await API.register(username, email, password);
|
||||
await startApp();
|
||||
} catch (e) {
|
||||
setAuthError(e.message);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
try { await API.register(username, email, password); await startApp(); }
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -409,7 +337,7 @@ function setAuthError(msg) { document.getElementById('authError').textContent =
|
||||
function setAuthLoading(on) {
|
||||
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
|
||||
btn.disabled = on;
|
||||
btn.textContent = on ? '⏳ Please wait...' : btn.dataset.label;
|
||||
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,43 +348,56 @@ function initListeners() {
|
||||
if (_listenersInit) return;
|
||||
_listenersInit = true;
|
||||
|
||||
// Sidebar
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
UI.toggleUserMenu();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
|
||||
});
|
||||
|
||||
// Flyout items
|
||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||
document.getElementById('menuAdmin').addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||
document.getElementById('menuDebug').addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||
|
||||
// Chat actions
|
||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
|
||||
|
||||
// Settings
|
||||
document.getElementById('settingsBtn').addEventListener('click', UI.openSettings);
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
});
|
||||
|
||||
// Profile
|
||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
|
||||
// Providers
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
|
||||
// Admin
|
||||
document.getElementById('adminBtn').addEventListener('click', UI.openAdmin);
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
|
||||
});
|
||||
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Model selector
|
||||
document.getElementById('modelSelect').addEventListener('change', function() {
|
||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||
});
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
});
|
||||
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
|
||||
// Admin modal
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
|
||||
});
|
||||
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
@@ -468,19 +409,7 @@ function initListeners() {
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
});
|
||||
|
||||
// Connection status
|
||||
document.getElementById('connectionStatus').addEventListener('click', handleLogout);
|
||||
|
||||
// Export dropdown
|
||||
document.getElementById('exportBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('exportDropdown').classList.toggle('show');
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
document.getElementById('exportDropdown').classList.remove('show');
|
||||
});
|
||||
|
||||
// Close modals on overlay
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.classList.remove('active');
|
||||
@@ -501,7 +430,6 @@ function handleSaveSettings() {
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
|
||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||
|
||||
saveSettings();
|
||||
UI.updateModelSelector();
|
||||
UI.closeSettings();
|
||||
@@ -513,16 +441,13 @@ async function handleChangePassword() {
|
||||
const pw = document.getElementById('profileNewPw').value;
|
||||
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
|
||||
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
|
||||
|
||||
try {
|
||||
await API.changePassword(cur, pw);
|
||||
UI.toast('Password updated', 'success');
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
document.getElementById('profileCurrentPw').value = '';
|
||||
document.getElementById('profileNewPw').value = '';
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function handleCreateProvider() {
|
||||
@@ -531,18 +456,14 @@ async function handleCreateProvider() {
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const model = document.getElementById('providerDefaultModel').value.trim();
|
||||
|
||||
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||
|
||||
try {
|
||||
await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||
UI.toast('Provider added', 'success');
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProvider(id, name) {
|
||||
@@ -552,23 +473,16 @@ async function deleteProvider(id, name) {
|
||||
UI.toast('Provider removed', 'success');
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Handlers ───────────────────────────
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('registration_enabled', { value: reg });
|
||||
UI.toast('Admin settings saved', 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
313
src/js/ui.js
313
src/js/ui.js
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user