Changeset 0.6.0 (#36)
This commit is contained in:
@@ -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}`);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
189
src/js/ui.js
189
src/js/ui.js
@@ -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>`; }
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user