Changeset 0.6.0 (#36)

This commit is contained in:
2026-02-20 22:24:47 +00:00
parent 30d0c11219
commit 925b70f98c
34 changed files with 2575 additions and 637 deletions

View File

@@ -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);