Upload 9 modified files from chat-switchboard-v0.5.0.zip
This commit is contained in:
912
src/js/ui.js
912
src/js/ui.js
@@ -1,515 +1,475 @@
|
||||
// ==========================================
|
||||
// UI Functions
|
||||
// Chat Switchboard – UI (v0.5.0)
|
||||
// ==========================================
|
||||
|
||||
function updateSettingsUI() {
|
||||
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
|
||||
document.getElementById('apiKey').value = State.settings.apiKey || '';
|
||||
|
||||
const modelSelect = document.getElementById('model');
|
||||
const modelCustom = document.getElementById('modelCustom');
|
||||
const savedModel = State.settings.model || 'gpt-3.5-turbo';
|
||||
|
||||
populateModelSelect(modelSelect);
|
||||
|
||||
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
|
||||
if (optionExists) {
|
||||
modelSelect.value = savedModel;
|
||||
modelCustom.value = '';
|
||||
} else {
|
||||
modelSelect.value = '';
|
||||
modelCustom.value = savedModel;
|
||||
}
|
||||
|
||||
document.getElementById('streamResponse').checked = State.settings.stream;
|
||||
document.getElementById('saveHistory').checked = State.settings.saveHistory;
|
||||
document.getElementById('showThinking').checked = State.settings.showThinking;
|
||||
document.getElementById('systemPrompt').value = State.settings.systemPrompt || '';
|
||||
document.getElementById('maxTokens').value = State.settings.maxTokens;
|
||||
document.getElementById('temperature').value = State.settings.temperature;
|
||||
document.getElementById('topP').value = State.settings.topP;
|
||||
document.getElementById('presencePenalty').value = State.settings.presencePenalty;
|
||||
}
|
||||
const UI = {
|
||||
|
||||
function populateModelSelect(selectElement) {
|
||||
const currentValue = selectElement.value;
|
||||
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
|
||||
|
||||
State.models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
option.textContent = model.id + (model.owned_by ? ` (${model.owned_by})` : '');
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentValue) {
|
||||
selectElement.value = currentValue;
|
||||
}
|
||||
}
|
||||
// ── Chat List ────────────────────────────
|
||||
|
||||
function updateQuickModelSelector() {
|
||||
const quickSelect = document.getElementById('quickModel');
|
||||
const currentModel = State.settings.model;
|
||||
|
||||
quickSelect.innerHTML = '';
|
||||
|
||||
if (State.models.length === 0) {
|
||||
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
|
||||
if (currentModel) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentModel;
|
||||
opt.textContent = currentModel;
|
||||
quickSelect.appendChild(opt);
|
||||
quickSelect.value = currentModel;
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
if (App.chats.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No conversations yet</div>';
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
State.models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
option.textContent = model.id;
|
||||
quickSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentModel) {
|
||||
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
|
||||
if (exists) {
|
||||
quickSelect.value = currentModel;
|
||||
} else {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentModel;
|
||||
opt.textContent = currentModel + ' (custom)';
|
||||
quickSelect.insertBefore(opt, quickSelect.firstChild);
|
||||
quickSelect.value = currentModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
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('');
|
||||
},
|
||||
|
||||
function openSettings() {
|
||||
updateSettingsUI();
|
||||
updateProfileSection();
|
||||
updateProviderSection();
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
}
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
function updateProfileSection() {
|
||||
const profileSection = document.getElementById('profileSection');
|
||||
const unmanagedSettings = document.getElementById('unmanagedSettings');
|
||||
const modeBadge = document.getElementById('settingsModeBadge');
|
||||
|
||||
if (Backend.isManaged) {
|
||||
profileSection.style.display = '';
|
||||
unmanagedSettings.style.display = 'none';
|
||||
modeBadge.textContent = '🔗 Managed Mode — API keys are configured by your admin or in Providers below';
|
||||
modeBadge.className = 'settings-mode-badge mode-managed';
|
||||
loadProfileIntoSettings();
|
||||
} else {
|
||||
profileSection.style.display = 'none';
|
||||
unmanagedSettings.style.display = '';
|
||||
modeBadge.textContent = '📱 Offline Mode — Configure your own API endpoint and key';
|
||||
modeBadge.className = 'settings-mode-badge mode-unmanaged';
|
||||
}
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadProfileIntoSettings() {
|
||||
try {
|
||||
const profile = await Backend.getProfile();
|
||||
document.getElementById('profileDisplayName').value = profile.display_name || '';
|
||||
document.getElementById('profileEmail').value = profile.email || '';
|
||||
} catch (e) {
|
||||
console.warn('Failed to load profile:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!Backend.isManaged) return;
|
||||
|
||||
const displayName = document.getElementById('profileDisplayName').value.trim();
|
||||
const email = document.getElementById('profileEmail').value.trim();
|
||||
|
||||
const updates = {};
|
||||
if (displayName !== (Backend.user.display_name || '')) {
|
||||
updates.display_name = displayName || null;
|
||||
}
|
||||
if (email && email !== Backend.user.email) {
|
||||
updates.email = email;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
try {
|
||||
const profile = await Backend.updateProfile(updates);
|
||||
// Update local user state
|
||||
if (profile.display_name !== undefined) Backend.user.display_name = profile.display_name;
|
||||
if (profile.email) Backend.user.email = profile.email;
|
||||
Backend.save();
|
||||
showToast('✅ Profile updated', 'success');
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
const current = document.getElementById('profileCurrentPw').value;
|
||||
const newPw = document.getElementById('profileNewPw').value;
|
||||
|
||||
if (!current || !newPw) {
|
||||
showToast('⚠️ Fill in both fields', 'warning');
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
showToast('⚠️ New password must be at least 8 characters', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Backend.changePassword(current, newPw);
|
||||
showToast('✅ Password updated', 'success');
|
||||
document.getElementById('profileCurrentPw').value = '';
|
||||
document.getElementById('profileNewPw').value = '';
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Providers (managed mode) ────────────
|
||||
|
||||
function updateProviderSection() {
|
||||
const managed = document.getElementById('managedProviders');
|
||||
if (Backend.isManaged) {
|
||||
managed.style.display = '';
|
||||
loadProviderList();
|
||||
} else {
|
||||
managed.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderList() {
|
||||
const container = document.getElementById('providerList');
|
||||
container.innerHTML = '<div class="admin-loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const data = await Backend.listConfigs();
|
||||
const configs = data.configs || data.data || data || [];
|
||||
const list = Array.isArray(configs) ? configs : [];
|
||||
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="provider-empty">No providers configured. Add one to start chatting.</div>';
|
||||
renderMessages(messages) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = list.map(c => `
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
|
||||
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
|
||||
${c.user_id ? `<button class="btn btn-small btn-danger" onclick="deleteProvider('${c.id}', '${escapeHtmlUI(c.name)}')">Remove</button>` : '<span class="admin-badge admin-badge-moderator">global</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + escapeHtmlUI(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
el.innerHTML = messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((m, i) => this._messageHTML(m, i))
|
||||
.join('');
|
||||
this._scrollToBottom();
|
||||
},
|
||||
|
||||
function showProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = '';
|
||||
document.getElementById('providerShowAddBtn').style.display = 'none';
|
||||
// Set default endpoint based on provider type
|
||||
updateProviderEndpointHint();
|
||||
}
|
||||
|
||||
function hideProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = 'none';
|
||||
document.getElementById('providerShowAddBtn').style.display = '';
|
||||
clearProviderForm();
|
||||
}
|
||||
|
||||
function clearProviderForm() {
|
||||
document.getElementById('providerName').value = '';
|
||||
document.getElementById('providerType').value = 'openai';
|
||||
document.getElementById('providerEndpoint').value = '';
|
||||
document.getElementById('providerApiKey').value = '';
|
||||
document.getElementById('providerDefaultModel').value = '';
|
||||
}
|
||||
|
||||
function updateProviderEndpointHint() {
|
||||
const type = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint');
|
||||
if (!endpoint.value) {
|
||||
endpoint.placeholder = type === 'anthropic'
|
||||
? 'https://api.anthropic.com'
|
||||
: 'https://api.openai.com/v1';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateProvider() {
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const provider = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const modelDefault = document.getElementById('providerDefaultModel').value.trim();
|
||||
|
||||
if (!name || !endpoint || !apiKey) {
|
||||
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
||||
showToast('✅ Provider added', 'success');
|
||||
hideProviderForm();
|
||||
loadProviderList();
|
||||
fetchModels(false); // Refresh model list
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(configId, name) {
|
||||
if (!confirm('Remove provider "' + name + '"?')) return;
|
||||
try {
|
||||
await Backend.deleteConfig(configId);
|
||||
showToast('✅ Provider removed', 'success');
|
||||
loadProviderList();
|
||||
fetchModels(false);
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtmlUI(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('settingsModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
function renderChatHistory() {
|
||||
const container = document.getElementById('chatHistory');
|
||||
if (State.chats.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">No chat history</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = State.chats.map(chat => `
|
||||
<div class="chat-history-item ${chat.id === State.currentChatId ? 'active' : ''}"
|
||||
onclick="loadChat('${chat.id}')">
|
||||
<span class="title">${escapeHtml(chat.title)}</span>
|
||||
<div class="item-actions">
|
||||
<button class="item-btn delete" onclick="handleDeleteChat('${chat.id}', event)" title="Delete">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
newChat();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((msg, idx) => createMessageHTML(msg, idx))
|
||||
.join('');
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function createMessageHTML(message, index) {
|
||||
const isUser = message.role === 'user';
|
||||
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
|
||||
const formattedContent = formatMessage(message.content);
|
||||
|
||||
return `
|
||||
<div class="message ${message.role}" data-index="${index}">
|
||||
<div class="message-content">
|
||||
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||
<div class="message-body">
|
||||
<div class="message-header">
|
||||
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
|
||||
<div class="message-actions">
|
||||
<button class="message-action-btn" onclick="copyMessage(${index})" title="Copy">📋 Copy</button>
|
||||
_messageHTML(msg, index) {
|
||||
const isUser = msg.role === 'user';
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
|
||||
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>
|
||||
<div class="message-text">${formatMessage(msg.content)}</div>
|
||||
</div>
|
||||
${time ? `<div class="message-time">${time}</div>` : ''}
|
||||
<div class="message-text">${formattedContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
function formatMessage(content) {
|
||||
let formatted = content;
|
||||
const thinkingBlocks = [];
|
||||
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>`;
|
||||
},
|
||||
|
||||
// Extract thinking blocks FIRST (before escaping)
|
||||
if (State.settings.showThinking) {
|
||||
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
|
||||
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
|
||||
// Store raw content; we'll escape it when reinserting
|
||||
thinkingBlocks.push({ id: blockId, content: thinkingContent.trim() });
|
||||
return `__THINKPLACEHOLDER_${blockId}__`;
|
||||
});
|
||||
} else {
|
||||
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
||||
}
|
||||
// ── Streaming ────────────────────────────
|
||||
|
||||
// Escape HTML for the entire string (placeholders are plain text, safe to escape)
|
||||
formatted = escapeHtml(formatted);
|
||||
async streamResponse(response, messages) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
|
||||
// Restore thinking blocks with properly-escaped content (single escape only)
|
||||
for (const block of thinkingBlocks) {
|
||||
const escapedContent = escapeHtml(block.content).replace(/\n/g, '<br>');
|
||||
formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, `
|
||||
<div class="thinking-block" id="${block.id}">
|
||||
<div class="thinking-header" onclick="toggleThinkingBlock('${block.id}')">
|
||||
<span class="thinking-toggle">▼</span>
|
||||
<span class="thinking-label">💭 Thinking</span>
|
||||
// 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>
|
||||
<div class="thinking-content">${escapedContent}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
</div>`;
|
||||
container.appendChild(div);
|
||||
|
||||
// Code blocks with copy button
|
||||
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
|
||||
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let content = '';
|
||||
|
||||
// Inline code
|
||||
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Bold
|
||||
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
// Italic
|
||||
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
// Line breaks (but not inside pre/code)
|
||||
formatted = formatted.replace(/\n/g, '<br>');
|
||||
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 formatted;
|
||||
}
|
||||
return content;
|
||||
},
|
||||
|
||||
function toggleThinkingBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block) {
|
||||
block.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
// ── Model Selector ───────────────────────
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
updateModelSelector() {
|
||||
const sel = document.getElementById('modelSelect');
|
||||
const current = App.settings.model;
|
||||
sel.innerHTML = '';
|
||||
|
||||
function scrollToBottom() {
|
||||
const container = document.getElementById('chatMessages');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
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;
|
||||
opt.textContent = m.id + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRegenerateButton(messages) {
|
||||
const btn = document.getElementById('regenerateBtn');
|
||||
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
|
||||
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
|
||||
}
|
||||
if (current) {
|
||||
const exists = [...sel.options].some(o => o.value === current);
|
||||
if (exists) {
|
||||
sel.value = current;
|
||||
} else if (current) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = current;
|
||||
opt.textContent = current + ' (custom)';
|
||||
sel.insertBefore(opt, sel.firstChild);
|
||||
sel.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 5000);
|
||||
}
|
||||
// Also update settings modal model selector if open
|
||||
const settingsSel = document.getElementById('settingsModel');
|
||||
if (settingsSel) {
|
||||
settingsSel.innerHTML = sel.innerHTML;
|
||||
if (current) settingsSel.value = current;
|
||||
}
|
||||
},
|
||||
|
||||
// Message actions
|
||||
function copyMessage(index) {
|
||||
const chat = getCurrentChat();
|
||||
if (chat && chat.messages[index]) {
|
||||
navigator.clipboard.writeText(chat.messages[index].content)
|
||||
.then(() => showToast('📋 Copied to clipboard', 'success'))
|
||||
.catch(() => showToast('❌ Failed to copy', 'error'));
|
||||
}
|
||||
}
|
||||
// ── Connection Status ────────────────────
|
||||
|
||||
function copyCode(codeId) {
|
||||
const codeElement = document.getElementById(codeId);
|
||||
if (codeElement) {
|
||||
navigator.clipboard.writeText(codeElement.textContent)
|
||||
.then(() => showToast('📋 Code copied', 'success'))
|
||||
.catch(() => showToast('❌ Failed to copy', 'error'));
|
||||
}
|
||||
}
|
||||
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';
|
||||
},
|
||||
|
||||
function exportChat(format) {
|
||||
const chat = getCurrentChat();
|
||||
if (!chat) {
|
||||
showToast('⚠️ No chat to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
let content, filename, mimeType;
|
||||
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
|
||||
|
||||
switch (format) {
|
||||
case 'markdown':
|
||||
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');
|
||||
filename = `${title}.md`;
|
||||
mimeType = 'text/markdown';
|
||||
break;
|
||||
case 'json':
|
||||
showAdminButton(show) {
|
||||
document.getElementById('adminBtn').style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Generating State ─────────────────────
|
||||
|
||||
setGenerating(on) {
|
||||
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>`;
|
||||
container.appendChild(typing);
|
||||
this._scrollToBottom();
|
||||
} 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';
|
||||
|
||||
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. Add one to start chatting.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(c => `
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<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">Failed: ${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';
|
||||
},
|
||||
|
||||
// ── 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');
|
||||
document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`).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 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>`;
|
||||
}
|
||||
},
|
||||
|
||||
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 => `
|
||||
<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>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 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);
|
||||
filename = `${title}.json`;
|
||||
mimeType = 'application/json';
|
||||
break;
|
||||
case 'text':
|
||||
content = chat.messages
|
||||
.filter(m => m.role !== 'system')
|
||||
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');
|
||||
filename = `${title}.txt`;
|
||||
mimeType = 'text/plain';
|
||||
break;
|
||||
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'));
|
||||
}
|
||||
},
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
_scrollToBottom() {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast(`📥 Exported as ${format}`, 'success');
|
||||
document.getElementById('exportDropdown').classList.remove('show');
|
||||
};
|
||||
|
||||
// ── Formatting ───────────────────────────────
|
||||
|
||||
function formatMessage(content) {
|
||||
if (!content) return '';
|
||||
const thinkingBlocks = [];
|
||||
|
||||
let text = content;
|
||||
|
||||
// Extract thinking blocks
|
||||
if (App.settings.showThinking) {
|
||||
text = text.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (_, inner) => {
|
||||
const id = 'think-' + Math.random().toString(36).slice(2, 9);
|
||||
thinkingBlocks.push({ id, content: inner.trim() });
|
||||
return `__THINK_${id}__`;
|
||||
});
|
||||
} else {
|
||||
text = text.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
text = esc(text);
|
||||
|
||||
// Restore thinking blocks (single-escaped)
|
||||
for (const b of thinkingBlocks) {
|
||||
const inner = esc(b.content).replace(/\n/g, '<br>');
|
||||
text = text.replace(`__THINK_${b.id}__`,
|
||||
`<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`);
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const id = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
return `<pre><code id="${id}" class="lang-${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>`;
|
||||
});
|
||||
|
||||
// Inline formatting
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user