Changeset 0.6.0 (#36)
This commit is contained in:
269
src/js/app.js
269
src/js/app.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (v0.5.4)
|
||||
// Chat Switchboard – Application (v0.6.1)
|
||||
// ==========================================
|
||||
|
||||
const App = {
|
||||
@@ -8,6 +8,7 @@ const App = {
|
||||
models: [],
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
|
||||
settings: {
|
||||
model: '',
|
||||
@@ -59,6 +60,7 @@ async function startApp() {
|
||||
await loadSettings();
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
await initBanners();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
@@ -67,7 +69,7 @@ async function startApp() {
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect('/ws');
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
@@ -206,10 +208,11 @@ async function fetchModels() {
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const resp = await API.listChats();
|
||||
const resp = await API.listChannels(1, 100, 'direct');
|
||||
App.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type || 'direct',
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [],
|
||||
@@ -255,7 +258,7 @@ async function newChat() {
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('Delete this chat?')) return;
|
||||
try {
|
||||
await API.deleteChat(chatId);
|
||||
await API.deleteChannel(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
if (App.currentChatId === chatId) newChat();
|
||||
UI.renderChatList();
|
||||
@@ -277,8 +280,8 @@ async function sendMessage() {
|
||||
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, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
@@ -295,8 +298,9 @@ async function sendMessage() {
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
const modelInfo = App.models.find(m => m.id === model);
|
||||
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, modelInfo?.configId);
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
@@ -340,12 +344,13 @@ async function regenerate() {
|
||||
if (lastUser.role !== 'user') return;
|
||||
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
const modelInfo = App.models.find(m => m.id === 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, modelInfo?.configId);
|
||||
const content = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
@@ -396,7 +401,18 @@ async function handleRegister() {
|
||||
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(); }
|
||||
try {
|
||||
const resp = await API.register(username, email, password);
|
||||
if (resp.pending) {
|
||||
setAuthError('');
|
||||
const errEl = document.getElementById('authError');
|
||||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||||
errEl.style.color = 'var(--accent)';
|
||||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||||
} else {
|
||||
await startApp();
|
||||
}
|
||||
}
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
@@ -418,6 +434,13 @@ function switchAuthTab(tab) {
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||||
const hdr = document.querySelector('.auth-card-header');
|
||||
if (hdr) {
|
||||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
hdr.querySelector('p').textContent = tab === 'login'
|
||||
? 'Sign in to continue to your workspace'
|
||||
: 'Get started with Chat Switchboard';
|
||||
}
|
||||
setAuthError('');
|
||||
}
|
||||
|
||||
@@ -440,6 +463,17 @@ function initListeners() {
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
document.getElementById('newChatDropdown').classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -451,8 +485,8 @@ function initListeners() {
|
||||
|
||||
// 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('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
|
||||
@@ -465,7 +499,7 @@ function initListeners() {
|
||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||
UI.updateCapabilityBadges();
|
||||
});
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
});
|
||||
@@ -473,6 +507,9 @@ function initListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||||
});
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.models.find(m => m.id === this.value);
|
||||
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||
@@ -503,12 +540,55 @@ function initListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin modal
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
// Admin modal (elements may not exist for non-admin users)
|
||||
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);
|
||||
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddUserForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — providers
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
|
||||
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||||
const preset = UI._bannerPresets[e.target.value];
|
||||
if (preset) {
|
||||
document.getElementById('adminBannerText').value = preset.text || '';
|
||||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
|
||||
UI.updateBannerPreview();
|
||||
}
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
// Sync color pickers with hex inputs
|
||||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
@@ -590,11 +670,168 @@ async function deleteProvider(id, name) {
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Registration
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('registration_enabled', { value: reg });
|
||||
UI.toast('Admin settings saved', 'success');
|
||||
|
||||
// Registration default state
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('registration_default_state', { value: regState });
|
||||
|
||||
// User providers
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
|
||||
|
||||
// Banner
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
position: document.getElementById('adminBannerPosition').value,
|
||||
bg: document.getElementById('adminBannerBg').value,
|
||||
fg: document.getElementById('adminBannerFg').value,
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply banner
|
||||
initBanners();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
|
||||
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
|
||||
async function toggleUserActive(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function toggleUserRole(id, role) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createAdminUser() {
|
||||
try {
|
||||
const u = document.getElementById('adminNewUsername').value.trim();
|
||||
const e = document.getElementById('adminNewEmail').value.trim();
|
||||
const p = document.getElementById('adminNewPassword').value;
|
||||
const r = document.getElementById('adminNewRole').value;
|
||||
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
|
||||
await API.adminCreateUser(u, e, p, r);
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteGlobalProvider(id) {
|
||||
if (!confirm('Delete this global provider?')) return;
|
||||
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createGlobalProvider() {
|
||||
try {
|
||||
const name = document.getElementById('adminProvName').value.trim();
|
||||
const prov = document.getElementById('adminProvType').value;
|
||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||
const key = document.getElementById('adminProvKey').value;
|
||||
const model = document.getElementById('adminProvModel').value.trim();
|
||||
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
|
||||
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||
UI.toast('Provider added', 'success');
|
||||
await UI.loadAdminProviders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function toggleModel(id, enabled) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateModel(id, { is_enabled: enabled });
|
||||
await UI.loadAdminModels();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkToggleModels(enabled) {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...';
|
||||
try {
|
||||
const resp = await API.adminBulkUpdateModels(enabled);
|
||||
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`;
|
||||
setTimeout(() => { hint.textContent = ''; }, 3000);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
try {
|
||||
const data = await API.getPublicSettings?.() || [];
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
|
||||
// Store globally for user-facing checks — unwrap {value: X} wrapper
|
||||
App.serverSettings = {};
|
||||
arr.forEach(s => {
|
||||
const v = s.value;
|
||||
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
const banner = App.serverSettings.banner;
|
||||
|
||||
// Clear previous banner state
|
||||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.classList.remove('active'); el.textContent = ''; }
|
||||
});
|
||||
root.style.setProperty('--banner-top-height', '0px');
|
||||
root.style.setProperty('--banner-bottom-height', '0px');
|
||||
|
||||
if (!banner || !banner.enabled) return;
|
||||
|
||||
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
|
||||
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
|
||||
|
||||
const text = banner.text || '';
|
||||
const pos = banner.position || 'both';
|
||||
|
||||
if (pos === 'top' || pos === 'both') {
|
||||
const el = document.getElementById('bannerTop');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-top-height', '22px');
|
||||
}
|
||||
if (pos === 'bottom' || pos === 'both') {
|
||||
const el = document.getElementById('bannerBottom');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-bottom-height', '22px');
|
||||
}
|
||||
} catch (e) {
|
||||
// Banners are optional — non-admin users may not have access to settings
|
||||
console.debug('Banner init skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in New Issue
Block a user