Changeset 0.22.5 (#147)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-03 09:58:23 +00:00
committed by xcaliber
parent 3953dcf364
commit 45fe965c32
32 changed files with 3021 additions and 11 deletions

303
src/js/pages.js Normal file
View File

@@ -0,0 +1,303 @@
// ==========================================
// Chat Switchboard Pages (v0.22.5)
// ==========================================
// Client-side handlers for Go template-rendered pages.
// Works with server-rendered HTML — no DOM construction,
// just event handling and API calls.
//
// The server pre-populates all dropdowns. This JS handles:
// - Cascading model select (provider change → filter models)
// - Admin save operations (roles, routing, providers, teams, users, settings)
// - Table filtering
// ==========================================
const Pages = {
// ── Model Select Cascading ───────────────
roleProviderChanged(selectEl) {
const providerID = selectEl.value;
const filterType = selectEl.dataset.filterType || '';
const row = selectEl.closest('.form-row') || selectEl.parentElement;
const modelSelect = row.querySelector('.model-model-select');
if (!modelSelect) return;
const models = _pageData('Models') || [];
const filtered = models.filter(m =>
m.provider_config_id === providerID &&
(!filterType || m.model_type === filterType)
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${_esc(m.display_name || m.model_id)}</option>`
).join('');
},
// ── Role Save ────────────────────────────
async saveRole(roleName) {
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
if (!container) return;
const getValue = (slot, cls) => {
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
return sel ? sel.value : '';
};
const body = {
role: roleName,
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
: null,
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
: null,
};
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
resp ? _toast('Role saved', 'success') : null;
},
// ── Routing Policy ───────────────────────
showRoutingForm() { _show('routingPolicyForm'); },
hideRoutingForm() { _hide('routingPolicyForm'); },
routingScopeChanged(sel) {
const row = document.getElementById('routingTeamRow');
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
},
async saveRoutingPolicy() {
const id = _val('routingPolicyId');
const body = {
name: _val('routingPolicyName'),
priority: parseInt(_val('routingPolicyPriority') || '100'),
type: _val('routingPolicyType'),
scope: _val('routingPolicyScope'),
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
config: _parseJSON(_val('routingPolicyConfig')),
active: document.getElementById('routingPolicyActive')?.checked ?? true,
};
if (body.config === null) return; // parse error
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
if (ok) { this.hideRoutingForm(); window.location.reload(); }
},
// ── Providers ────────────────────────────
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
hideProviderForm() { _hide('providerFormWrap'); },
editProvider(id) {
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
if (!details) return;
_val('providerFormId', id);
_val('providerFormName', details.name);
_val('providerFormEndpoint', details.endpoint);
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
_show('providerFormWrap');
},
async saveProvider() {
const id = _val('providerFormId');
const body = {
name: _val('providerFormName'),
provider: _val('providerFormType'),
endpoint: _val('providerFormEndpoint'),
scope: _val('providerFormScope'),
};
const key = _val('providerFormKey');
if (key) body.api_key = key;
const teamId = _val('providerFormTeamId');
if (body.scope === 'team' && teamId) body.team_id = teamId;
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
if (ok) { this.hideProviderForm(); window.location.reload(); }
},
async syncProvider(id) {
_toast('Syncing…', 'info');
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
},
// ── Model Filtering ──────────────────────
filterModels(query) {
const q = (query || _val('modelSearch') || '').toLowerCase();
const typeF = _val('modelTypeFilter') || '';
const provF = _val('modelProviderFilter') || '';
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
const name = (tr.dataset.name || '').toLowerCase();
const type = tr.dataset.type || '';
const prov = tr.dataset.provider || '';
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
tr.style.display = show ? '' : 'none';
});
},
// ── Teams ────────────────────────────────
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
hideTeamForm() { _hide('teamFormWrap'); },
editTeam(id, name, desc) {
_val('teamFormId', id);
_val('teamFormName', name);
_val('teamFormDescription', desc);
document.getElementById('teamFormTitle').textContent = 'Edit Team';
_show('teamFormWrap');
},
async saveTeam() {
const id = _val('teamFormId');
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
if (ok) { this.hideTeamForm(); window.location.reload(); }
},
// ── Users ────────────────────────────────
filterUsers(query) {
const q = (query || '').toLowerCase();
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
});
},
async editUserRole(id, username, currentRole) {
const role = prompt(`Role for ${username}:`, currentRole);
if (!role || role === currentRole) return;
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
if (ok) window.location.reload();
},
async toggleUser(id, active) {
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
if (ok) window.location.reload();
},
// ── Settings ─────────────────────────────
async saveSettings() {
// Policies
const policies = {
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
};
// Banner
const banner = {
enabled: document.getElementById('settBannerEnabled')?.checked || false,
text: _val('settBannerText'),
bg: _val('settBannerBg'),
fg: _val('settBannerFg'),
position: 'both',
};
// System prompt
const system_prompt = { content: _val('settSystemPrompt') };
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
if (ok) _toast('Settings saved', 'success');
},
// ── User Settings (settings surface) ─────
async saveProfile() {
const name = _val('settingsDisplayName');
if (!name) { _toast('Display name is required', 'error'); return; }
const ok = await _api('PUT', '/api/v1/users/me', { display_name: name });
if (ok) _toast('Profile saved', 'success');
},
async changePassword() {
const current = _val('settingsCurrentPw');
const newPw = _val('settingsNewPw');
const confirm = _val('settingsConfirmPw');
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
const ok = await _api('PUT', '/api/v1/auth/password', { current_password: current, new_password: newPw });
if (ok) {
_toast('Password changed', 'success');
_val('settingsCurrentPw', '');
_val('settingsNewPw', '');
_val('settingsConfirmPw', '');
}
},
};
// ── Init: banner toggle ──────────────────────
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('settBannerEnabled');
const fields = document.getElementById('bannerFields');
if (cb && fields) {
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
fields.style.display = cb.checked ? '' : 'none';
}
});
// ── Helpers ──────────────────────────────────
function _pageData(key) {
const d = window.__PAGE_DATA__;
return d ? (d[key] || d[key.toLowerCase()]) : null;
}
function _val(id, setVal) {
const el = document.getElementById(id);
if (!el) return '';
if (setVal !== undefined) { el.value = setVal; return; }
return el.value;
}
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(msg, type);
} else {
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
}
}
function _parseJSON(text) {
if (!text || !text.trim()) return {};
try { return JSON.parse(text); }
catch (e) { _toast('Invalid JSON', 'error'); return null; }
}
async function _api(method, path, body) {
const base = window.__BASE__ || '';
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
let token = '';
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
token = saved.accessToken || '';
} catch (e) { /* ignore */ }
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (resp.ok) return true;
const err = await resp.json().catch(() => ({}));
_toast(err.error || `Error ${resp.status}`, 'error');
return false;
} catch (e) {
_toast('Connection error', 'error');
return false;
}
}