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,10 +1,17 @@
// ==========================================
// Chat Switchboard API Client (v0.5.0)
// Chat Switchboard API Client (v0.6.2)
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
@@ -15,7 +22,7 @@ const API = {
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
@@ -25,7 +32,7 @@ const API = {
},
saveTokens() {
localStorage.setItem('sb_auth', JSON.stringify({
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
@@ -36,7 +43,7 @@ const API = {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem('sb_auth');
localStorage.removeItem(_storageKey);
},
get isAuthed() { return !!this.accessToken; },
@@ -45,7 +52,7 @@ const API = {
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) });
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return resp.json();
},
@@ -58,7 +65,7 @@ const API = {
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
this._setAuth(data);
if (!data.pending) this._setAuth(data);
return data;
},
@@ -92,36 +99,39 @@ const API = {
this.saveTokens();
},
// ── Chats ────────────────────────────────
// ── Channels ──────────────────────────────
listChats(page = 1, perPage = 100) {
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChat(title, model, systemPrompt) {
const body = { title };
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/chats', body);
return this._post('/api/v1/channels', body);
},
getChat(id) { return this._get(`/api/v1/chats/${id}`); },
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); },
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); },
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// ── Messages ─────────────────────────────
listMessages(chatId, page = 1, perPage = 200) {
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Completions ──────────────────────────
async streamCompletion(chatId, content, model, signal) {
const body = { chat_id: chatId, content, stream: true };
async streamCompletion(channelId, content, model, signal, apiConfigId) {
const body = { channel_id: channelId, content, stream: true };
if (model) body.model = model;
if (apiConfigId) body.api_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
let resp = await fetch('/api/v1/chat/completions', {
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
@@ -130,7 +140,7 @@ const API = {
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch('/api/v1/chat/completions', {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
@@ -182,6 +192,7 @@ const API = {
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
@@ -196,8 +207,12 @@ const API = {
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── User Models ──────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
// ── HTTP Internals ───────────────────────
_authHeaders() {
@@ -230,7 +245,7 @@ const API = {
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);

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

View File

@@ -191,7 +191,7 @@ const Events = {
}
// Add auth token as query param (WebSocket doesn't support headers)
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
if (tokens.access) {
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
} else {

View File

@@ -1,5 +1,5 @@
// ==========================================
// Chat Switchboard UI (v0.5.4)
// Chat Switchboard UI (v0.6.1)
// ==========================================
const UI = {
@@ -337,11 +337,33 @@ const UI = {
}
UI.loadProfileIntoSettings();
UI.loadProviderList();
UI.switchSettingsTab('general');
document.getElementById('settingsModal').classList.add('active');
},
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'providers') {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
},
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
if (!notice) return;
const allowed = App.serverSettings?.user_providers_enabled !== false;
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
@@ -398,64 +420,173 @@ const UI = {
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'settings') await this.loadAdminSettings();
},
async loadAdminUsers() {
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
el.innerHTML = '<div class="loading">Loading...</div>';
if (!quiet) 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 class="admin-user-row${!u.is_active ? ' user-inactive' : ''}">
<div class="admin-user-info">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${!u.is_active ? '<span class="badge-pending">pending</span>' : ''}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
<button onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Approve'}</button>
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>`).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminProviders() {
const el = document.getElementById('adminProviderList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
<div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div>
`).join('') || '<div class="empty-hint">No global providers</div>';
const s = await API.adminGetStats();
const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels() {
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(c => `
<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList');
el.innerHTML = '<div class="loading">Loading...</div>';
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(m => {
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) {
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
}
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
return `<div class="admin-model-row">
<span>${esc(m.model_id || m.id)}</span>
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<span>${m.is_enabled ? '' : ''}</span>
<button class="admin-model-toggle ${m.is_enabled ? 'enabled' : ''}" onclick="toggleModel('${m.id}', ${!m.is_enabled})">${m.is_enabled ? '✓ Enabled' : 'Disabled'}</button>
</div>`;
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
const settings = data.settings || data || [];
const arr = Array.isArray(settings) ? settings : [];
// Unwrap {value: X} wrapper from backend storage
const get = (key, fallback) => {
const s = arr.find(s => s.key === key);
if (!s) return fallback;
const v = s.value;
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
// Registration default state
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
// User providers
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
// Banner
const banner = get('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load presets
const presets = get('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
const bg = document.getElementById('adminBannerBg').value;
const fg = document.getElementById('adminBannerFg').value;
prev.textContent = text;
prev.style.background = bg;
prev.style.color = fg;
},
// ── User Model List ─────────────────────
async loadUserModels() {
const el = document.getElementById('userModelList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
const data = await API.listEnabledModels();
const models = data.models || [];
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available — add a provider in the section above</div>';
return;
}
el.innerHTML = models.map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
return `<div class="model-list-item">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},