348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
// ==========================================
|
||
// 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'),
|
||
};
|
||
|
||
// 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');
|
||
},
|
||
|
||
// ── Login ─────────────────────────────────
|
||
|
||
async doLogin() {
|
||
const username = _val('loginUsername');
|
||
const password = _val('loginPassword');
|
||
const errEl = document.getElementById('loginError');
|
||
const btn = document.getElementById('loginBtn');
|
||
if (!username || !password) {
|
||
if (errEl) { errEl.textContent = 'Enter username and password'; errEl.style.display = ''; }
|
||
return;
|
||
}
|
||
if (errEl) errEl.style.display = 'none';
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
|
||
|
||
const base = window.__BASE__ || '';
|
||
try {
|
||
const resp = await fetch(base + '/api/v1/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ login: username, password }),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || `Login failed (${resp.status})`);
|
||
}
|
||
const data = await resp.json();
|
||
|
||
// Save tokens in same format as API._setAuth / API.saveTokens
|
||
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
|
||
localStorage.setItem(storageKey, JSON.stringify({
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token,
|
||
user: data.user,
|
||
}));
|
||
// Set cookie for server-rendered page auth
|
||
document.cookie = `sb_token=${data.access_token}; path=/; max-age=900; SameSite=Strict`;
|
||
|
||
// Redirect to chat surface
|
||
window.location.href = base + '/';
|
||
} catch (e) {
|
||
if (errEl) { errEl.textContent = e.message; errEl.style.display = ''; }
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Log In'; }
|
||
}
|
||
},
|
||
|
||
// ── 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;
|
||
}
|
||
}
|