Changeset 0.37.7 (#219)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,917 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Admin Handlers
|
||||
// ==========================================
|
||||
// Admin actions: user management, roles, model visibility,
|
||||
// personas, team management, global providers.
|
||||
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
function _adminScroll() { return document.getElementById('adminMain'); }
|
||||
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 showApproveForm(userId, username) {
|
||||
document.getElementById('approveUserId').value = userId;
|
||||
document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
|
||||
const teamsEl = document.getElementById('approveTeamsList');
|
||||
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
|
||||
openModal('approveUserModal');
|
||||
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = (resp.data || []).filter(t => t.is_active);
|
||||
if (teams.length === 0) {
|
||||
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
|
||||
} else {
|
||||
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">Assign to teams:</label>' +
|
||||
teams.map(t => `<label class="toggle-label" style="margin-bottom:6px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> <span>${esc(t.name)}</span></label>`).join('');
|
||||
}
|
||||
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
|
||||
}
|
||||
|
||||
function hideApproveForm(userId) {
|
||||
closeModal('approveUserModal');
|
||||
}
|
||||
|
||||
async function submitApproval(userId) {
|
||||
if (!userId) userId = document.getElementById('approveUserId').value;
|
||||
const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(userId, true, teamIds, 'member');
|
||||
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
|
||||
UI.toast(msg, 'success');
|
||||
closeModal('approveUserModal');
|
||||
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 (!await showConfirm(`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);
|
||||
closeModal('createUserModal');
|
||||
// Clear form fields
|
||||
document.getElementById('adminNewUsername').value = '';
|
||||
document.getElementById('adminNewEmail').value = '';
|
||||
document.getElementById('adminNewPassword').value = '';
|
||||
document.getElementById('adminNewRole').value = 'user';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function adminResetUserPassword(id, username) {
|
||||
// Show the reset password modal
|
||||
document.getElementById('resetPwUser').textContent = username;
|
||||
document.getElementById('resetPwInput').value = '';
|
||||
openModal('resetPwModal');
|
||||
document.getElementById('resetPwInput').focus();
|
||||
|
||||
// Wire up the confirm button (remove old listener, add new)
|
||||
const btn = document.getElementById('resetPwConfirmBtn');
|
||||
const newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.replaceChild(newBtn, btn);
|
||||
newBtn.addEventListener('click', async () => {
|
||||
const pw = document.getElementById('resetPwInput').value;
|
||||
if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
|
||||
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
|
||||
try {
|
||||
await API.adminResetPassword(id, pw);
|
||||
closeModal('resetPwModal');
|
||||
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
|
||||
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try {
|
||||
const resp = await API.adminFetchModels();
|
||||
const errs = resp.errors || [];
|
||||
if (errs.length > 0) {
|
||||
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
|
||||
} else {
|
||||
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
|
||||
}
|
||||
hint.textContent = '';
|
||||
await UI.loadAdminModels();
|
||||
}
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function cycleModelVisibility(id, current) {
|
||||
// Cycle: disabled → enabled → team → disabled
|
||||
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
// Send both for backward compat with pre-0.8.3 backends
|
||||
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
|
||||
await UI.loadAdminModels();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetVisibility(visibility) {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
|
||||
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
|
||||
try {
|
||||
// Send both for backward compat with pre-0.8.3 backends
|
||||
const resp = await API.adminBulkUpdateModels(visibility);
|
||||
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
|
||||
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
|
||||
setTimeout(() => { hint.textContent = ''; }, 3000);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
|
||||
// ── Admin Roles — now managed by renderRoleConfig primitive in ui-admin.js ──
|
||||
|
||||
// ── User Model Preferences ──────────────────
|
||||
|
||||
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
|
||||
const compositeKey = (providerConfigId || '') + ':' + modelId;
|
||||
try {
|
||||
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
|
||||
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
|
||||
else App.hiddenModels.add(compositeKey);
|
||||
await UI.loadUserModels();
|
||||
await fetchModels(); // refresh model selector
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetUserModelVisibility(show) {
|
||||
const models = App.models.filter(m => !m.isPersona);
|
||||
if (!models.length) return;
|
||||
const shouldHide = !show;
|
||||
// Only update models not already in the desired state
|
||||
const toUpdate = models
|
||||
.filter(m => {
|
||||
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
|
||||
return App.hiddenModels.has(compositeKey) !== shouldHide;
|
||||
})
|
||||
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
|
||||
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
|
||||
try {
|
||||
await API.bulkSetModelPreferences(toUpdate, shouldHide);
|
||||
toUpdate.forEach(e => {
|
||||
const key = (e.provider_config_id || '') + ':' + e.model_id;
|
||||
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
|
||||
});
|
||||
await UI.loadUserModels();
|
||||
await fetchModels();
|
||||
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteUserPersona(id, name) {
|
||||
if (!await showConfirm(`Delete persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.deleteUserPersona(id);
|
||||
UI.toast('Persona deleted');
|
||||
await UI.loadUserPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Personas ────────────────────────────
|
||||
|
||||
var _adminPersonaForm = null;
|
||||
function ensureAdminPersonaForm() {
|
||||
if (_adminPersonaForm) return _adminPersonaForm;
|
||||
const container = document.getElementById('adminAddPersonaForm');
|
||||
if (!container) return null;
|
||||
_adminPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'adminPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: true,
|
||||
showKBPicker: true,
|
||||
kbScope: 'admin',
|
||||
onSubmit: (vals) => createAdminPersona(vals),
|
||||
onCancel: () => {
|
||||
closeModal('personaFormModal');
|
||||
_editingPersonaId = null;
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
}
|
||||
});
|
||||
// Override avatar handlers for edit-mode (upload immediately for existing personas)
|
||||
const fileInput = document.getElementById('adminPersona_avatarFileInput');
|
||||
if (fileInput) {
|
||||
// Remove default handler set by renderPersonaForm, add edit-aware one
|
||||
const newInput = fileInput.cloneNode(true);
|
||||
fileInput.parentNode.replaceChild(newInput, fileInput);
|
||||
newInput.addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (_editingPersonaId) {
|
||||
try {
|
||||
const data = await API.adminUploadPersonaAvatar(_editingPersonaId, reader.result);
|
||||
if (data.avatar) {
|
||||
_adminPersonaForm.updateAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPersonas(true);
|
||||
await fetchModels();
|
||||
UI.toast('Persona avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
} else {
|
||||
_adminPersonaForm.updateAvatarPreview(reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('adminPersona_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
|
||||
}
|
||||
const removeBtn = document.getElementById('adminPersona_avatarRemoveBtn');
|
||||
if (removeBtn) {
|
||||
const newBtn = removeBtn.cloneNode(true);
|
||||
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
|
||||
newBtn.addEventListener('click', async () => {
|
||||
if (_editingPersonaId) {
|
||||
try {
|
||||
await API.adminDeletePersonaAvatar(_editingPersonaId);
|
||||
_adminPersonaForm.updateAvatarPreview(null);
|
||||
await UI.loadAdminPersonas(true);
|
||||
await fetchModels();
|
||||
UI.toast('Persona avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
} else {
|
||||
_adminPersonaForm.updateAvatarPreview(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return _adminPersonaForm;
|
||||
}
|
||||
|
||||
var _editingPersonaId = null;
|
||||
function editAdminPersona(id) {
|
||||
const p = UI._personaCache?.[id];
|
||||
if (!p) return;
|
||||
_editingPersonaId = id;
|
||||
ensureAdminPersonaForm();
|
||||
const form = _adminPersonaForm;
|
||||
if (!form) return;
|
||||
document.getElementById('personaFormTitle').textContent = 'Edit Persona';
|
||||
openModal('personaFormModal');
|
||||
form.setValues(p);
|
||||
form.setSubmitLabel('Update');
|
||||
// Load KB bindings (v0.17.0)
|
||||
if (form.kbPicker) {
|
||||
loadPersonaKBs(id, form.kbPicker, '/api/v1/admin');
|
||||
}
|
||||
}
|
||||
|
||||
async function createAdminPersona(vals) {
|
||||
if (!vals) {
|
||||
if (_adminPersonaForm) vals = _adminPersonaForm.getValues();
|
||||
else return;
|
||||
}
|
||||
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
|
||||
|
||||
const persona = {
|
||||
name: vals.name,
|
||||
base_model_id: vals.base_model_id,
|
||||
description: vals.description || '',
|
||||
system_prompt: vals.system_prompt || '',
|
||||
};
|
||||
if (vals.provider_config_id) persona.provider_config_id = vals.provider_config_id;
|
||||
if (vals.temperature != null) persona.temperature = vals.temperature;
|
||||
if (vals.max_tokens != null) persona.max_tokens = vals.max_tokens;
|
||||
|
||||
try {
|
||||
let personaId = _editingPersonaId;
|
||||
if (_editingPersonaId) {
|
||||
await API.adminUpdatePersona(_editingPersonaId, persona);
|
||||
UI.toast('Persona updated', 'success');
|
||||
} else {
|
||||
const result = await API.adminCreatePersona(persona);
|
||||
personaId = result?.id || result?.persona?.id;
|
||||
UI.toast('Persona created', 'success');
|
||||
}
|
||||
|
||||
// Upload pending avatar for new personas
|
||||
if (!_editingPersonaId && personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Persona avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
// Save KB bindings (v0.17.0)
|
||||
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
|
||||
try { await API.adminSetPersonaKBs(personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('Persona KB binding failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPersonaId = null;
|
||||
closeModal('personaFormModal');
|
||||
if (_adminPersonaForm) {
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
}
|
||||
await UI.loadAdminPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleAdminPersona(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdatePersona(id, { is_active: active });
|
||||
await UI.loadAdminPersonas(true);
|
||||
_restoreScroll(el, pos);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAdminPersona(id, name) {
|
||||
if (!await showConfirm(`Delete persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.adminDeletePersona(id);
|
||||
UI.toast('Persona deleted', 'success');
|
||||
await UI.loadAdminPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Team Admin Functions ────────────────────
|
||||
|
||||
async function toggleTeamActive(id, active) {
|
||||
try {
|
||||
await API.adminUpdateTeam(id, { is_active: active });
|
||||
UI.toast(active ? 'Team activated' : 'Team deactivated');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTeam(id, name) {
|
||||
if (!await showConfirm(`Delete team "${name}"? All members will be removed.`)) return;
|
||||
try {
|
||||
await API.adminDeleteTeam(id);
|
||||
UI.toast('Team deleted');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.adminUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeamMember(teamId, memberId, email) {
|
||||
if (!await showConfirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.adminRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Settings-side team management (uses team admin API, not sys-admin)
|
||||
async function settingsUpdateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.teamUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function settingsRemoveTeamMember(teamId, memberId, email) {
|
||||
if (!await showConfirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.teamRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsDeleteTeamPersona(teamId, personaId, name) {
|
||||
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.teamDeletePersona(teamId, personaId);
|
||||
UI.toast('Persona deleted');
|
||||
await UI.loadTeamManagePersonas(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
|
||||
|
||||
// ── Group Actions ───────────────────────────
|
||||
|
||||
async function deleteGroup(id, name) {
|
||||
if (!await showConfirm(`Delete group "${name}"? Members will lose access to any resources granted via this group.`)) return;
|
||||
try { await API.adminDeleteGroup(id); UI.toast('Group deleted'); await UI.loadAdminGroups(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function removeGroupMember(groupId, userId, label) {
|
||||
if (!await showConfirm(`Remove ${label} from group?`)) return;
|
||||
try {
|
||||
await API.adminRemoveGroupMember(groupId, userId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadGroupMembers(groupId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveGrant(resourceType, resourceId) {
|
||||
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
|
||||
if (!scopeSel) return;
|
||||
const scope = scopeSel.value;
|
||||
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
|
||||
const selectedGroups = [...(groupsDiv?.querySelectorAll('.grant-group-cb:checked') || [])].map(cb => cb.value);
|
||||
|
||||
if (scope === 'groups' && selectedGroups.length === 0) {
|
||||
return UI.toast('Select at least one group', 'error');
|
||||
}
|
||||
|
||||
try {
|
||||
await API.adminSetGrant(resourceType, resourceId, scope, selectedGroups);
|
||||
// Remove the picker
|
||||
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
|
||||
UI.toast(scope === 'team_only' ? 'Access reverted to team only' : `Access set to ${scope}`);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initAdminListeners() {
|
||||
// Back button and category tab navigation are handled by the inline
|
||||
// script in admin.html (sessionStorage return URL + location.replace).
|
||||
// Only wire up section-specific action handlers here.
|
||||
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
openModal('createUserModal');
|
||||
});
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — approve user modal
|
||||
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
|
||||
|
||||
// Admin — providers (form managed by primitives in loadAdminProviders)
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
|
||||
document.getElementById('providerFormTitle').textContent = 'Add Provider';
|
||||
openModal('providerFormModal');
|
||||
});
|
||||
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — personas (shared form)
|
||||
document.getElementById('adminAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const form = ensureAdminPersonaForm();
|
||||
if (!form) return;
|
||||
_editingPersonaId = null;
|
||||
form.setSubmitLabel('Create');
|
||||
form.clearForm();
|
||||
document.getElementById('personaFormTitle').textContent = 'Create Persona';
|
||||
openModal('personaFormModal');
|
||||
});
|
||||
|
||||
// Admin — Teams
|
||||
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
|
||||
openModal('createTeamModal');
|
||||
document.getElementById('adminTeamName').focus();
|
||||
});
|
||||
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
|
||||
closeModal('createTeamModal');
|
||||
});
|
||||
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminTeamName').value.trim();
|
||||
const desc = document.getElementById('adminTeamDesc').value.trim();
|
||||
if (!name) return UI.toast('Team name required', 'error');
|
||||
try {
|
||||
await API.adminCreateTeam(name, desc);
|
||||
closeModal('createTeamModal');
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
UI.toast('Team created');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminTeamDetail').style.display = 'none';
|
||||
document.getElementById('adminTeamList').style.display = '';
|
||||
UI.loadAdminTeams(true);
|
||||
});
|
||||
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = '';
|
||||
await UI.loadMemberUserDropdown(UI._teamEditId);
|
||||
});
|
||||
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
|
||||
try {
|
||||
await API.adminUpdateTeam(UI._teamEditId, {
|
||||
settings: JSON.stringify({ require_private_providers: this.checked })
|
||||
});
|
||||
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
|
||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
||||
});
|
||||
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
|
||||
try {
|
||||
await API.adminUpdateTeam(UI._teamEditId, {
|
||||
settings: JSON.stringify({ allow_team_providers: this.checked })
|
||||
});
|
||||
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
|
||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
||||
});
|
||||
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
|
||||
const userId = document.getElementById('adminMemberUser').value;
|
||||
const role = document.getElementById('adminMemberRole').value;
|
||||
if (!userId) return UI.toast('Select a user', 'error');
|
||||
try {
|
||||
await API.adminAddMember(UI._teamEditId, userId, role);
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
UI.toast('Member added');
|
||||
await UI.loadTeamMembers(UI._teamEditId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// ── Group management ────────────────────────
|
||||
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
|
||||
openModal('createGroupModal');
|
||||
document.getElementById('adminGroupName').focus();
|
||||
// Populate team dropdown
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = (resp.data || []).filter(t => t.is_active);
|
||||
const sel = document.getElementById('adminGroupTeam');
|
||||
sel.innerHTML = '<option value="">Select team...</option>' +
|
||||
teams.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
|
||||
} catch (e) { /* proceed without teams */ }
|
||||
});
|
||||
document.getElementById('adminGroupScope')?.addEventListener('change', function() {
|
||||
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
|
||||
closeModal('createGroupModal');
|
||||
});
|
||||
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminGroupName').value.trim();
|
||||
const desc = document.getElementById('adminGroupDesc').value.trim();
|
||||
const scope = document.getElementById('adminGroupScope').value;
|
||||
const teamId = scope === 'team' ? document.getElementById('adminGroupTeam').value : null;
|
||||
if (!name) return UI.toast('Group name required', 'error');
|
||||
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
|
||||
try {
|
||||
await API.adminCreateGroup(name, desc, scope, teamId);
|
||||
closeModal('createGroupModal');
|
||||
document.getElementById('adminGroupName').value = '';
|
||||
document.getElementById('adminGroupDesc').value = '';
|
||||
UI.toast('Group created');
|
||||
await UI.loadAdminGroups(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
document.getElementById('adminGroupBackBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminGroupDetail').style.display = 'none';
|
||||
document.getElementById('adminGroupList').style.display = '';
|
||||
UI.loadAdminGroups(true);
|
||||
});
|
||||
document.getElementById('adminAddGroupMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('adminAddGroupMemberForm').style.display = '';
|
||||
await UI.loadGroupMemberDropdown(UI._groupEditId);
|
||||
});
|
||||
document.getElementById('adminCancelGroupMemberBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminAddGroupMemberSubmit')?.addEventListener('click', async () => {
|
||||
const userId = document.getElementById('adminGroupMemberUser').value;
|
||||
if (!userId) return UI.toast('Select a user', 'error');
|
||||
try {
|
||||
await API.adminAddGroupMember(UI._groupEditId, userId);
|
||||
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
|
||||
UI.toast('Member added');
|
||||
await UI.loadGroupMembers(UI._groupEditId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// ── Extension management ────────────────────
|
||||
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminInstallExtForm').style.display = '';
|
||||
});
|
||||
document.getElementById('adminInstallExtSubmit')?.addEventListener('click', async () => {
|
||||
const extId = document.getElementById('extInstallId').value.trim();
|
||||
const name = document.getElementById('extInstallName').value.trim();
|
||||
if (!extId || !name) return UI.toast('ID and Name are required', 'error');
|
||||
|
||||
// Build manifest: merge user-provided JSON with _script
|
||||
let manifest = {};
|
||||
const manifestRaw = document.getElementById('extInstallManifest').value.trim();
|
||||
if (manifestRaw) {
|
||||
try { manifest = JSON.parse(manifestRaw); }
|
||||
catch { return UI.toast('Invalid manifest JSON', 'error'); }
|
||||
}
|
||||
const script = document.getElementById('extInstallScript').value;
|
||||
if (script) manifest._script = script;
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/admin/extensions', {
|
||||
ext_id: extId,
|
||||
name: name,
|
||||
version: document.getElementById('extInstallVersion').value.trim() || '1.0.0',
|
||||
author: document.getElementById('extInstallAuthor').value.trim(),
|
||||
description: document.getElementById('extInstallDesc').value.trim(),
|
||||
manifest: manifest,
|
||||
is_system: document.getElementById('extInstallSystem').checked,
|
||||
is_enabled: document.getElementById('extInstallEnabled').checked,
|
||||
});
|
||||
document.getElementById('adminInstallExtForm').style.display = 'none';
|
||||
// Clear form
|
||||
['extInstallId','extInstallName','extInstallVersion','extInstallAuthor','extInstallDesc','extInstallManifest','extInstallScript'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = el.type === 'checkbox' ? '' : (el.tagName === 'TEXTAREA' ? '' : '');
|
||||
});
|
||||
document.getElementById('extInstallVersion').value = '1.0.0';
|
||||
UI.toast('Extension installed');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.on('role.fallback', (payload) => {
|
||||
const banner = document.getElementById('roleFallbackBanner');
|
||||
if (!banner) return;
|
||||
const msg = payload?.message || 'A model role is using its fallback provider.';
|
||||
banner.querySelector('.fallback-msg').textContent = msg;
|
||||
banner.style.display = '';
|
||||
// Auto-dismiss after 60s
|
||||
clearTimeout(banner._dismissTimer);
|
||||
banner._dismissTimer = setTimeout(() => { banner.style.display = 'none'; }, 60000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension admin actions (global scope for onclick) ──
|
||||
|
||||
async function toggleAdminExtension(id, enabled) {
|
||||
try {
|
||||
await API._put(`/api/v1/admin/extensions/${id}`, { is_enabled: enabled });
|
||||
UI.toast(enabled ? 'Extension enabled' : 'Extension disabled');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAdminExtension(id, name) {
|
||||
if (!await showConfirm(`Uninstall extension "${name}"? This cannot be undone.`, { danger: true })) return;
|
||||
try {
|
||||
await API._del(`/api/v1/admin/extensions/${id}`);
|
||||
UI.toast('Extension uninstalled');
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Track CM6 editor instances per extension edit form
|
||||
var _extEditors = {};
|
||||
|
||||
// Listen for theme changes to update CM6 editors
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.on('theme.changed', (data) => {
|
||||
const isDark = data.resolved === 'dark';
|
||||
for (const editors of Object.values(_extEditors)) {
|
||||
editors.manifest?.setDarkMode(isDark);
|
||||
editors.script?.setDarkMode(isDark);
|
||||
}
|
||||
});
|
||||
|
||||
sw.on('keymap.changed', (data) => {
|
||||
const mode = data.mode || 'standard';
|
||||
for (const editors of Object.values(_extEditors)) {
|
||||
editors.manifest?.setKeymap(mode);
|
||||
editors.script?.setKeymap(mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editAdminExtension(id) {
|
||||
// Close any existing edit form (and destroy CM6 instances)
|
||||
document.querySelectorAll('.ext-edit-form').forEach(el => {
|
||||
const eid = el.dataset.extId;
|
||||
if (_extEditors[eid]) {
|
||||
_extEditors[eid].manifest?.destroy();
|
||||
_extEditors[eid].script?.destroy();
|
||||
delete _extEditors[eid];
|
||||
}
|
||||
el.remove();
|
||||
});
|
||||
|
||||
const ext = (UI._adminExtensions || []).find(e => e.id === id);
|
||||
if (!ext) return UI.toast('Extension not found', 'error');
|
||||
|
||||
// Extract manifest and _script separately
|
||||
let manifest = {};
|
||||
try { manifest = typeof ext.manifest === 'string' ? JSON.parse(ext.manifest) : (ext.manifest || {}); }
|
||||
catch { manifest = {}; }
|
||||
const script = manifest._script || '';
|
||||
// Show manifest without _script for cleaner editing
|
||||
const manifestClean = { ...manifest };
|
||||
delete manifestClean._script;
|
||||
const manifestJSON = JSON.stringify(manifestClean, null, 2);
|
||||
|
||||
const systemWarning = ext.is_system
|
||||
? `<div style="background:var(--bg-3);border:1px solid var(--warning,#f39c12);border-radius:6px;padding:8px 12px;font-size:12px;margin-bottom:8px;color:var(--warning,#f39c12)">
|
||||
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Use container divs instead of textareas when CM6 is available
|
||||
const useCM6 = !!window.CM?.codeEditor;
|
||||
const manifestEditor = useCM6
|
||||
? `<div id="extEdit-manifest-${id}" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
|
||||
: `<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>`;
|
||||
const scriptEditor = useCM6
|
||||
? `<div id="extEdit-script-${id}" style="width:100%;min-height:320px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
|
||||
: `<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>`;
|
||||
|
||||
const formHTML = `
|
||||
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
|
||||
${systemWarning}
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Name</label>
|
||||
<input type="text" id="extEdit-name-${id}" value="${esc(ext.name)}" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Description</label>
|
||||
<input type="text" id="extEdit-desc-${id}" value="${esc(ext.description || '')}" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
|
||||
${manifestEditor}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
|
||||
${scriptEditor}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn-small" data-action="_closeExtEditForm" data-args='${JSON.stringify([id])}'">Cancel</button>
|
||||
<button class="btn-small btn-primary" data-action="saveAdminExtension" data-args='${JSON.stringify([id])}'">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Find the extension row and insert the form after it
|
||||
const rows = document.querySelectorAll('#adminExtensionsList tbody tr');
|
||||
for (const row of rows) {
|
||||
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
|
||||
row.insertAdjacentHTML('afterend', formHTML);
|
||||
|
||||
if (useCM6) {
|
||||
// Get user keybinding preference
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const keymapMode = prefs.editorKeymap || 'standard';
|
||||
|
||||
// Initialize CM6 editors
|
||||
_extEditors[id] = {
|
||||
manifest: CM.codeEditor(document.getElementById(`extEdit-manifest-${id}`), {
|
||||
language: 'json',
|
||||
value: manifestJSON,
|
||||
lineNumbers: true,
|
||||
keymap: keymapMode,
|
||||
}),
|
||||
script: CM.codeEditor(document.getElementById(`extEdit-script-${id}`), {
|
||||
language: 'javascript',
|
||||
value: script,
|
||||
lineNumbers: true,
|
||||
keymap: keymapMode,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Fallback: Tab key inserts tab in script textarea
|
||||
const scriptEl = document.getElementById(`extEdit-script-${id}`);
|
||||
if (scriptEl) {
|
||||
scriptEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = scriptEl.selectionStart;
|
||||
const end = scriptEl.selectionEnd;
|
||||
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
|
||||
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _closeExtEditForm(id) {
|
||||
if (_extEditors[id]) {
|
||||
_extEditors[id].manifest?.destroy();
|
||||
_extEditors[id].script?.destroy();
|
||||
delete _extEditors[id];
|
||||
}
|
||||
document.querySelector(`.ext-edit-form[data-ext-id="${id}"]`)?.remove();
|
||||
}
|
||||
|
||||
async function saveAdminExtension(id) {
|
||||
const nameEl = document.getElementById(`extEdit-name-${id}`);
|
||||
const descEl = document.getElementById(`extEdit-desc-${id}`);
|
||||
|
||||
// Read from CM6 if available, otherwise from textarea
|
||||
const editors = _extEditors[id];
|
||||
const manifestText = editors?.manifest
|
||||
? editors.manifest.getValue()
|
||||
: document.getElementById(`extEdit-manifest-${id}`)?.value;
|
||||
const scriptText = editors?.script
|
||||
? editors.script.getValue()
|
||||
: document.getElementById(`extEdit-script-${id}`)?.value;
|
||||
|
||||
if (!nameEl || manifestText == null || scriptText == null) return;
|
||||
|
||||
// Parse manifest and merge _script back in
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse((manifestText || '').trim() || '{}');
|
||||
} catch (e) {
|
||||
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
if (scriptText.trim()) {
|
||||
manifest._script = scriptText;
|
||||
}
|
||||
|
||||
try {
|
||||
await API._put(`/api/v1/admin/extensions/${id}`, {
|
||||
name: nameEl.value.trim(),
|
||||
description: descEl.value.trim(),
|
||||
manifest: manifest,
|
||||
});
|
||||
UI.toast('Extension updated — reload page to apply changes');
|
||||
_closeExtEditForm(id);
|
||||
await UI.loadAdminExtensions();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('_adminPersonaForm', _adminPersonaForm);
|
||||
sb.register('_initAdminListeners', _initAdminListeners);
|
||||
sb.register('adminResetUserPassword', adminResetUserPassword);
|
||||
sb.register('bulkSetUserModelVisibility', bulkSetUserModelVisibility);
|
||||
sb.register('cycleModelVisibility', cycleModelVisibility);
|
||||
sb.register('deleteAdminExtension', deleteAdminExtension);
|
||||
sb.register('deleteAdminPersona', deleteAdminPersona);
|
||||
sb.register('deleteGroup', deleteGroup);
|
||||
sb.register('deleteTeam', deleteTeam);
|
||||
sb.register('deleteUser', deleteUser);
|
||||
sb.register('deleteUserPersona', deleteUserPersona);
|
||||
sb.register('editAdminExtension', editAdminExtension);
|
||||
sb.register('editAdminPersona', editAdminPersona);
|
||||
sb.register('ensureAdminPersonaForm', ensureAdminPersonaForm);
|
||||
sb.register('removeGroupMember', removeGroupMember);
|
||||
sb.register('removeTeamMember', removeTeamMember);
|
||||
sb.register('saveGrant', saveGrant);
|
||||
sb.register('settingsDeleteTeamPersona', settingsDeleteTeamPersona);
|
||||
sb.register('settingsRemoveTeamMember', settingsRemoveTeamMember);
|
||||
sb.register('settingsUpdateTeamMember', settingsUpdateTeamMember);
|
||||
sb.register('showApproveForm', showApproveForm);
|
||||
sb.register('toggleAdminExtension', toggleAdminExtension);
|
||||
sb.register('toggleAdminPersona', toggleAdminPersona);
|
||||
sb.register('toggleTeamActive', toggleTeamActive);
|
||||
sb.register('toggleUserActive', toggleUserActive);
|
||||
sb.register('toggleUserModelVisibility', toggleUserModelVisibility);
|
||||
sb.register('toggleUserRole', toggleUserRole);
|
||||
sb.register('updateTeamMember', updateTeamMember);
|
||||
sb.register('_closeExtEditForm', _closeExtEditForm);
|
||||
sb.register('saveAdminExtension', saveAdminExtension);
|
||||
@@ -1,380 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Admin Packages UI
|
||||
// ==========================================
|
||||
// Renders in the "Packages" admin section via ADMIN_LOADERS.
|
||||
// Replaces admin-surfaces.js (v0.28.7).
|
||||
// v0.30.0: scope column, settings, export, registry browse.
|
||||
//
|
||||
// Exports: window._loadAdminPackages
|
||||
|
||||
async function _loadAdminPackages() {
|
||||
const container = document.getElementById('adminPackagesContent');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML =
|
||||
'<div style="margin-bottom:16px">' +
|
||||
'<p class="text-muted" style="font-size:12px;margin-bottom:12px">' +
|
||||
'Surfaces, extensions, and full packages. Install .pkg or .surface archives.' +
|
||||
'</p>' +
|
||||
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="pkgFilterAll">All</button>' +
|
||||
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
|
||||
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
|
||||
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
'<button class="btn-small" id="pkgBrowseRegistry">Browse Registry</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
|
||||
'<h4 style="margin:0 0 8px 0;font-size:13px">Install Package</h4>' +
|
||||
'<input type="file" id="pkgInstallFile" accept=".pkg,.surface,.zip" style="font-size:12px">' +
|
||||
'<div style="display:flex;gap:8px;margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="pkgInstallSubmit">Upload & Install</button>' +
|
||||
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminPkgSettingsPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
|
||||
'<div id="adminPkgRegistryPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
|
||||
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
var base = document.body.dataset.basePath || '';
|
||||
var currentFilter = '';
|
||||
|
||||
// Filter buttons
|
||||
['All', 'Surface', 'Extension', 'Full'].forEach(function(label) {
|
||||
var btn = document.getElementById('pkgFilter' + label);
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
currentFilter = label === 'All' ? '' : label.toLowerCase();
|
||||
document.getElementById('adminPkgRegistryPanel').style.display = 'none';
|
||||
loadList();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Browse Registry button
|
||||
var browseBtn = document.getElementById('pkgBrowseRegistry');
|
||||
if (browseBtn) {
|
||||
browseBtn.addEventListener('click', loadRegistry);
|
||||
}
|
||||
|
||||
// Install form
|
||||
var installSubmit = document.getElementById('pkgInstallSubmit');
|
||||
var installCancel = document.getElementById('pkgInstallCancel');
|
||||
if (installSubmit) {
|
||||
installSubmit.addEventListener('click', async function() {
|
||||
var fileInput = document.getElementById('pkgInstallFile');
|
||||
if (!fileInput || !fileInput.files[0]) {
|
||||
UI.toast('Select a .pkg, .surface, or .zip file', 'warning');
|
||||
return;
|
||||
}
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
try {
|
||||
var resp = await fetch(base + '/api/v1/admin/packages/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + API.accessToken },
|
||||
body: fd,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var err = await resp.json();
|
||||
UI.toast(err.error || 'Install failed', 'error');
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
UI.toast('Installed: ' + (data.title || data.id), 'success');
|
||||
fileInput.value = '';
|
||||
document.getElementById('adminPkgInstallForm').style.display = 'none';
|
||||
loadList();
|
||||
} catch (e) {
|
||||
UI.toast('Install failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (installCancel) {
|
||||
installCancel.addEventListener('click', function() {
|
||||
document.getElementById('adminPkgInstallForm').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
var listEl = document.getElementById('adminPkgList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
var url = '/api/v1/admin/packages';
|
||||
if (currentFilter) url += '?type=' + currentFilter;
|
||||
var resp = await API._get(url);
|
||||
var packages = resp.data || [];
|
||||
|
||||
if (packages.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No packages found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
|
||||
'<th style="width:25%">Package</th>' +
|
||||
'<th>Type</th>' +
|
||||
'<th>Version</th>' +
|
||||
'<th>Scope</th>' +
|
||||
'<th>Source</th>' +
|
||||
'<th>Status</th>' +
|
||||
'<th style="width:140px">Actions</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
|
||||
packages.forEach(function(pkg) {
|
||||
var typeBadge = {
|
||||
surface: '<span class="badge badge-info">surface</span>',
|
||||
extension: '<span class="badge badge-accent">extension</span>',
|
||||
full: '<span class="badge badge-warning">full</span>',
|
||||
}[pkg.type] || '<span class="badge">' + pkg.type + '</span>';
|
||||
|
||||
var sourceBadge = {
|
||||
core: '<span class="text-muted">core</span>',
|
||||
builtin: '<span class="text-muted">builtin</span>',
|
||||
extension: '<span class="text-muted">installed</span>',
|
||||
registry: '<span class="text-muted">registry</span>',
|
||||
}[pkg.source] || pkg.source;
|
||||
|
||||
var scopeBadge = {
|
||||
global: '<span class="text-muted">global</span>',
|
||||
team: '<span class="badge badge-info">team</span>',
|
||||
personal: '<span class="badge badge-accent">personal</span>',
|
||||
}[pkg.scope] || pkg.scope;
|
||||
|
||||
var statusBadge = pkg.enabled
|
||||
? '<span class="badge badge-success">enabled</span>'
|
||||
: '<span class="badge badge-muted">disabled</span>';
|
||||
|
||||
var actions = '';
|
||||
// Toggle enable/disable (not for chat/admin)
|
||||
if (pkg.id !== 'chat' && pkg.id !== 'admin') {
|
||||
if (pkg.enabled) {
|
||||
actions += '<button class="btn-small btn-ghost pkg-disable" data-id="' + pkg.id + '" title="Disable">Disable</button> ';
|
||||
} else {
|
||||
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
|
||||
}
|
||||
}
|
||||
// Settings (if manifest has settings)
|
||||
if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) {
|
||||
actions += '<button class="btn-small btn-ghost pkg-settings" data-id="' + pkg.id + '" title="Settings">Settings</button> ';
|
||||
}
|
||||
// Export (non-core only)
|
||||
if (pkg.source !== 'core') {
|
||||
actions += '<button class="btn-small btn-ghost pkg-export" data-id="' + pkg.id + '" title="Export">Export</button> ';
|
||||
}
|
||||
// Delete (non-core only)
|
||||
if (pkg.source !== 'core') {
|
||||
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
|
||||
}
|
||||
|
||||
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
|
||||
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
|
||||
var schemaVer = pkg.schema_version > 0 ? ' <span class="text-muted" style="font-size:10px">v' + pkg.schema_version + '</span>' : '';
|
||||
|
||||
html += '<tr>' +
|
||||
'<td><strong>' + pkg.title + '</strong>' + system + schemaVer + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
|
||||
'<td>' + typeBadge + '</td>' +
|
||||
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '\u2014') + '</td>' +
|
||||
'<td>' + scopeBadge + '</td>' +
|
||||
'<td>' + sourceBadge + '</td>' +
|
||||
'<td>' + statusBadge + '</td>' +
|
||||
'<td>' + actions + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
listEl.innerHTML = html;
|
||||
|
||||
// Wire action buttons
|
||||
listEl.querySelectorAll('.pkg-enable').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/enable');
|
||||
UI.toast('Enabled', 'success');
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.pkg-disable').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/disable');
|
||||
UI.toast('Disabled', 'success');
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.pkg-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
if (!confirm('Delete package "' + btn.dataset.id + '"? This cannot be undone.')) return;
|
||||
await API._del('/api/v1/admin/packages/' + btn.dataset.id);
|
||||
UI.toast('Deleted', 'success');
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.pkg-export').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
window.location = base + '/api/v1/admin/packages/' + btn.dataset.id + '/export';
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.pkg-settings').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
loadSettings(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings panel ──────────────────────────
|
||||
|
||||
async function loadSettings(pkgId) {
|
||||
var panel = document.getElementById('adminPkgSettingsPanel');
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = '<div class="loading">Loading settings...</div>';
|
||||
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/packages/' + pkgId + '/settings');
|
||||
var schema = resp.schema || [];
|
||||
var values = resp.values || {};
|
||||
|
||||
if (typeof values === 'string') {
|
||||
try { values = JSON.parse(values); } catch(e) { values = {}; }
|
||||
}
|
||||
|
||||
if (schema.length === 0) {
|
||||
panel.innerHTML = '<p class="text-muted" style="font-size:12px">No settings declared by this package.</p>' +
|
||||
'<button class="btn-small" id="pkgSettingsClose">Close</button>';
|
||||
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<h4 style="margin:0 0 12px 0;font-size:13px">Settings: ' + pkgId + '</h4>';
|
||||
schema.forEach(function(s) {
|
||||
var key = s.key;
|
||||
var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : '');
|
||||
html += '<div style="margin-bottom:10px">' +
|
||||
'<label style="font-size:12px;display:block;margin-bottom:4px">' + (s.label || key) + '</label>';
|
||||
|
||||
if (s.type === 'select' && s.options) {
|
||||
html += '<select class="pkg-setting-input" data-key="' + key + '" style="font-size:12px;padding:4px 8px">';
|
||||
s.options.forEach(function(opt) {
|
||||
html += '<option value="' + opt + '"' + (opt === val ? ' selected' : '') + '>' + opt + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
} else if (s.type === 'boolean') {
|
||||
html += '<input type="checkbox" class="pkg-setting-input" data-key="' + key + '" data-type="boolean"' + (val ? ' checked' : '') + '>';
|
||||
} else if (s.type === 'number') {
|
||||
html += '<input type="number" class="pkg-setting-input" data-key="' + key + '" data-type="number" value="' + val + '" style="font-size:12px;padding:4px 8px;width:100px">';
|
||||
} else {
|
||||
html += '<input type="text" class="pkg-setting-input" data-key="' + key + '" value="' + val + '" style="font-size:12px;padding:4px 8px;width:200px">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||
'<button class="btn-small btn-primary" id="pkgSettingsSave">Save</button>' +
|
||||
'<button class="btn-small" id="pkgSettingsClose">Close</button>' +
|
||||
'</div>';
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
document.getElementById('pkgSettingsSave').addEventListener('click', async function() {
|
||||
var data = {};
|
||||
panel.querySelectorAll('.pkg-setting-input').forEach(function(el) {
|
||||
var key = el.dataset.key;
|
||||
if (el.dataset.type === 'boolean') {
|
||||
data[key] = el.checked;
|
||||
} else if (el.dataset.type === 'number') {
|
||||
data[key] = parseFloat(el.value) || 0;
|
||||
} else {
|
||||
data[key] = el.value;
|
||||
}
|
||||
});
|
||||
try {
|
||||
await API._put('/api/v1/admin/packages/' + pkgId + '/settings', data);
|
||||
UI.toast('Settings saved', 'success');
|
||||
panel.style.display = 'none';
|
||||
} catch (e) {
|
||||
UI.toast('Failed to save: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||
} catch (e) {
|
||||
panel.innerHTML = '<div style="color:var(--danger)">Failed to load settings: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry browse ─────────────────────────
|
||||
|
||||
async function loadRegistry() {
|
||||
var panel = document.getElementById('adminPkgRegistryPanel');
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = '<div class="loading">Loading registry...</div>';
|
||||
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/packages/registry');
|
||||
var packages = resp.packages || [];
|
||||
var registryUrl = resp.registry_url || '';
|
||||
|
||||
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<h4 style="margin:0;font-size:13px">Package Registry</h4>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
'<button class="btn-small" id="pkgRegistryClose">Close</button>' +
|
||||
'</div>';
|
||||
|
||||
if (!registryUrl) {
|
||||
html += '<p class="text-muted" style="font-size:12px">No registry URL configured. Set <code>package_registry</code> in Admin Settings with a <code>{"url": "https://..."}</code> value.</p>';
|
||||
} else if (packages.length === 0) {
|
||||
html += '<p class="text-muted" style="font-size:12px">Registry is empty or returned no packages.</p>';
|
||||
} else {
|
||||
html += '<table class="admin-table" style="width:100%"><thead><tr>' +
|
||||
'<th style="width:30%">Package</th><th>Version</th><th>Author</th><th>Actions</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
|
||||
packages.forEach(function(pkg) {
|
||||
var installBtn = pkg.installed
|
||||
? '<span class="badge badge-success">Installed</span>'
|
||||
: '<button class="btn-small btn-primary registry-install" data-url="' + pkg.download_url + '">Install</button>';
|
||||
|
||||
html += '<tr>' +
|
||||
'<td><strong>' + pkg.title + '</strong><div class="text-muted" style="font-size:11px">' +
|
||||
(pkg.description || '') + '</div></td>' +
|
||||
'<td style="font-family:monospace;font-size:12px">' + (pkg.version || '') + '</td>' +
|
||||
'<td style="font-size:12px">' + (pkg.author || '') + '</td>' +
|
||||
'<td>' + installBtn + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
document.getElementById('pkgRegistryClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||
|
||||
panel.querySelectorAll('.registry-install').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing...';
|
||||
try {
|
||||
await API._post('/api/v1/admin/packages/registry/install', {
|
||||
download_url: btn.dataset.url,
|
||||
});
|
||||
UI.toast('Installed from registry', 'success');
|
||||
loadList();
|
||||
loadRegistry(); // refresh to show "Installed" badge
|
||||
} catch (e) {
|
||||
UI.toast('Install failed: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Install';
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
panel.innerHTML = '<div style="color:var(--danger)">Failed to load registry: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadList();
|
||||
}
|
||||
|
||||
sb.register('_loadAdminPackages', _loadAdminPackages);
|
||||
@@ -1,367 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - Admin Surface Scaffold
|
||||
// ==========================================
|
||||
// Injects section-specific DOM into adminDynamic, wires listeners,
|
||||
// and calls the appropriate ADMIN_LOADERS[section]() from ui-admin.js.
|
||||
// Loaded only on the admin surface (/admin/:section).
|
||||
|
||||
|
||||
// Each admin section loader (ui-admin.js) expects specific container
|
||||
// elements. SCAFFOLDING maps section name -> HTML to inject before
|
||||
// the loader runs.
|
||||
var SCAFFOLDING = {};
|
||||
|
||||
// -- People -----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.users =
|
||||
'<div id="adminUserList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.teams =
|
||||
'<div id="adminTeamList" class="admin-list"></div>' +
|
||||
'<div id="adminTeamDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminTeamBackBtn">← Back</button>' +
|
||||
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:12px">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminTeamPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminTeamAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>' +
|
||||
'</div>' +
|
||||
'<div id="adminMemberList" class="admin-list"></div>' +
|
||||
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>User</label><select id="adminMemberUser"><option value="">Select user...</option></select></div>' +
|
||||
'<div class="form-group"><label>Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminAddMemberSubmit">Add Member</button>' +
|
||||
'<button class="btn-small" id="adminCancelMemberBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small" id="adminAddMemberBtn" style="margin-top:8px">+ Add Member</button>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.groups =
|
||||
'<div id="adminGroupList" class="admin-list"></div>' +
|
||||
'<div id="adminGroupDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminGroupBackBtn">← Back</button>' +
|
||||
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
|
||||
'<span id="adminGroupDetailMeta" class="text-muted"></span>' +
|
||||
'</div>' +
|
||||
|
||||
/* ── Permissions (v0.24.2) ── */
|
||||
'<div class="settings-section" style="margin-bottom:16px">' +
|
||||
'<h5 style="margin:0 0 8px">Permissions</h5>' +
|
||||
'<div id="adminGroupPermissions" class="admin-permissions-grid"></div>' +
|
||||
'</div>' +
|
||||
|
||||
/* ── Token Budgets ── */
|
||||
'<div class="settings-section" style="margin-bottom:16px">' +
|
||||
'<h5 style="margin:0 0 8px">Token Budgets</h5>' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Daily limit</label>' +
|
||||
'<input type="number" id="adminGroupBudgetDaily" placeholder="Unlimited" min="0" style="width:100%">' +
|
||||
'</div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Monthly limit</label>' +
|
||||
'<input type="number" id="adminGroupBudgetMonthly" placeholder="Unlimited" min="0" style="width:100%">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* ── Model Allowlist ── */
|
||||
'<div class="settings-section" style="margin-bottom:16px">' +
|
||||
'<h5 style="margin:0 0 8px">Allowed Models</h5>' +
|
||||
'<p class="text-muted" style="font-size:12px;margin:0 0 8px">Leave empty for unrestricted access to all enabled models.</p>' +
|
||||
'<div id="adminGroupModels" class="admin-models-grid"></div>' +
|
||||
'</div>' +
|
||||
|
||||
/* ── Save button ── */
|
||||
'<div style="margin-bottom:16px">' +
|
||||
'<button class="btn-small btn-primary" id="adminGroupSavePermsBtn">Save Permissions & Budgets</button>' +
|
||||
'<span id="adminGroupSaveStatus" class="text-muted" style="margin-left:8px;font-size:12px"></span>' +
|
||||
'</div>' +
|
||||
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
|
||||
/* ── Members (existing) ── */
|
||||
'<h5 style="margin:0 0 8px">Members</h5>' +
|
||||
'<div id="adminGroupMemberList" class="admin-list"></div>' +
|
||||
'<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>User</label><select id="adminGroupMemberUser"><option value="">Select user...</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminAddGroupMemberSubmit">Add Member</button>' +
|
||||
'<button class="btn-small" id="adminCancelGroupMemberBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small" id="adminAddGroupMemberBtn" style="margin-top:8px">+ Add Member</button>' +
|
||||
'</div>';
|
||||
|
||||
// -- AI ---------------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.providers =
|
||||
'<div id="adminProviderList" class="provider-cards"></div>';
|
||||
|
||||
SCAFFOLDING.models =
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small btn-primary" id="adminFetchModelsBtn">Fetch Models</button>' +
|
||||
'<span id="adminModelsHint" class="text-muted" style="font-size:12px"></span>' +
|
||||
'</div>' +
|
||||
'<div id="adminModelList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.personas =
|
||||
'<div id="adminPersonaList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
|
||||
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
|
||||
SCAFFOLDING.memory = '<div id="adminMemoryContent"></div>';
|
||||
|
||||
// -- System -----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.settings =
|
||||
'<div class="admin-settings-form">' +
|
||||
'<div class="settings-section"><h3>Registration</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminRegToggle"><span class="toggle-track"></span><span>Allow new user registration</span></label>' +
|
||||
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
|
||||
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>System Prompt</h3>' +
|
||||
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Default Model</h3>' +
|
||||
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Policies</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminUserProvidersToggle"><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminUserPersonasToggle"><span class="toggle-track"></span><span>Allow user personas</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminKBDirectAccessToggle"><span class="toggle-track"></span><span>Allow direct KB access (non-persona)</span></label>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Environment Banner</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminBannerEnabled"><span class="toggle-track"></span><span>Show environment banner</span></label>' +
|
||||
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Background</label><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;margin-left:4px"></div>' +
|
||||
'<div class="form-group"><label>Foreground</label><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;margin-left:4px"></div>' +
|
||||
'</div>' +
|
||||
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Web Search</h3>' +
|
||||
'<div class="form-group"><label>Provider</label>' +
|
||||
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
|
||||
'</div>' +
|
||||
'<div id="searxngConfigFields" style="display:none">' +
|
||||
'<div class="form-group"><label>Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://searxng.example.com"></div>' +
|
||||
'<div class="form-group"><label>API Key</label><input type="text" id="adminSearchApiKey" placeholder="Optional"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Auto-Compaction</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminCompactionEnabled"><span class="toggle-track"></span><span>Enable auto-compaction</span></label>' +
|
||||
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
|
||||
'<div class="form-group"><label>Cooldown (min)</label><input type="number" id="adminCompactionCooldown" value="30" min="5" max="1440"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Memory Extraction</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminMemoryExtractionEnabled"><span class="toggle-track"></span><span>Enable memory extraction</span></label>' +
|
||||
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminMemoryAutoApprove"><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h3>Encryption Vault</h3>' +
|
||||
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
|
||||
'</div>' +
|
||||
'<div id="roleFallbackBanner"></div>' +
|
||||
'<div style="display:flex;gap:8px;align-items:center;margin-top:16px">' +
|
||||
'<button class="btn-md btn-primary" id="adminSaveSettings">Save Settings</button>' +
|
||||
'<div style="flex:1"></div>' +
|
||||
'<button class="btn-small" id="adminExportSettings" title="Export admin settings as JSON">Export</button>' +
|
||||
'<button class="btn-small" id="adminImportSettings" title="Import admin settings from JSON">Import</button>' +
|
||||
'<input type="file" id="adminImportFile" accept=".json,application/json" style="display:none">' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
|
||||
|
||||
SCAFFOLDING.packages =
|
||||
'<div id="adminPackagesContent"></div>';
|
||||
|
||||
// -- Routing ----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.health =
|
||||
'<div id="adminHealthCards" class="stat-cards-grid"></div>' +
|
||||
'<div id="adminHealthContent"></div>';
|
||||
|
||||
SCAFFOLDING.routing =
|
||||
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
|
||||
'<div id="adminRoutingPolicyList" class="admin-list"></div>' +
|
||||
'<div id="adminRoutingForm" class="admin-inline-form" style="display:none"></div>' +
|
||||
'<div class="settings-section" style="margin-top:24px"><h4>Test Routing</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Model</label><input type="text" id="routingTestModel" placeholder="gpt-4o"></div>' +
|
||||
'<div class="form-group"><label>User ID</label><input type="text" id="routingTestUser" placeholder="optional"></div>' +
|
||||
'<button class="btn-small btn-primary" id="routingTestBtn" style="align-self:flex-end">Test</button>' +
|
||||
'</div>' +
|
||||
'<pre id="routingTestResult" class="code-block" style="display:none;margin-top:8px;font-size:12px;max-height:200px;overflow:auto"></pre>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.capabilities =
|
||||
'<div id="adminCapabilityList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
// -- Monitoring -------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.dashboard =
|
||||
'<div id="adminDashboardCards" class="stat-cards-grid"></div>' +
|
||||
'<div id="adminDashboardContent"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.usage =
|
||||
'<div id="adminUsageCards" class="stat-cards-grid"></div>' +
|
||||
'<div id="adminUsageChart" style="margin-bottom:24px"></div>' +
|
||||
'<div id="adminUsageTotals"></div>' +
|
||||
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
|
||||
'<div id="adminPricingTable" style="margin-top:24px"></div>';
|
||||
|
||||
SCAFFOLDING.audit =
|
||||
'<div class="form-row" style="margin-bottom:12px">' +
|
||||
'<div class="form-group"><label>Action</label><select id="auditFilterAction"><option value="">All</option></select></div>' +
|
||||
'<div class="form-group"><label>Resource</label><select id="auditFilterResource"><option value="">All</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div id="adminAuditList" class="admin-list"><div class="loading">Loading...</div></div>' +
|
||||
'<div id="auditPagination" style="margin-top:12px;display:flex;align-items:center;gap:8px">' +
|
||||
'<button class="btn-small" id="auditPrevBtn">← Prev</button>' +
|
||||
'<span id="auditPageInfo" class="text-muted" style="font-size:12px"></span>' +
|
||||
'<button class="btn-small" id="auditNextBtn">Next →</button>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.stats =
|
||||
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.channels =
|
||||
'<div class="stat-cards-grid" style="margin-bottom:16px">' +
|
||||
'<div class="stat-card"><div class="stat-value" id="adminArchivedCount">—</div><div class="stat-label">Archived</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value" id="adminRetentionMode">—</div><div class="stat-label">Retention Mode</div></div>' +
|
||||
'</div>' +
|
||||
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
var ADD_ACTIONS = {
|
||||
users: function() {
|
||||
openModal('createUserModal');
|
||||
var n = document.getElementById('adminNewUsername');
|
||||
if (n) n.focus();
|
||||
},
|
||||
teams: function() {
|
||||
openModal('createTeamModal');
|
||||
var n = document.getElementById('adminTeamName');
|
||||
if (n) n.focus();
|
||||
},
|
||||
providers: function() {
|
||||
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
|
||||
document.getElementById('providerFormTitle').textContent = 'Add Provider';
|
||||
openModal('providerFormModal');
|
||||
},
|
||||
personas: function() {
|
||||
if (typeof ensureAdminPersonaForm === 'function') {
|
||||
var form = ensureAdminPersonaForm();
|
||||
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
|
||||
}
|
||||
document.getElementById('personaFormTitle').textContent = 'Create Persona';
|
||||
openModal('personaFormModal');
|
||||
},
|
||||
groups: function() {
|
||||
openModal('createGroupModal');
|
||||
var n = document.getElementById('adminGroupName');
|
||||
if (n) n.focus();
|
||||
},
|
||||
packages: function() {
|
||||
var f = document.getElementById('adminPkgInstallForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// === Init on DOMContentLoaded ========================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// When the Preact admin surface is active, skip scaffold init entirely.
|
||||
if (document.getElementById('admin-mount')) return;
|
||||
var el = document.getElementById('adminContentBody');
|
||||
if (!el) return;
|
||||
var section = el.dataset.section;
|
||||
if (!section) return;
|
||||
|
||||
// -- Category / nav resolution -----------
|
||||
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
|
||||
// admin-scaffold.js previously had its own duplicate catMap/secMap.
|
||||
var cat = 'people';
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined') {
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
|
||||
}
|
||||
}
|
||||
var secMap;
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
|
||||
secMap = {};
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
|
||||
return { id: id, l: ADMIN_LABELS[id] || id };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild left nav for current category
|
||||
var navEl = document.getElementById('adminNav');
|
||||
var base = window.__BASE__ || '';
|
||||
if (navEl && secMap[cat]) {
|
||||
navEl.innerHTML = secMap[cat].map(function(s) {
|
||||
return '<a href="' + base + '/admin/' + s.id + '" class="admin-nav-link' + (s.id === section ? ' active' : '') + '">' + s.l + '</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Update section title
|
||||
var titleEl = document.getElementById('adminSectionTitle');
|
||||
var curSec = secMap[cat] && secMap[cat].find(function(s) { return s.id === section; });
|
||||
if (titleEl && curSec) titleEl.textContent = curSec.l;
|
||||
|
||||
// -- Inject scaffolding ------------------
|
||||
var target = document.getElementById('adminDynamic');
|
||||
if (target && SCAFFOLDING[section]) {
|
||||
target.innerHTML = SCAFFOLDING[section];
|
||||
} else if (target) {
|
||||
target.innerHTML = '<div class="empty-hint">Section: ' + section + '</div>';
|
||||
}
|
||||
|
||||
// -- Show/wire Add button for sections that have one --
|
||||
var addBtn = document.getElementById('adminAddBtn');
|
||||
if (addBtn && ADD_ACTIONS[section]) {
|
||||
addBtn.style.display = '';
|
||||
addBtn.addEventListener('click', ADD_ACTIONS[section]);
|
||||
}
|
||||
|
||||
// -- Wire install extension cancel -------
|
||||
var extCancel = document.getElementById('adminInstallExtCancelBtn');
|
||||
if (extCancel) {
|
||||
extCancel.addEventListener('click', function() {
|
||||
document.getElementById('adminInstallExtForm').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
|
||||
if (typeof _initAdminListeners === 'function') _initAdminListeners();
|
||||
// -- Wire settings toggle show/hide listeners (banner, compaction, etc.) --
|
||||
if (typeof _initAdminSettingsToggles === 'function') _initAdminSettingsToggles();
|
||||
|
||||
// -- Call the section data loader --------
|
||||
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {
|
||||
ADMIN_LOADERS[section]();
|
||||
}
|
||||
});
|
||||
@@ -321,8 +321,7 @@ function initListeners() {
|
||||
_listenersInit = true;
|
||||
|
||||
_initChatListeners(); // from chat.js
|
||||
_initSettingsListeners(); // from settings-handlers.js
|
||||
_initAdminListeners(); // from admin-handlers.js
|
||||
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
|
||||
_initNotesListeners(); // from notes.js
|
||||
_initFileListeners(); // from files.js
|
||||
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Broadcast Admin Panel
|
||||
// ==========================================
|
||||
// Admin section for composing and sending system announcements.
|
||||
// Registered as ADMIN_LOADERS.broadcast in ui-admin.js.
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div style="max-width:600px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:20px">' +
|
||||
'Send a system announcement to all active users. Notifications appear in their ' +
|
||||
'bell menu and optionally via email (based on user preferences).' +
|
||||
'</p>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Title</label>' +
|
||||
'<input type="text" id="broadcastTitle" class="input" placeholder="Scheduled maintenance tonight" autocomplete="off">' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Message</label>' +
|
||||
'<textarea id="broadcastMessage" class="input" rows="4" placeholder="Details about the announcement…" style="resize:vertical;font-family:inherit"></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Level</label>' +
|
||||
'<select id="broadcastLevel" class="input">' +
|
||||
'<option value="info">Info</option>' +
|
||||
'<option value="warning">Warning</option>' +
|
||||
'<option value="critical">Critical</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<button id="broadcastSendBtn" class="btn-md btn-primary" style="margin-top:8px">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
|
||||
' Send Broadcast' +
|
||||
'</button>' +
|
||||
'<div id="broadcastResult" style="margin-top:12px;display:none"></div>' +
|
||||
'</div>';
|
||||
|
||||
sb.register('_loadAdminBroadcast', async function () {
|
||||
var body = document.getElementById('adminDynamic') || document.getElementById('adminContentBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
var sendBtn = document.getElementById('broadcastSendBtn');
|
||||
var resultEl = document.getElementById('broadcastResult');
|
||||
|
||||
sendBtn.addEventListener('click', async function () {
|
||||
var title = document.getElementById('broadcastTitle').value.trim();
|
||||
var message = document.getElementById('broadcastMessage').value.trim();
|
||||
var level = document.getElementById('broadcastLevel').value;
|
||||
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
if (!message) { UI.toast('Message is required', 'warning'); return; }
|
||||
|
||||
// Confirmation
|
||||
var ok = await UI.confirm(
|
||||
'Send broadcast to all active users?\n\n' +
|
||||
'Level: ' + level + '\n' +
|
||||
'Title: ' + title
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = 'Sending…';
|
||||
resultEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
var resp = await API.post('/admin/notifications/broadcast', {
|
||||
title: title,
|
||||
message: message,
|
||||
level: level,
|
||||
});
|
||||
|
||||
var count = resp.count || 0;
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.className = 'admin-success';
|
||||
resultEl.innerHTML =
|
||||
'<span style="color:var(--success);font-weight:600">✓</span> ' +
|
||||
'Broadcast sent to ' + count + ' user' + (count !== 1 ? 's' : '') + '.';
|
||||
|
||||
// Clear form
|
||||
document.getElementById('broadcastTitle').value = '';
|
||||
document.getElementById('broadcastMessage').value = '';
|
||||
document.getElementById('broadcastLevel').value = 'info';
|
||||
|
||||
UI.toast('Broadcast sent to ' + count + ' users', 'success');
|
||||
} catch (err) {
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.className = 'admin-error';
|
||||
resultEl.innerHTML =
|
||||
'<span style="color:var(--danger);font-weight:600">✗</span> ' +
|
||||
'Failed: ' + (err.message || err);
|
||||
UI.toast('Broadcast failed', 'error');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = ' Send Broadcast';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1075,11 +1075,11 @@ function _initChatListeners() {
|
||||
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
|
||||
});
|
||||
|
||||
// Flyout items
|
||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
|
||||
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
|
||||
// Flyout items — v0.37.7: admin + team admin navigate to dedicated surfaces
|
||||
const _base = window.__BASE__ || '';
|
||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/settings/general'; });
|
||||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/admin/users'; });
|
||||
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/team-admin/members'; });
|
||||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||
|
||||
@@ -1096,7 +1096,7 @@ function _initChatListeners() {
|
||||
|
||||
// Model selector (custom dropdown)
|
||||
UI.initModelDropdown();
|
||||
UI.initAppearance();
|
||||
// UI.initAppearance removed in v0.37.7 — appearance handled by SDK theme
|
||||
|
||||
// Mobile hamburger
|
||||
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
// data-portability.js — v0.35.1
|
||||
//
|
||||
// Settings → Data & Privacy section. Wires the v0.34.0 backend
|
||||
// endpoints: export/import user data, ChatGPT import, and GDPR
|
||||
// account deletion. Also used by admin-handlers.js for team export.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── API helpers ──────────────────────────────
|
||||
|
||||
const _base = (window.__BASE__ || '') + '/api/v1';
|
||||
|
||||
async function _exportMyData() {
|
||||
const btn = document.getElementById('dpExportBtn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Exporting…';
|
||||
try {
|
||||
const resp = await fetch(_base + '/export/me', {
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Export failed (' + resp.status + ')');
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Extract filename from Content-Disposition or use default
|
||||
const cd = resp.headers.get('Content-Disposition') || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/);
|
||||
a.download = match ? match[1] : 'switchboard-export.switchboard';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Export downloaded', 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Download My Data';
|
||||
}
|
||||
}
|
||||
|
||||
async function _importMyData() {
|
||||
const fileInput = document.getElementById('dpImportFile');
|
||||
if (!fileInput || !fileInput.files[0]) return;
|
||||
const file = fileInput.files[0];
|
||||
|
||||
const btn = document.getElementById('dpImportBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(_base + '/import/me', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
body: form,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Import failed');
|
||||
|
||||
let msg = 'Import complete.';
|
||||
if (data.imported) {
|
||||
const counts = Object.entries(data.imported)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (counts) msg += ' Imported: ' + counts + '.';
|
||||
}
|
||||
if (data.skipped) {
|
||||
const skips = Object.entries(data.skipped)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (skips) msg += ' Skipped (already exist): ' + skips + '.';
|
||||
}
|
||||
if (data.errors && data.errors.length) {
|
||||
msg += ' Errors: ' + data.errors.join('; ');
|
||||
UI.toast(msg, 'warning');
|
||||
} else {
|
||||
UI.toast(msg, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
fileInput.value = '';
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function _importChatGPT() {
|
||||
const fileInput = document.getElementById('dpChatGPTFile');
|
||||
if (!fileInput || !fileInput.files[0]) return;
|
||||
const file = fileInput.files[0];
|
||||
|
||||
const btn = document.getElementById('dpChatGPTBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(_base + '/import/chatgpt', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
body: form,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Import failed');
|
||||
|
||||
let msg = 'ChatGPT import complete.';
|
||||
if (data.imported) {
|
||||
const counts = Object.entries(data.imported)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (counts) msg += ' Imported: ' + counts + '.';
|
||||
}
|
||||
if (data.skipped) {
|
||||
const skips = Object.entries(data.skipped)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (skips) msg += ' Skipped: ' + skips + '.';
|
||||
}
|
||||
UI.toast(msg, 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
fileInput.value = '';
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
}
|
||||
}
|
||||
|
||||
function _showDeleteConfirm() {
|
||||
const el = document.getElementById('dpDeleteSection');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--danger-dim);border:1px solid var(--danger);border-radius:var(--radius);padding:14px 16px;margin-top:12px;">
|
||||
<p style="color:var(--danger);font-weight:600;font-size:13px;margin:0 0 8px;">
|
||||
This will permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label style="font-size:12px;color:var(--text-2);">Enter your password to confirm</label>
|
||||
<input type="password" id="dpDeletePassword" autocomplete="current-password"
|
||||
style="max-width:280px;" placeholder="Your password">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-md btn-danger" id="dpDeleteConfirmBtn">Delete My Account</button>
|
||||
<button class="btn-md btn-ghost" id="dpDeleteCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('dpDeleteConfirmBtn')
|
||||
.addEventListener('click', _executeDelete);
|
||||
document.getElementById('dpDeleteCancelBtn')
|
||||
.addEventListener('click', () => { _renderDeleteButton(el); });
|
||||
}
|
||||
|
||||
function _renderDeleteButton(el) {
|
||||
el.innerHTML = `
|
||||
<button class="btn-md btn-ghost" id="dpDeleteStartBtn"
|
||||
style="color:var(--danger);border-color:var(--danger);">
|
||||
Delete My Account
|
||||
</button>`;
|
||||
document.getElementById('dpDeleteStartBtn')
|
||||
.addEventListener('click', _showDeleteConfirm);
|
||||
}
|
||||
|
||||
async function _executeDelete() {
|
||||
const pw = document.getElementById('dpDeletePassword');
|
||||
if (!pw || !pw.value) {
|
||||
UI.toast('Password is required', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('dpDeleteConfirmBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
|
||||
try {
|
||||
const resp = await fetch(_base + '/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + API.accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ confirm: 'DELETE', password: pw.value }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Delete failed');
|
||||
UI.toast('Account deleted. Redirecting…', 'success');
|
||||
setTimeout(() => {
|
||||
API.clearTokens();
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Delete My Account'; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section renderer ─────────────────────────
|
||||
|
||||
function loadDataPrivacy() {
|
||||
const mount = document.getElementById('dpMount');
|
||||
if (!mount) return;
|
||||
|
||||
mount.innerHTML = `
|
||||
<div class="settings-section">
|
||||
<h3>Export My Data</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Download all your data as a <code>.switchboard</code> archive: conversations,
|
||||
notes, memories, projects, files, settings, and usage history.
|
||||
</p>
|
||||
<button class="btn-md btn-primary" id="dpExportBtn">Download My Data</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:20px;">
|
||||
<h3>Import Data</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Restore from a <code>.switchboard</code> archive exported from this or another
|
||||
instance. Existing items with the same ID are skipped (no duplicates).
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="file" id="dpImportFile" accept=".switchboard,.zip" style="font-size:13px;">
|
||||
<button class="btn-md btn-primary" id="dpImportBtn" disabled>Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:20px;">
|
||||
<h3>Import from ChatGPT</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Import conversations from a ChatGPT export. Go to
|
||||
<a href="https://chatgpt.com/#settings/DataControls" target="_blank"
|
||||
style="color:var(--accent);">ChatGPT Settings → Data Controls → Export</a>,
|
||||
download the zip, and upload it here. Conversations become direct chats.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="file" id="dpChatGPTFile" accept=".zip" style="font-size:13px;">
|
||||
<button class="btn-md btn-primary" id="dpChatGPTBtn" disabled>Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h3 style="color:var(--danger);">Danger Zone</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Permanently delete your account and all associated data. Your messages in
|
||||
shared channels will be anonymized. This cannot be undone.
|
||||
</p>
|
||||
<div id="dpDeleteSection"></div>
|
||||
</div>`;
|
||||
|
||||
// Wire listeners
|
||||
document.getElementById('dpExportBtn').addEventListener('click', _exportMyData);
|
||||
|
||||
const impFile = document.getElementById('dpImportFile');
|
||||
const impBtn = document.getElementById('dpImportBtn');
|
||||
impFile.addEventListener('change', () => { impBtn.disabled = !impFile.files.length; });
|
||||
impBtn.addEventListener('click', _importMyData);
|
||||
|
||||
const cgFile = document.getElementById('dpChatGPTFile');
|
||||
const cgBtn = document.getElementById('dpChatGPTBtn');
|
||||
cgFile.addEventListener('change', () => { cgBtn.disabled = !cgFile.files.length; });
|
||||
cgBtn.addEventListener('click', _importChatGPT);
|
||||
|
||||
_renderDeleteButton(document.getElementById('dpDeleteSection'));
|
||||
}
|
||||
|
||||
// ── Admin: team export ───────────────────────
|
||||
|
||||
async function exportTeamData(teamId, teamName) {
|
||||
UI.toast('Exporting team data…', 'info');
|
||||
try {
|
||||
const resp = await fetch(_base + '/admin/teams/' + teamId + '/export', {
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Export failed (' + resp.status + ')');
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const cd = resp.headers.get('Content-Disposition') || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/);
|
||||
a.download = match ? match[1] : ('switchboard-team-' + (teamName || teamId).replace(/\s+/g, '-') + '.switchboard');
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Team export downloaded', 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Register ─────────────────────────────────
|
||||
|
||||
sb.register('loadDataPrivacy', loadDataPrivacy);
|
||||
sb.register('exportTeamData', exportTeamData);
|
||||
|
||||
})();
|
||||
@@ -1,180 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Git Credentials Settings
|
||||
// ==========================================
|
||||
// Settings section for managing SSH keys used with git workspaces.
|
||||
// Keys are generated server-side — private keys never leave the server.
|
||||
// Users copy the public key into their repo host (GitHub, Gitea, etc.).
|
||||
// Optional persona binding enables per-persona commit attribution.
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div style="max-width:640px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:16px">' +
|
||||
'SSH keys for git operations. Keys are generated on the server — the private key ' +
|
||||
'is vault-encrypted and never exposed. Copy the public key into your git host.' +
|
||||
'</p>' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
'<h4 style="margin:0">SSH Keys</h4>' +
|
||||
'<button id="gitKeyGenerateBtn" class="btn-small btn-primary">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||
' Generate Key' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div id="gitKeyList" class="admin-list" style="margin-bottom:16px"></div>' +
|
||||
'<div id="gitKeyEmpty" style="display:none;color:var(--text-3);font-size:13px;text-align:center;padding:24px 0">' +
|
||||
'No SSH keys yet. Generate one to get started.' +
|
||||
'</div>' +
|
||||
'<div style="margin-top:16px;padding:14px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
|
||||
'<span style="font-size:12px;font-weight:600">Security</span>' +
|
||||
'</div>' +
|
||||
'<p style="font-size:12px;color:var(--text-3);line-height:1.6;margin:0">' +
|
||||
'Private keys are encrypted with your personal vault key. They are used server-side ' +
|
||||
'for git push/pull operations and cannot be downloaded or viewed.' +
|
||||
'</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Generate key dialog ──
|
||||
var GENERATE_HTML =
|
||||
'<div style="max-width:400px">' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Key Name</label>' +
|
||||
'<input type="text" id="gitKeyName" class="input" placeholder="e.g. GitHub - main" autocomplete="off">' +
|
||||
'</div>' +
|
||||
'<div class="form-group" id="gitKeyPersonaGroup" style="display:none">' +
|
||||
'<label>Persona (optional)</label>' +
|
||||
'<select id="gitKeyPersona" class="input"><option value="">None — use account identity</option></select>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin:4px 0 0">Commits signed with this key will use the persona\'s name as git author.</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
async function _loadSettingsGitKeys() {
|
||||
var body = document.querySelector('.settings-section') || document.getElementById('settingsSection');
|
||||
if (!body) return;
|
||||
body.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
var listEl = document.getElementById('gitKeyList');
|
||||
var emptyEl = document.getElementById('gitKeyEmpty');
|
||||
var genBtn = document.getElementById('gitKeyGenerateBtn');
|
||||
|
||||
await refreshList();
|
||||
|
||||
genBtn.addEventListener('click', async function () {
|
||||
// Show generate dialog
|
||||
var ok = await new Promise(function (resolve) {
|
||||
UI.modal('Generate SSH Key', GENERATE_HTML, [
|
||||
{ label: 'Cancel', variant: 'ghost', action: function () { resolve(false); } },
|
||||
{ label: 'Generate', variant: 'primary', action: function () { resolve(true); } },
|
||||
]);
|
||||
|
||||
// Load personas for the optional selector
|
||||
API.get('/personas/mine').then(function (d) {
|
||||
var personas = (d.data || d.personas || []);
|
||||
if (personas.length > 0) {
|
||||
var group = document.getElementById('gitKeyPersonaGroup');
|
||||
var sel = document.getElementById('gitKeyPersona');
|
||||
if (group) group.style.display = '';
|
||||
personas.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name + ' (@' + p.handle + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}).catch(function () {});
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
var name = (document.getElementById('gitKeyName') || {}).value || '';
|
||||
if (!name.trim()) { UI.toast('Key name is required', 'warning'); return; }
|
||||
|
||||
var personaId = (document.getElementById('gitKeyPersona') || {}).value || null;
|
||||
|
||||
try {
|
||||
var payload = { name: name.trim() };
|
||||
if (personaId) payload.persona_id = personaId;
|
||||
var cred = await API.post('/git-credentials/generate', payload);
|
||||
UI.toast('SSH key generated', 'success');
|
||||
|
||||
// Show the public key for copying
|
||||
showPublicKey(cred);
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
UI.toast('Generation failed: ' + (err.message || err), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
var d = await API.get('/git-credentials');
|
||||
var creds = d.data || [];
|
||||
if (creds.length === 0) {
|
||||
listEl.innerHTML = '';
|
||||
listEl.style.display = 'none';
|
||||
emptyEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
listEl.style.display = '';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
listEl.innerHTML = creds.map(function (c) {
|
||||
var ts = new Date(c.created_at);
|
||||
var dateStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
var fp = c.fingerprint ? '<span style="font-family:var(--mono);font-size:11px;color:var(--text-3)">' + esc(c.fingerprint) + '</span>' : '';
|
||||
var persona = c.persona_id ? '<span class="badge" style="font-size:10px">persona</span>' : '';
|
||||
return '<div class="admin-list-item" style="display:flex;align-items:center;gap:10px;padding:10px 14px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:500;font-size:13px">' + esc(c.name) + ' ' + persona + '</div>' +
|
||||
'<div style="display:flex;gap:12px;margin-top:2px">' +
|
||||
'<span class="badge">' + esc(c.auth_type) + '</span>' +
|
||||
fp +
|
||||
'<span style="font-size:11px;color:var(--text-3)">' + dateStr + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(c.public_key ? '<button class="btn-small" onclick="_gitKeyCopy(\'' + c.id + '\')">Copy Public Key</button>' : '') +
|
||||
'<button class="btn-small btn-ghost" onclick="_gitKeyDelete(\'' + c.id + '\',\'' + esc(c.name) + '\')">Delete</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<div style="color:var(--danger);padding:12px">Failed to load credentials</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function showPublicKey(cred) {
|
||||
if (!cred.public_key) return;
|
||||
UI.modal('Public Key — ' + cred.name,
|
||||
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">Add this key to your git host (GitHub → Settings → SSH Keys):</p>' +
|
||||
'<textarea readonly style="width:100%;height:80px;font-family:var(--mono);font-size:11px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius);padding:8px;color:var(--text);resize:none">' + esc(cred.public_key) + '</textarea>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin-top:4px">Fingerprint: ' + esc(cred.fingerprint) + '</p>',
|
||||
[{ label: 'Done', variant: 'primary' }]
|
||||
);
|
||||
}
|
||||
|
||||
// Register global handlers for onclick
|
||||
window._gitKeyCopy = async function (id) {
|
||||
try {
|
||||
var d = await API.get('/git-credentials/' + id + '/public-key');
|
||||
await navigator.clipboard.writeText(d.public_key);
|
||||
UI.toast('Public key copied to clipboard', 'success');
|
||||
} catch (err) {
|
||||
UI.toast('Failed to copy: ' + (err.message || err), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window._gitKeyDelete = async function (id, name) {
|
||||
var ok = await UI.confirm('Delete SSH key "' + name + '"?\n\nWorkspaces using this key will lose git access until reassigned.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.del('/git-credentials/' + id);
|
||||
UI.toast('Key deleted', 'success');
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
UI.toast('Delete failed: ' + (err.message || err), 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
|
||||
sb.register('_loadSettingsGitKeys', _loadSettingsGitKeys);
|
||||
@@ -1,370 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Memory UI (v0.18.0 Phase 3)
|
||||
// ==========================================
|
||||
// User-facing memory management in Settings,
|
||||
// admin memory review in Admin Panel,
|
||||
// and persona memory config in persona forms.
|
||||
//
|
||||
// Exports: window.MemoryUI
|
||||
|
||||
|
||||
const MemoryUI = {
|
||||
|
||||
// ── State ────────────────────────────────
|
||||
_memories: [],
|
||||
_pendingMemories: [],
|
||||
_filter: { scope: 'user', status: 'active', query: '' },
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// User Settings Tab
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
async openSettingsPanel() {
|
||||
const panel = document.getElementById('settingsMemoryContent');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="loading">Loading memories...</div>';
|
||||
|
||||
try {
|
||||
// Load memory count summary
|
||||
const count = await API.getMemoryCount();
|
||||
const active = count.active || 0;
|
||||
const pending = count.pending || 0;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="memory-summary">
|
||||
<div class="memory-stat">
|
||||
<span class="memory-stat-value">${active}</span>
|
||||
<span class="memory-stat-label">Active</span>
|
||||
</div>
|
||||
<div class="memory-stat">
|
||||
<span class="memory-stat-value">${pending}</span>
|
||||
<span class="memory-stat-label">Pending Review</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-filter-row">
|
||||
<select id="memoryStatusFilter" class="memory-select">
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_review">Pending Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<input type="text" id="memorySearchInput" placeholder="Search memories..." class="memory-search">
|
||||
</div>
|
||||
<div class="memory-actions-row">
|
||||
<button class="btn-small" id="memoryRefreshBtn" title="Refresh">↻ Refresh</button>
|
||||
${pending > 0 ? `<button class="btn-small btn-primary" id="memoryApproveAllBtn">✓ Approve All Pending</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="memoryList" class="memory-list"></div>
|
||||
`;
|
||||
|
||||
// Wire events
|
||||
document.getElementById('memoryStatusFilter')?.addEventListener('change', (e) => {
|
||||
MemoryUI._filter.status = e.target.value;
|
||||
MemoryUI.loadMemories();
|
||||
});
|
||||
document.getElementById('memorySearchInput')?.addEventListener('input', _debounce((e) => {
|
||||
MemoryUI._filter.query = e.target.value;
|
||||
MemoryUI.loadMemories();
|
||||
}, 300));
|
||||
document.getElementById('memoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openSettingsPanel());
|
||||
document.getElementById('memoryApproveAllBtn')?.addEventListener('click', () => MemoryUI.approveAllPending());
|
||||
|
||||
// Load initial list
|
||||
await this.loadMemories();
|
||||
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMemories() {
|
||||
const el = document.getElementById('memoryList');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const data = await API.listMemories(this._filter.status, this._filter.query);
|
||||
this._memories = data.data || [];
|
||||
|
||||
if (!this._memories.length) {
|
||||
el.innerHTML = `<div class="empty-hint">No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = this._memories.map(m => this._renderMemoryCard(m)).join('');
|
||||
this._wireMemoryCards(el);
|
||||
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
_renderMemoryCard(m) {
|
||||
const confidence = Math.round(m.confidence * 100);
|
||||
const confidenceClass = confidence >= 80 ? 'high' : confidence >= 50 ? 'medium' : 'low';
|
||||
const updated = m.updated_at ? new Date(m.updated_at).toLocaleDateString() : '';
|
||||
const scopeLabel = m.scope === 'persona_user' ? 'persona' : m.scope;
|
||||
|
||||
return `
|
||||
<div class="memory-card" data-id="${m.id}">
|
||||
<div class="memory-card-header">
|
||||
<span class="memory-key">${esc(m.key)}</span>
|
||||
<div class="memory-badges">
|
||||
<span class="memory-scope-badge memory-scope-${m.scope}">${scopeLabel}</span>
|
||||
<span class="memory-confidence memory-confidence-${confidenceClass}" title="Confidence: ${confidence}%">${confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-card-value">${esc(m.value)}</div>
|
||||
<div class="memory-card-footer">
|
||||
<span class="memory-date">${updated}</span>
|
||||
<div class="memory-card-actions">
|
||||
${m.status === 'pending_review' ? `
|
||||
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}" title="Approve">✓</button>
|
||||
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}" title="Reject">✕</button>
|
||||
` : ''}
|
||||
<button class="btn-tiny" data-action="edit" data-id="${m.id}" title="Edit">✎</button>
|
||||
<button class="btn-tiny btn-danger" data-action="delete" data-id="${m.id}" title="Delete">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_wireMemoryCards(container) {
|
||||
container.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const id = btn.dataset.id;
|
||||
const action = btn.dataset.action;
|
||||
|
||||
if (action === 'approve') await this.approveMemory(id);
|
||||
else if (action === 'reject') await this.rejectMemory(id);
|
||||
else if (action === 'delete') await this.deleteMemory(id);
|
||||
else if (action === 'edit') this.editMemory(id);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async approveMemory(id) {
|
||||
try {
|
||||
await API.approveMemory(id);
|
||||
UI.toast('Memory approved', 'success');
|
||||
this.loadMemories();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async rejectMemory(id) {
|
||||
try {
|
||||
await API.rejectMemory(id);
|
||||
UI.toast('Memory rejected', 'success');
|
||||
this.loadMemories();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async deleteMemory(id) {
|
||||
if (!await showConfirm('Delete this memory permanently?')) return;
|
||||
try {
|
||||
await API.deleteMemory(id);
|
||||
UI.toast('Memory deleted', 'success');
|
||||
this.loadMemories();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
editMemory(id) {
|
||||
const m = this._memories.find(x => x.id === id);
|
||||
if (!m) return;
|
||||
|
||||
const card = document.querySelector(`.memory-card[data-id="${id}"]`);
|
||||
if (!card) return;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="memory-edit-form">
|
||||
<div class="form-group">
|
||||
<label>Key</label>
|
||||
<input type="text" id="memEdit_key_${id}" value="${esc(m.key)}" class="memory-edit-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Value</label>
|
||||
<textarea id="memEdit_val_${id}" rows="2" class="memory-edit-input">${esc(m.value)}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="gap:6px">
|
||||
<button class="btn-small btn-primary" id="memEditSave_${id}">Save</button>
|
||||
<button class="btn-small" id="memEditCancel_${id}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById(`memEditSave_${id}`)?.addEventListener('click', async () => {
|
||||
const key = document.getElementById(`memEdit_key_${id}`).value.trim();
|
||||
const value = document.getElementById(`memEdit_val_${id}`).value.trim();
|
||||
if (!key || !value) return UI.toast('Key and value are required', 'warning');
|
||||
try {
|
||||
await API.updateMemory(id, { key, value });
|
||||
UI.toast('Memory updated', 'success');
|
||||
this.loadMemories();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById(`memEditCancel_${id}`)?.addEventListener('click', () => {
|
||||
this.loadMemories();
|
||||
});
|
||||
},
|
||||
|
||||
async approveAllPending() {
|
||||
try {
|
||||
const data = await API.listMemories('pending_review', '');
|
||||
const ids = (data.data || []).map(m => m.id);
|
||||
if (!ids.length) return UI.toast('No pending memories', 'info');
|
||||
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
|
||||
await API.bulkApproveMemories(ids);
|
||||
UI.toast(`${ids.length} memories approved`, 'success');
|
||||
this.openSettingsPanel();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Admin Panel — Memory Review
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
async openAdminPanel() {
|
||||
const el = document.getElementById('adminMemoryContent');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const data = await API.adminListPendingMemories();
|
||||
const pending = data.data || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="admin-toolbar">
|
||||
<span class="admin-toolbar-label">${pending.length} pending memories</span>
|
||||
${pending.length > 0 ? `
|
||||
<button class="btn-small btn-primary" id="adminBulkApproveBtn">✓ Approve All</button>
|
||||
` : ''}
|
||||
<button class="btn-small" id="adminMemoryRefreshBtn">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="adminMemoryList" class="memory-list">
|
||||
${pending.length === 0 ? '<div class="empty-hint">No memories pending review</div>' : ''}
|
||||
${pending.map(m => this._renderAdminMemoryRow(m)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire events
|
||||
document.getElementById('adminBulkApproveBtn')?.addEventListener('click', async () => {
|
||||
const ids = pending.map(m => m.id);
|
||||
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
|
||||
try {
|
||||
await API.bulkApproveMemories(ids);
|
||||
UI.toast(`${ids.length} memories approved`, 'success');
|
||||
MemoryUI.openAdminPanel();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
document.getElementById('adminMemoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openAdminPanel());
|
||||
|
||||
// Wire per-row actions
|
||||
el.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
try {
|
||||
if (btn.dataset.action === 'approve') {
|
||||
await API.approveMemory(id);
|
||||
UI.toast('Approved', 'success');
|
||||
} else if (btn.dataset.action === 'reject') {
|
||||
await API.rejectMemory(id);
|
||||
UI.toast('Rejected', 'success');
|
||||
}
|
||||
MemoryUI.openAdminPanel();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
_renderAdminMemoryRow(m) {
|
||||
const confidence = Math.round(m.confidence * 100);
|
||||
const scope = m.scope === 'persona_user' ? 'persona' : m.scope;
|
||||
return `
|
||||
<div class="memory-card memory-card-pending">
|
||||
<div class="memory-card-header">
|
||||
<span class="memory-key">${esc(m.key)}</span>
|
||||
<div class="memory-badges">
|
||||
<span class="memory-scope-badge memory-scope-${m.scope}">${scope}</span>
|
||||
<span class="memory-confidence">${confidence}%</span>
|
||||
<span class="memory-owner">${esc(m.owner_id?.substring(0, 8) || '?')}…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-card-value">${esc(m.value)}</div>
|
||||
<div class="memory-card-footer">
|
||||
<span class="memory-date">${m.updated_at ? new Date(m.updated_at).toLocaleDateString() : ''}</span>
|
||||
<div class="memory-card-actions">
|
||||
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}">✓ Approve</button>
|
||||
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}">✕ Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Persona Form Memory Fields
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
// Call this after renderPersonaForm to append memory fields.
|
||||
// container: the form container element
|
||||
// prefix: the form prefix (e.g. 'pf', 'apf')
|
||||
appendMemoryFields(container, prefix) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'memory-persona-section';
|
||||
section.innerHTML = `
|
||||
<div class="form-divider"></div>
|
||||
<h4 class="form-section-title">Memory</h4>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="${prefix}_memoryEnabled" checked>
|
||||
Enable memory for this persona
|
||||
</label>
|
||||
<p class="section-hint" style="margin:4px 0 8px">When enabled, this persona can save and recall memories from conversations.</p>
|
||||
<div class="form-group" id="${prefix}_memoryPromptGroup">
|
||||
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
|
||||
<textarea id="${prefix}_memoryExtractionPrompt" rows="3" placeholder="Leave blank for default extraction behavior. Custom prompts let you control what facts this persona remembers."></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert before the submit/cancel buttons
|
||||
const submitRow = container.querySelector('.form-row:last-child');
|
||||
if (submitRow) {
|
||||
container.insertBefore(section, submitRow);
|
||||
} else {
|
||||
container.appendChild(section);
|
||||
}
|
||||
},
|
||||
|
||||
// Read memory values from a persona form
|
||||
getMemoryValues(prefix) {
|
||||
const enabled = document.getElementById(`${prefix}_memoryEnabled`)?.checked ?? true;
|
||||
const prompt = document.getElementById(`${prefix}_memoryExtractionPrompt`)?.value.trim() || null;
|
||||
return { memory_enabled: enabled, memory_extraction_prompt: prompt };
|
||||
},
|
||||
|
||||
// Set memory values in a persona form
|
||||
setMemoryValues(prefix, persona) {
|
||||
const enabledEl = document.getElementById(`${prefix}_memoryEnabled`);
|
||||
if (enabledEl) enabledEl.checked = persona.memory_enabled !== false;
|
||||
const promptEl = document.getElementById(`${prefix}_memoryExtractionPrompt`);
|
||||
if (promptEl) promptEl.value = persona.memory_extraction_prompt || '';
|
||||
},
|
||||
};
|
||||
|
||||
// ── Debounce helper ──────────────────────────
|
||||
function _debounce(fn, ms) {
|
||||
let timer;
|
||||
return function(...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('MemoryUI', MemoryUI);
|
||||
@@ -1,169 +0,0 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
Notification Preferences (v0.20.0 Phase 3)
|
||||
Settings → Notifications tab
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
//
|
||||
// Exports: window.NotifPrefs
|
||||
|
||||
const NotifPrefs = {
|
||||
_loaded: false,
|
||||
_prefs: [],
|
||||
|
||||
// Known notification types for the UI
|
||||
_types: [
|
||||
{ key: 'kb.ready', label: 'Knowledge base ready' },
|
||||
{ key: 'kb.error', label: 'Knowledge base error' },
|
||||
{ key: 'role.fallback', label: 'Model fallback' },
|
||||
{ key: 'grant.changed', label: 'Group membership change' },
|
||||
{ key: '*', label: 'Default (all others)' },
|
||||
],
|
||||
|
||||
async load() {
|
||||
try {
|
||||
this._prefs = await API.listNotifPrefs() || [];
|
||||
} catch (e) {
|
||||
console.warn('[notif-prefs] failed to load:', e.message);
|
||||
this._prefs = [];
|
||||
}
|
||||
this._loaded = true;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const container = document.getElementById('notifPrefsBody');
|
||||
if (!container) return;
|
||||
|
||||
const prefMap = {};
|
||||
for (const p of this._prefs) {
|
||||
prefMap[p.type] = p;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const t of this._types) {
|
||||
const p = prefMap[t.key];
|
||||
const inApp = p ? p.in_app : true;
|
||||
const email = p ? p.email : false;
|
||||
|
||||
html += `<tr class="notif-pref-row" data-type="${esc(t.key)}">
|
||||
<td class="notif-pref-label">${esc(t.label)}</td>
|
||||
<td class="notif-pref-toggle">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="notif-pref-inapp" ${inApp ? 'checked' : ''}>
|
||||
</label>
|
||||
</td>
|
||||
<td class="notif-pref-toggle">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="notif-pref-email" ${email ? 'checked' : ''}>
|
||||
</label>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
container.innerHTML = `<table class="notif-pref-table">
|
||||
<thead><tr>
|
||||
<th>Notification Type</th>
|
||||
<th>In-App</th>
|
||||
<th>Email</th>
|
||||
</tr></thead>
|
||||
<tbody>${html}</tbody>
|
||||
</table>`;
|
||||
|
||||
// Attach change listeners
|
||||
container.querySelectorAll('.notif-pref-row').forEach(row => {
|
||||
const type = row.dataset.type;
|
||||
row.querySelector('.notif-pref-inapp').addEventListener('change', (e) => {
|
||||
this._save(type, { in_app: e.target.checked });
|
||||
});
|
||||
row.querySelector('.notif-pref-email').addEventListener('change', (e) => {
|
||||
this._save(type, { email: e.target.checked });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async _save(type, patch) {
|
||||
try {
|
||||
await API.setNotifPref(type, patch);
|
||||
// Update cache
|
||||
const existing = this._prefs.find(p => p.type === type);
|
||||
if (existing) {
|
||||
Object.assign(existing, patch);
|
||||
} else {
|
||||
this._prefs.push({ type, in_app: true, email: false, ...patch });
|
||||
}
|
||||
} catch (e) {
|
||||
UI.toast('Failed to save preference', 'error');
|
||||
this.render(); // revert checkboxes
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Admin SMTP UI helpers ───────────────────
|
||||
|
||||
// Called from index.html onclick
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
NotifPrefs._testEmail = async function() {
|
||||
const status = document.getElementById('smtpTestStatus');
|
||||
const btn = document.getElementById('adminSmtpTestBtn');
|
||||
if (!status || !btn) return;
|
||||
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Sending…';
|
||||
status.className = 'smtp-test-status';
|
||||
|
||||
try {
|
||||
const result = await API.adminTestEmail();
|
||||
status.textContent = `✓ Sent to ${result.sent_to}`;
|
||||
status.className = 'smtp-test-status success';
|
||||
} catch (e) {
|
||||
status.textContent = '✗ ' + (e.message || 'Failed');
|
||||
status.className = 'smtp-test-status error';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
NotifPrefs._initAdminSmtp = function() {
|
||||
const toggle = document.getElementById('adminEmailEnabled');
|
||||
const fields = document.getElementById('smtpConfigFields');
|
||||
if (!toggle || !fields) return;
|
||||
toggle.addEventListener('change', () => {
|
||||
fields.style.display = toggle.checked ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
NotifPrefs._loadAdminSmtp = function(cfg) {
|
||||
if (!cfg) return;
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (cfg.email_enabled) el('adminEmailEnabled').checked = true;
|
||||
if (cfg.smtp_host) el('adminSmtpHost').value = cfg.smtp_host;
|
||||
if (cfg.smtp_port) el('adminSmtpPort').value = cfg.smtp_port;
|
||||
if (cfg.smtp_user) el('adminSmtpUser').value = cfg.smtp_user;
|
||||
// Don't populate password (security)
|
||||
if (cfg.smtp_from) el('adminSmtpFrom').value = cfg.smtp_from;
|
||||
if (cfg.smtp_tls != null) el('adminSmtpTls').value = String(cfg.smtp_tls);
|
||||
|
||||
// Show/hide SMTP fields
|
||||
const fields = document.getElementById('smtpConfigFields');
|
||||
if (fields) fields.style.display = cfg.email_enabled ? '' : 'none';
|
||||
};
|
||||
|
||||
NotifPrefs._getAdminSmtp = function() {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const config = {
|
||||
email_enabled: el('adminEmailEnabled')?.checked || false,
|
||||
smtp_host: el('adminSmtpHost')?.value?.trim() || '',
|
||||
smtp_port: parseInt(el('adminSmtpPort')?.value) || 587,
|
||||
smtp_user: el('adminSmtpUser')?.value?.trim() || '',
|
||||
smtp_from: el('adminSmtpFrom')?.value?.trim() || '',
|
||||
smtp_tls: el('adminSmtpTls')?.value === 'true',
|
||||
};
|
||||
// Only include password if user typed something new
|
||||
const pw = el('adminSmtpPassword')?.value;
|
||||
if (pw) config.smtp_password = pw;
|
||||
return config;
|
||||
};
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('NotifPrefs', NotifPrefs);
|
||||
@@ -1,183 +0,0 @@
|
||||
// persona-kb.js — Persona–Knowledge Base binding UI (v0.17.0)
|
||||
//
|
||||
// Provides renderPersonaKBPicker(container, opts) which renders a multi-select
|
||||
// KB picker with auto_search toggles for use in persona create/edit forms.
|
||||
// Pattern: (container, options) → control object with getValues/setValues/clear.
|
||||
//
|
||||
// Exports: window.renderPersonaKBPicker,window.loadPersonaKBs,window.savePersonaKBs
|
||||
|
||||
|
||||
// ── Persona-KB Picker ───────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a KB picker into a container element.
|
||||
*
|
||||
* Options:
|
||||
* scope - 'admin' | 'team' | 'personal' — controls which KBs are shown
|
||||
* teamId - required for team scope
|
||||
* prefix - DOM id prefix (default: 'pkb')
|
||||
*
|
||||
* Returns: { getValues(), setValues(kbs), clear(), refresh() }
|
||||
*/
|
||||
function renderPersonaKBPicker(containerEl, options = {}) {
|
||||
const pfx = options.prefix || 'pkb';
|
||||
let _allKBs = []; // available KBs from server
|
||||
let _selected = {}; // kb_id → { selected: bool, auto_search: bool }
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="persona-kb-picker" id="${pfx}_picker">
|
||||
<label>Knowledge Bases</label>
|
||||
<div class="form-hint">Bind KBs to this persona — users will search these KBs automatically when using this persona.</div>
|
||||
<div class="persona-kb-list" id="${pfx}_list">
|
||||
<div class="empty-hint">Loading knowledge bases…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Load available KBs ──
|
||||
async function loadKBs() {
|
||||
try {
|
||||
let resp;
|
||||
if (options.scope === 'team' && options.teamId) {
|
||||
resp = await API._get(`/api/v1/teams/${options.teamId}/knowledge-bases`);
|
||||
} else if (options.scope === 'admin') {
|
||||
resp = await API._get('/api/v1/knowledge-bases');
|
||||
} else {
|
||||
// User scope — show discoverable KBs
|
||||
resp = await API._get('/api/v1/knowledge-bases-discoverable');
|
||||
}
|
||||
_allKBs = (resp.data || []).filter(kb => kb.chunk_count > 0);
|
||||
} catch (e) {
|
||||
_allKBs = [];
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Render KB list ──
|
||||
function render() {
|
||||
const list = document.getElementById(`${pfx}_list`);
|
||||
if (!list) return;
|
||||
|
||||
if (_allKBs.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">No knowledge bases with content available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = _allKBs.map(kb => {
|
||||
const sel = _selected[kb.id] || { selected: false, auto_search: false };
|
||||
return `
|
||||
<label class="persona-kb-item ${sel.selected ? 'selected' : ''}">
|
||||
<input type="checkbox" data-kb-id="${esc(kb.id)}" ${sel.selected ? 'checked' : ''}>
|
||||
<span class="kb-name">${esc(kb.name)}</span>
|
||||
<span class="kb-meta">${kb.document_count} docs · ${kb.chunk_count} chunks</span>
|
||||
<label class="auto-search-toggle ${sel.selected ? '' : 'hidden'}" title="Auto-inject top results into context (no tool call needed)">
|
||||
<input type="checkbox" data-kb-auto="${esc(kb.id)}" ${sel.auto_search ? 'checked' : ''}>
|
||||
<span>Auto-inject</span>
|
||||
</label>
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
// Wire checkboxes
|
||||
list.querySelectorAll('input[data-kb-id]').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const kbId = this.dataset.kbId;
|
||||
if (!_selected[kbId]) _selected[kbId] = { selected: false, auto_search: false };
|
||||
_selected[kbId].selected = this.checked;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('input[data-kb-auto]').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const kbId = this.dataset.kbAuto;
|
||||
if (_selected[kbId]) {
|
||||
_selected[kbId].auto_search = this.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Control object ──
|
||||
const control = {
|
||||
getValues() {
|
||||
const kbIds = [];
|
||||
const autoSearch = {};
|
||||
for (const [kbId, state] of Object.entries(_selected)) {
|
||||
if (state.selected) {
|
||||
kbIds.push(kbId);
|
||||
if (state.auto_search) {
|
||||
autoSearch[kbId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kb_ids: kbIds, auto_search: autoSearch };
|
||||
},
|
||||
|
||||
setValues(personaKBs) {
|
||||
_selected = {};
|
||||
if (Array.isArray(personaKBs)) {
|
||||
for (const pkb of personaKBs) {
|
||||
_selected[pkb.kb_id] = {
|
||||
selected: true,
|
||||
auto_search: !!pkb.auto_search,
|
||||
};
|
||||
}
|
||||
}
|
||||
render();
|
||||
},
|
||||
|
||||
clear() {
|
||||
_selected = {};
|
||||
render();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
await loadKBs();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-load on create
|
||||
loadKBs();
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
|
||||
// ── Persona-KB Save/Load Helpers ────────────────
|
||||
|
||||
/**
|
||||
* Load persona KB bindings and populate the picker.
|
||||
* @param {string} personaId
|
||||
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||
*/
|
||||
async function loadPersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||
if (!personaId || !kbPicker) return;
|
||||
try {
|
||||
const resp = await API._get(`${apiPrefix}/personas/${personaId}/knowledge-bases`);
|
||||
kbPicker.setValues(resp.data || []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load persona KBs:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save persona KB bindings from the picker.
|
||||
* @param {string} personaId
|
||||
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||
*/
|
||||
async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||
if (!personaId || !kbPicker) return;
|
||||
const values = kbPicker.getValues();
|
||||
try {
|
||||
await API._put(`${apiPrefix}/personas/${personaId}/knowledge-bases`, values);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save persona KBs:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('renderPersonaKBPicker', renderPersonaKBPicker);
|
||||
sb.register('loadPersonaKBs', loadPersonaKBs);
|
||||
sb.register('savePersonaKBs', savePersonaKBs);
|
||||
@@ -1,908 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Settings Handlers
|
||||
// ==========================================
|
||||
// Settings save, provider CRUD, avatar, command palette,
|
||||
// user model roles.
|
||||
|
||||
|
||||
// ── Settings ─────────────────────────────────
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const remote = await API.getSettings();
|
||||
if (remote && typeof remote === 'object') {
|
||||
if (remote.model) App.settings.model = remote.model;
|
||||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await API.updateSettings({
|
||||
model: App.settings.model,
|
||||
system_prompt: App.settings.systemPrompt,
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
});
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Avatar Preview ──────────────────────────
|
||||
function updateAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('avatarPreview');
|
||||
const letter = document.getElementById('avatarPreviewLetter');
|
||||
const removeBtn = document.getElementById('avatarRemoveBtn');
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
letter.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Avatar';
|
||||
preview.insertBefore(img, letter);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
const name = API.user?.display_name || API.user?.username || '?';
|
||||
letter.textContent = name[0].toUpperCase();
|
||||
letter.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings Handlers ────────────────────────
|
||||
|
||||
async function handleSaveSettings() {
|
||||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
|
||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||
saveSettings();
|
||||
|
||||
// ── Save profile fields (display name, email) ──
|
||||
const displayName = document.getElementById('profileDisplayName').value.trim();
|
||||
const email = document.getElementById('profileEmail').value.trim();
|
||||
const profileUpdates = {};
|
||||
if (displayName !== (API.user?.display_name || '')) profileUpdates.display_name = displayName;
|
||||
if (email !== (API.user?.email || '')) profileUpdates.email = email;
|
||||
if (Object.keys(profileUpdates).length > 0) {
|
||||
try {
|
||||
const updated = await API.updateProfile(profileUpdates);
|
||||
// Sync local user object so sidebar/avatar reflect the change
|
||||
if (updated) {
|
||||
if (updated.display_name !== undefined) API.user.display_name = updated.display_name;
|
||||
if (updated.email !== undefined) API.user.email = updated.email;
|
||||
API.saveTokens();
|
||||
UI.updateUser();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Profile save failed:', e.message);
|
||||
UI.toast('Profile update failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
UI.closeSettings();
|
||||
UI.toast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
const cur = document.getElementById('profileCurrentPw').value;
|
||||
const pw = document.getElementById('profileNewPw').value;
|
||||
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
|
||||
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
|
||||
try {
|
||||
await API.changePassword(cur, pw);
|
||||
UI.toast('Password updated', 'success');
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
document.getElementById('profileCurrentPw').value = '';
|
||||
document.getElementById('profileNewPw').value = '';
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── User BYOK Provider — primitive instances (initialized in _initSettingsListeners) ──
|
||||
var _userProvForm = null;
|
||||
var _userProvList = null;
|
||||
|
||||
function _initUserProviderPrimitives() {
|
||||
const formEl = document.getElementById('providerAddForm');
|
||||
const listEl = document.getElementById('providerList');
|
||||
if (!formEl || !listEl) return;
|
||||
|
||||
_userProvForm = renderProviderForm(formEl, {
|
||||
prefix: 'userProv',
|
||||
showPrivate: false,
|
||||
showDefaultModel: true,
|
||||
onSubmit: async (vals, isEdit) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
if (!vals.name || !vals.endpoint) return UI.toast('Fill in required fields', 'warning');
|
||||
const patch = { name: vals.name, provider: vals.provider, endpoint: vals.endpoint, model_default: vals.model_default };
|
||||
if (vals.api_key) patch.api_key = vals.api_key;
|
||||
await API.updateConfig(vals._editId, patch);
|
||||
UI.toast('Provider updated', 'success');
|
||||
} else {
|
||||
if (!vals.name || !vals.endpoint || !vals.api_key) return UI.toast('Fill in required fields', 'warning');
|
||||
const result = await API.createConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default);
|
||||
if (result.warning) UI.toast(`Provider added — ${result.warning}`, 'warning');
|
||||
else {
|
||||
const count = result.models_fetched || 0;
|
||||
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
|
||||
}
|
||||
}
|
||||
formEl.style.display = 'none';
|
||||
_userProvForm.setCreateMode();
|
||||
_userProvList.refresh();
|
||||
if (typeof fetchModels === 'function') fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => {
|
||||
formEl.style.display = 'none';
|
||||
_userProvForm.setCreateMode();
|
||||
},
|
||||
});
|
||||
|
||||
_userProvList = renderProviderList(listEl, {
|
||||
apiFetch: () => API.listConfigs(),
|
||||
showRefresh: true,
|
||||
emptyMsg: 'No providers configured.',
|
||||
onEdit: async (prov) => {
|
||||
try {
|
||||
const cfg = await API.getConfig(prov.id);
|
||||
_userProvForm.setEditMode(cfg.id, cfg);
|
||||
formEl.style.display = '';
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onDelete: async (prov) => {
|
||||
if (!await showConfirm(`Remove provider "${prov.name}"?`)) return;
|
||||
try {
|
||||
await API.deleteConfig(prov.id);
|
||||
UI.toast('Provider removed', 'success');
|
||||
_userProvList.refresh();
|
||||
if (typeof fetchModels === 'function') fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onRefresh: async (prov) => {
|
||||
try {
|
||||
UI.toast(`Fetching models for ${prov.name}...`, 'info');
|
||||
const result = await API.fetchProviderModels(prov.id);
|
||||
const total = result.total || 0;
|
||||
UI.toast(`${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
|
||||
if (typeof fetchModels === 'function') fetchModels();
|
||||
} catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Admin system prompt → global_settings (JSON value)
|
||||
const sysPromptContent = document.getElementById('adminSystemPrompt').value.trim();
|
||||
await API.adminUpdateSetting('system_prompt', { value: { content: sysPromptContent } });
|
||||
|
||||
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
||||
|
||||
// Registration default state → default_user_active policy
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
|
||||
|
||||
// User BYOK providers → allow_user_byok policy
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
|
||||
|
||||
// User personas → allow_user_personas policy
|
||||
const userPersonas = document.getElementById('adminUserPersonasToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPersonas ? 'true' : 'false' });
|
||||
|
||||
// Default model → default_model policy
|
||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
||||
|
||||
// KB direct access → kb_direct_access policy (v0.17.0)
|
||||
const kbDirect = document.getElementById('adminKBDirectAccessToggle')?.checked;
|
||||
if (kbDirect != null) {
|
||||
await API.adminUpdateSetting('kb_direct_access', { value: kbDirect ? 'true' : 'false' });
|
||||
}
|
||||
|
||||
// Banner → global_settings (JSON value)
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
bg: document.getElementById('adminBannerBg').value,
|
||||
fg: document.getElementById('adminBannerFg').value,
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
// Web Search config → global_settings (JSON value)
|
||||
const searchConfig = {
|
||||
provider: document.getElementById('adminSearchProvider').value,
|
||||
endpoint: document.getElementById('adminSearchEndpoint').value.trim(),
|
||||
api_key: document.getElementById('adminSearchApiKey').value.trim(),
|
||||
max_results: parseInt(document.getElementById('adminSearchMaxResults').value) || 5,
|
||||
};
|
||||
await API.adminUpdateSetting('search_config', { value: searchConfig });
|
||||
|
||||
// Auto-Compaction config → global_settings
|
||||
const compactionEnabled = document.getElementById('adminCompactionEnabled')?.checked || false;
|
||||
const compactionThreshold = parseInt(document.getElementById('adminCompactionThreshold')?.value) || 70;
|
||||
const compactionCooldown = parseInt(document.getElementById('adminCompactionCooldown')?.value) || 30;
|
||||
await API.adminUpdateSetting('auto_compaction', { value: {
|
||||
enabled: compactionEnabled,
|
||||
threshold: compactionThreshold,
|
||||
cooldown: compactionCooldown,
|
||||
}});
|
||||
// Scanner reads these individual keys each tick
|
||||
await API.adminUpdateSetting('auto_compaction_enabled', { value: compactionEnabled });
|
||||
await API.adminUpdateSetting('auto_compaction_threshold', { value: compactionThreshold / 100 });
|
||||
await API.adminUpdateSetting('auto_compaction_cooldown_minutes', { value: compactionCooldown });
|
||||
|
||||
// Memory extraction (v0.18.0)
|
||||
const memExtractionEnabled = document.getElementById('adminMemoryExtractionEnabled')?.checked || false;
|
||||
const memAutoApprove = document.getElementById('adminMemoryAutoApprove')?.checked || false;
|
||||
await API.adminUpdateSetting('memory_extraction', { value: {
|
||||
enabled: memExtractionEnabled,
|
||||
auto_approve: memAutoApprove,
|
||||
}});
|
||||
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
|
||||
|
||||
// Email / SMTP config (v0.20.0 Phase 3)
|
||||
if (typeof NotifPrefs !== 'undefined' && NotifPrefs._getAdminSmtp) {
|
||||
const smtpConfig = NotifPrefs._getAdminSmtp();
|
||||
await API.adminUpdateSetting('notifications', { value: smtpConfig });
|
||||
}
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
if (typeof initBanners === 'function') await initBanners();
|
||||
if (typeof UI.checkUserProvidersAllowed === 'function') UI.checkUserProvidersAllowed();
|
||||
if (typeof UI.checkUserPersonasAllowed === 'function') UI.checkUserPersonasAllowed();
|
||||
if (typeof fetchModels === 'function') fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── User Model Role — primitive instance (initialized in _initSettingsListeners) ──
|
||||
var _userRoleConfig = null;
|
||||
|
||||
function _initUserRolePrimitive() {
|
||||
const el = document.getElementById('userRolesConfig');
|
||||
if (!el) return;
|
||||
|
||||
_userRoleConfig = renderRoleConfig(el, {
|
||||
scope: 'personal',
|
||||
showFallback: false,
|
||||
modelIdField: 'baseModelId',
|
||||
modelNameField: 'name',
|
||||
providerIdField: 'configId',
|
||||
noneLabel: '— use org default —',
|
||||
fetchProviders: async () => {
|
||||
const configs = await API.listConfigs();
|
||||
const list = configs.configs || configs.data || [];
|
||||
return list.filter(c => c.scope === 'personal');
|
||||
},
|
||||
fetchModels: async () => {
|
||||
// Refresh App.models to ensure personal provider models are current.
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
return (typeof App !== 'undefined' && App.models) || [];
|
||||
},
|
||||
fetchRoles: async () => {
|
||||
const settings = await API.getSettings() || {};
|
||||
return settings.model_roles || {};
|
||||
},
|
||||
onSave: async (roleId, config) => {
|
||||
if (!config.primary) throw new Error('Select both a provider and model');
|
||||
const settings = await API.getSettings() || {};
|
||||
const roles = settings.model_roles || {};
|
||||
roles[roleId] = config;
|
||||
await API.updateSettings({ model_roles: roles });
|
||||
UI.toast(`${roleId} role override saved`, 'success');
|
||||
},
|
||||
onClear: async (roleId) => {
|
||||
const settings = await API.getSettings() || {};
|
||||
const roles = settings.model_roles || {};
|
||||
delete roles[roleId];
|
||||
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
|
||||
UI.toast(`${roleId} role override removed — using org default`, 'success');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Palette ──────────────────────────
|
||||
|
||||
const _cmdCommands = [
|
||||
// Navigation
|
||||
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
|
||||
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
|
||||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>', action: () => UI.toggleSidebar() },
|
||||
|
||||
// Tools
|
||||
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
|
||||
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
|
||||
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
|
||||
|
||||
// Settings
|
||||
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
|
||||
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
|
||||
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
|
||||
|
||||
// Account
|
||||
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', action: () => handleLogout() },
|
||||
];
|
||||
|
||||
let _cmdActiveIndex = 0;
|
||||
|
||||
function toggleCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
if (el.classList.contains('active')) { closeCmdPalette(); return; }
|
||||
openCmdPalette();
|
||||
}
|
||||
|
||||
function openCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
const input = document.getElementById('cmdInput');
|
||||
el.classList.add('active');
|
||||
input.value = '';
|
||||
_cmdActiveIndex = 0;
|
||||
_renderCmdResults('');
|
||||
input.focus();
|
||||
|
||||
// Wire up input events (use named handler to avoid duplicates)
|
||||
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
|
||||
input.onkeydown = _handleCmdKey;
|
||||
|
||||
// Close on overlay click
|
||||
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
|
||||
}
|
||||
|
||||
function closeCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
el.classList.remove('active');
|
||||
document.getElementById('cmdInput').oninput = null;
|
||||
document.getElementById('cmdInput').onkeydown = null;
|
||||
}
|
||||
|
||||
function _handleCmdKey(e) {
|
||||
const results = document.getElementById('cmdResults');
|
||||
const items = results.querySelectorAll('.cmd-item');
|
||||
if (!items.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
|
||||
_highlightCmdItem(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
|
||||
_highlightCmdItem(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
items[_cmdActiveIndex]?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _highlightCmdItem(items) {
|
||||
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
|
||||
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function _getVisibleCommands() {
|
||||
return _cmdCommands.filter(c => !c.visible || c.visible());
|
||||
}
|
||||
|
||||
function _renderCmdResults(query) {
|
||||
const el = document.getElementById('cmdResults');
|
||||
const q = query.trim().toLowerCase();
|
||||
let commands = _getVisibleCommands();
|
||||
|
||||
// Add recent chats as commands when searching
|
||||
if (q) {
|
||||
const chatMatches = App.chats
|
||||
.filter(c => (c.title || '').toLowerCase().includes(q))
|
||||
.slice(0, 5)
|
||||
.map(c => ({
|
||||
id: 'chat-' + c.id,
|
||||
label: c.title || 'Untitled',
|
||||
group: 'Chats',
|
||||
hint: _relativeTime(c.updatedAt),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: () => selectChat(c.id),
|
||||
}));
|
||||
commands = [...chatMatches, ...commands];
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if (q) {
|
||||
commands = commands.filter(c => c.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
el.innerHTML = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp active index
|
||||
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
|
||||
|
||||
// Render grouped
|
||||
let html = '';
|
||||
let lastGroup = '';
|
||||
commands.forEach((c, i) => {
|
||||
if (c.group !== lastGroup) {
|
||||
lastGroup = c.group;
|
||||
html += `<div class="cmd-group-label">${c.group}</div>`;
|
||||
}
|
||||
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
|
||||
${c.icon}
|
||||
<span class="cmd-item-label">${c.label}</span>
|
||||
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
el.innerHTML = html;
|
||||
|
||||
// Attach click handlers
|
||||
el.querySelectorAll('.cmd-item').forEach((item, i) => {
|
||||
item.addEventListener('click', () => {
|
||||
closeCmdPalette();
|
||||
commands[i].action();
|
||||
});
|
||||
item.addEventListener('mouseenter', () => {
|
||||
_cmdActiveIndex = i;
|
||||
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settings Listeners (extracted from initListeners) ──
|
||||
// Settings surface elements (settingsModel, providerShowAddBtn, etc.) only
|
||||
// exist on the /settings/ surface. Team admin elements (settingsTeamAddMemberBtn,
|
||||
// etc.) exist on the chat surface. Both are wired here — settings-surface
|
||||
// features under a guard, team admin features unconditionally via ?.
|
||||
|
||||
function _initSettingsListeners() {
|
||||
// ── Settings surface features (only exist on /settings/) ──
|
||||
// Use data-surface to detect we're on the settings surface (not chat).
|
||||
// Individual features guard on their own elements since each section
|
||||
// only renders its own DOM.
|
||||
const isSettingsSurface = document.body.dataset.surface === 'settings';
|
||||
if (isSettingsSurface) {
|
||||
// General section: model change hint
|
||||
const settingsModel = document.getElementById('settingsModel');
|
||||
if (settingsModel) {
|
||||
settingsModel.addEventListener('change', function() {
|
||||
const model = App.findModel(this.value);
|
||||
const caps = model?.capabilities || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
|
||||
: '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Providers section: BYOK form + list primitives
|
||||
_initUserProviderPrimitives();
|
||||
document.getElementById('providerShowAddBtn')?.addEventListener('click', () => {
|
||||
const formEl = document.getElementById('providerAddForm');
|
||||
if (_userProvForm) _userProvForm.setCreateMode();
|
||||
if (formEl) formEl.style.display = '';
|
||||
});
|
||||
|
||||
// Models section: role primitives
|
||||
_initUserRolePrimitive();
|
||||
}
|
||||
|
||||
// ── Team management (team admin self-service, on chat surface) ──
|
||||
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = '';
|
||||
const sel = document.getElementById('settingsTeamMemberUser');
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(UI._managingTeamId);
|
||||
const existingIds = new Set((resp.data || []).map(m => m.user_id));
|
||||
sel.innerHTML = '<option value="">Enter user ID or ask sys-admin</option>';
|
||||
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
|
||||
});
|
||||
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
});
|
||||
var _teamPersonaForm = null;
|
||||
document.getElementById('settingsTeamAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('settingsTeamAddPersona');
|
||||
container.style.display = '';
|
||||
if (!_teamPersonaForm) {
|
||||
_teamPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'teamPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
kbScope: 'team',
|
||||
kbTeamId: UI._managingTeamId,
|
||||
onSubmit: async (vals) => {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.teamCreatePersona(teamId, vals);
|
||||
const personaId = result?.id;
|
||||
if (personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
|
||||
}
|
||||
if (personaId && vals._kbValues) {
|
||||
try { await API.teamSetPersonaKBs(teamId, personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_teamPersonaForm.clearForm();
|
||||
UI.toast('Team persona created');
|
||||
await UI.loadTeamManagePersonas(teamId);
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _teamPersonaForm.clearForm(); }
|
||||
});
|
||||
} else {
|
||||
_teamPersonaForm.clearForm();
|
||||
}
|
||||
UI.loadTeamPersonaModelDropdown(UI._managingTeamId);
|
||||
});
|
||||
|
||||
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
|
||||
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
|
||||
const formEl = document.getElementById('settingsTeamProviderForm');
|
||||
if (!formEl) return;
|
||||
if (UI._teamProvForm) UI._teamProvForm.setCreateMode();
|
||||
formEl.style.display = formEl.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
|
||||
// User — personal personas
|
||||
var _userPersonaForm = null;
|
||||
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('userAddPersonaForm');
|
||||
container.style.display = container.style.display === 'none' ? '' : 'none';
|
||||
if (!_userPersonaForm) {
|
||||
_userPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'userPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
kbScope: 'personal',
|
||||
onSubmit: async (vals) => {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.createUserPersona(vals);
|
||||
const personaId = result?.id;
|
||||
if (personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
|
||||
}
|
||||
if (personaId && vals._kbValues) {
|
||||
try { await API.setUserPersonaKBs(personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('User persona KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_userPersonaForm.clearForm();
|
||||
UI.toast('Persona created');
|
||||
await UI.loadUserPersonas();
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _userPersonaForm.clearForm(); }
|
||||
});
|
||||
}
|
||||
const sel = _userPersonaForm.getModelSelect();
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPersona).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin settings toggle wiring — safe to call from admin surface scaffold
|
||||
// (no App dependencies, all optional-chained)
|
||||
function _initAdminSettingsToggles() {
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
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(); }});
|
||||
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||||
});
|
||||
// Memory extraction toggle
|
||||
document.getElementById('adminMemoryExtractionEnabled')?.addEventListener('change', (e) => {
|
||||
const f = document.getElementById('memoryExtractionConfigFields');
|
||||
if (f) f.style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
|
||||
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
|
||||
|
||||
// Admin — usage
|
||||
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
|
||||
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
|
||||
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
|
||||
|
||||
// Admin — settings export/import
|
||||
document.getElementById('adminExportSettings')?.addEventListener('click', _exportAdminSettings);
|
||||
const importBtn = document.getElementById('adminImportSettings');
|
||||
const importFile = document.getElementById('adminImportFile');
|
||||
if (importBtn && importFile) {
|
||||
importBtn.addEventListener('click', () => importFile.click());
|
||||
importFile.addEventListener('change', () => {
|
||||
if (importFile.files[0]) _importAdminSettings(importFile.files[0]);
|
||||
importFile.value = ''; // reset so same file can be re-selected
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Settings Export ────────────────────
|
||||
async function _exportAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
const envelope = {
|
||||
_type: 'switchboard_admin_settings',
|
||||
_version: 1,
|
||||
_exported_at: new Date().toISOString(),
|
||||
_switchboard_version: window.__VERSION__ || 'unknown',
|
||||
settings: data.settings || {},
|
||||
policies: data.policies || {},
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
a.download = `switchboard-admin-${ts}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Count sensitive keys so admin is aware
|
||||
const sensitiveKeys = ['search_config', 'notifications'];
|
||||
const hasSensitive = sensitiveKeys.some(k => data.settings?.[k]);
|
||||
UI.toast(
|
||||
'Settings exported' + (hasSensitive ? ' (includes API keys/credentials)' : ''),
|
||||
hasSensitive ? 'warning' : 'success'
|
||||
);
|
||||
} catch (e) {
|
||||
UI.toast('Export failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Settings Import ────────────────────
|
||||
async function _importAdminSettings(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(text);
|
||||
} catch (_) {
|
||||
UI.toast('Invalid JSON file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope._type !== 'switchboard_admin_settings') {
|
||||
UI.toast('Not a Switchboard settings export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsCount = Object.keys(envelope.settings || {}).length;
|
||||
const policiesCount = Object.keys(envelope.policies || {}).length;
|
||||
const totalKeys = settingsCount + policiesCount;
|
||||
|
||||
if (totalKeys === 0) {
|
||||
UI.toast('Settings file is empty', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = envelope._exported_at
|
||||
? `Exported ${new Date(envelope._exported_at).toLocaleString()}`
|
||||
: 'Unknown export date';
|
||||
const ver = envelope._switchboard_version
|
||||
? ` from v${envelope._switchboard_version}`
|
||||
: '';
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
`Import ${totalKeys} settings?\n\n` +
|
||||
`${settingsCount} config keys, ${policiesCount} policies\n` +
|
||||
`${meta}${ver}\n\n` +
|
||||
`This will overwrite current admin settings.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
let applied = 0;
|
||||
let errors = 0;
|
||||
|
||||
// Apply policies (string values)
|
||||
for (const [key, val] of Object.entries(envelope.policies || {})) {
|
||||
try {
|
||||
await API.adminUpdateSetting(key, { value: val });
|
||||
applied++;
|
||||
} catch (e) {
|
||||
console.warn(`[import] policy "${key}" failed:`, e.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply settings (JSON values)
|
||||
for (const [key, val] of Object.entries(envelope.settings || {})) {
|
||||
try {
|
||||
await API.adminUpdateSetting(key, { value: val });
|
||||
applied++;
|
||||
} catch (e) {
|
||||
console.warn(`[import] setting "${key}" failed:`, e.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors === 0) {
|
||||
UI.toast(`Imported ${applied} settings`, 'success');
|
||||
} else {
|
||||
UI.toast(`Imported ${applied} settings, ${errors} failed (check console)`, 'warning');
|
||||
}
|
||||
|
||||
// Reload the settings form to reflect imported values
|
||||
if (typeof UI !== 'undefined' && UI.loadAdminSettings) {
|
||||
await UI.loadAdminSettings();
|
||||
}
|
||||
} catch (e) {
|
||||
UI.toast('Import failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── User-Facing Model/Persona Functions ──────
|
||||
// These are called from onclick handlers rendered by loadUserModels() and
|
||||
// loadUserPersonas(). Defined here so they're available on the settings
|
||||
// surface (admin-handlers.js which also defines them is only loaded on
|
||||
// chat and admin surfaces).
|
||||
|
||||
if (typeof toggleUserModelVisibility === 'undefined') {
|
||||
sb.register('toggleUserModelVisibility', async function(modelId, providerConfigId, currentlyHidden) {
|
||||
const compositeKey = (providerConfigId || '') + ':' + modelId;
|
||||
try {
|
||||
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
|
||||
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
|
||||
else App.hiddenModels.add(compositeKey);
|
||||
await UI.loadUserModels();
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof bulkSetUserModelVisibility === 'undefined') {
|
||||
sb.register('bulkSetUserModelVisibility', async function(show) {
|
||||
const models = (App.models || []).filter(m => !m.isPersona);
|
||||
if (!models.length) return;
|
||||
const shouldHide = !show;
|
||||
const toUpdate = models
|
||||
.filter(m => {
|
||||
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
|
||||
return App.hiddenModels.has(compositeKey) !== shouldHide;
|
||||
})
|
||||
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
|
||||
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
|
||||
try {
|
||||
await API.bulkSetModelPreferences(toUpdate, shouldHide);
|
||||
toUpdate.forEach(e => {
|
||||
const key = (e.provider_config_id || '') + ':' + e.model_id;
|
||||
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
|
||||
});
|
||||
await UI.loadUserModels();
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
UI.toast(`${toUpdate.length} model${toUpdate.length !== 1 ? 's' : ''} ${show ? 'shown' : 'hidden'}`);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof deleteUserPersona === 'undefined') {
|
||||
sb.register('deleteUserPersona', async function(id, name) {
|
||||
if (!await showConfirm(`Delete persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.deleteUserPersona(id);
|
||||
UI.toast('Persona deleted');
|
||||
await UI.loadUserPersonas();
|
||||
if (typeof fetchModels === 'function') await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
// v0.27.0: Team Workflows settings section — lists workflows for user's teams.
|
||||
// Loaded as a dynamic section in the settings surface.
|
||||
sb.register('loadTeamWorkflows', async function() {
|
||||
var el = document.getElementById('settingsDynamic');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
var teamsResp = await API.listMyTeams();
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
if (teams.length === 0) {
|
||||
el.innerHTML = '<div class="settings-placeholder">You are not a member of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<p style="font-size:13px;color:var(--text-2);margin-bottom:20px;">' +
|
||||
'Manage workflows owned by your teams. Use the Admin panel for global workflows.</p>';
|
||||
|
||||
for (var t = 0; t < teams.length; t++) {
|
||||
var team = teams[t];
|
||||
var wfResp;
|
||||
try {
|
||||
wfResp = await API.listWorkflows(team.id);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
var workflows = wfResp.data || wfResp || [];
|
||||
|
||||
html += '<div class="settings-section" style="margin-bottom:16px;">' +
|
||||
'<h4 style="margin:0 0 10px;font-size:14px;">' + esc(team.name) + '</h4>';
|
||||
|
||||
if (workflows.length === 0) {
|
||||
html += '<div class="settings-placeholder" style="padding:12px;">No workflows for this team.</div>';
|
||||
} else {
|
||||
workflows.forEach(function(wf) {
|
||||
var statusBadge = wf.is_active ?
|
||||
'<span class="badge badge-ok">Active</span>' :
|
||||
'<span class="badge">Inactive</span>';
|
||||
var base = window.__BASE__ || '';
|
||||
html += '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;' +
|
||||
'background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;margin-bottom:6px;">' +
|
||||
'<span style="flex:1;font-weight:500;">' + esc(wf.name) + '</span>' +
|
||||
statusBadge +
|
||||
'<span class="badge">v' + wf.version + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--text-3);">' + esc(wf.slug) + '</span>' +
|
||||
(API.isAdmin ?
|
||||
'<a href="' + base + '/admin/workflows" class="btn-small" style="text-decoration:none;">Edit in Admin</a>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('loadSettings', loadSettings);
|
||||
sb.register('saveSettings', saveSettings);
|
||||
sb.register('updateAvatarPreview', updateAvatarPreview);
|
||||
sb.register('_userProvForm', _userProvForm);
|
||||
sb.register('_userProvList', _userProvList);
|
||||
sb.register('handleSaveAdminSettings', handleSaveAdminSettings);
|
||||
sb.register('_userRoleConfig', _userRoleConfig);
|
||||
sb.register('toggleCmdPalette', toggleCmdPalette);
|
||||
sb.register('closeCmdPalette', closeCmdPalette);
|
||||
sb.register('_initSettingsListeners', _initSettingsListeners);
|
||||
sb.register('_initAdminSettingsToggles', _initAdminSettingsToggles);
|
||||
@@ -214,12 +214,14 @@ export function createDomains(restClient) {
|
||||
teams: {
|
||||
mine: () => rc.get('/api/v1/teams/mine'),
|
||||
get: (id) => rc.get(`/api/v1/teams/${id}`),
|
||||
update: (id, data) => rc.put(`/api/v1/teams/${id}`, data),
|
||||
members: (id) => rc.get(`/api/v1/teams/${id}/members`),
|
||||
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
|
||||
updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
|
||||
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
|
||||
personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
|
||||
createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
|
||||
updatePersona:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}`, data),
|
||||
deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
|
||||
personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
|
||||
setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
|
||||
@@ -236,6 +238,22 @@ export function createDomains(restClient) {
|
||||
audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
|
||||
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
|
||||
usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
|
||||
// Team workflows
|
||||
workflows: (id, opts) => rc.get(`/api/v1/teams/${id}/workflows` + _qs(opts)),
|
||||
createWorkflow: (id, data) => rc.post(`/api/v1/teams/${id}/workflows`, data),
|
||||
getWorkflow: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}`),
|
||||
updateWorkflow: (id, wfId, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}`, data),
|
||||
deleteWorkflow: (id, wfId) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}`),
|
||||
publishWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/publish`, {}),
|
||||
// Team tasks
|
||||
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
|
||||
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
|
||||
updateTask: (id, taskId, data) => rc.put(`/api/v1/teams/${id}/tasks/${taskId}`, data),
|
||||
deleteTask: (id, taskId) => rc.del(`/api/v1/teams/${id}/tasks/${taskId}`),
|
||||
runTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/run`, {}),
|
||||
killTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/kill`, {}),
|
||||
// Team knowledge bases
|
||||
knowledgeBases:(id) => rc.get(`/api/v1/teams/${id}/knowledge-bases`),
|
||||
},
|
||||
|
||||
// ── 15. Workflows ──────────────────────
|
||||
@@ -442,6 +460,21 @@ export function createDomains(restClient) {
|
||||
},
|
||||
},
|
||||
|
||||
// ── 19. Git Credentials ──────────────────
|
||||
git: {
|
||||
credentials: {
|
||||
list: () => rc.get('/api/v1/git-credentials'),
|
||||
create: (data) => rc.post('/api/v1/git-credentials', data),
|
||||
del: (id) => rc.del(`/api/v1/git-credentials/${id}`),
|
||||
},
|
||||
},
|
||||
|
||||
// ── 20. Data Portability ─────────────────
|
||||
dataPortability: {
|
||||
exportMe: () => rc.get('/api/v1/export/me'),
|
||||
deleteAccount: (data) => rc.post('/api/v1/profile/delete', data),
|
||||
},
|
||||
|
||||
// ── Misc (not domain-specific) ─────────
|
||||
folders: {
|
||||
list: () => rc.get('/api/v1/folders'),
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* BridgeSection — thin Preact wrapper for deferred settings sections
|
||||
*
|
||||
* Renders a container <div> with the expected DOM IDs, then calls
|
||||
* the old JS loader function so legacy code can populate it.
|
||||
*
|
||||
* Props:
|
||||
* id — mount div ID (e.g. 'settingsTasksMount')
|
||||
* loader — function to call after mount (e.g. () => _loadSettingsTasks?.())
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useEffect, useRef } = hooks;
|
||||
|
||||
export function BridgeSection({ id, loader }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loader) {
|
||||
// Defer to next tick so the DOM node is committed
|
||||
requestAnimationFrame(() => {
|
||||
try { loader(); }
|
||||
catch (e) { console.warn('[settings/bridge]', id, e.message); }
|
||||
});
|
||||
}
|
||||
}, [id, loader]);
|
||||
|
||||
return html`
|
||||
<div ref=${ref} id=${id}>
|
||||
<div class="settings-placeholder">Loading\u2026</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
59
src/js/sw/surfaces/settings/data.js
Normal file
59
src/js/sw/surfaces/settings/data.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Settings > Data & Privacy — export and account deletion
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useCallback } = hooks;
|
||||
|
||||
export default function DataSection() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const exportData = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.exportMe();
|
||||
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setExporting(false); }
|
||||
}, []);
|
||||
|
||||
const deleteAccount = useCallback(async () => {
|
||||
const ok = await sw.confirm(
|
||||
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
|
||||
true,
|
||||
);
|
||||
if (!ok) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.deleteAccount({ confirm: true });
|
||||
sw.toast('Account deletion initiated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setDeleting(false); }
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="settings-section">
|
||||
<h3>Export Your Data</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Download a copy of all your data including conversations, settings, and memories.
|
||||
The export will be prepared and a download link will be provided.
|
||||
</p>
|
||||
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
|
||||
${exporting ? 'Preparing\u2026' : 'Export My Data'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
|
||||
<h3 style="color:var(--danger);">Danger Zone</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
We recommend exporting your data before proceeding.
|
||||
</p>
|
||||
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
|
||||
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
97
src/js/sw/surfaces/settings/gitkeys.js
Normal file
97
src/js/sw/surfaces/settings/gitkeys.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Settings > Git Keys — SSH credential management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function GitKeysSection() {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.git.credentials.list();
|
||||
setKeys(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function addKey(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
const key = form.key.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.git.credentials.create({ name, public_key: key || undefined });
|
||||
sw.toast('Credential added', 'success');
|
||||
form.reset();
|
||||
setShowAdd(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKey(id) {
|
||||
const ok = await sw.confirm('Delete this SSH credential?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.git.credentials.del(id);
|
||||
sw.toast('Credential deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
|
||||
${showAdd ? 'Cancel' : '+ Add Key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showAdd && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. deploy-key-prod" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
|
||||
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Add Credential</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
|
||||
${keys.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
${k.fingerprint && html`
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
|
||||
${k.fingerprint.substring(0, 24)}\u2026
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -7,39 +7,34 @@
|
||||
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
|
||||
*
|
||||
* Layout: topbar + left nav + content area (same CSS classes as before).
|
||||
* Preact sections are loaded lazily. Bridge sections delegate to old JS.
|
||||
* All sections are native Preact components loaded lazily.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useMemo } = hooks;
|
||||
const { render } = preact;
|
||||
|
||||
import { BridgeSection } from './bridge-section.js';
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
// Each returns a Promise<{ default: Component }> or { SectionName: Component }
|
||||
const sectionModules = {
|
||||
general: () => import('./general.js'),
|
||||
appearance: () => import('./appearance.js'),
|
||||
profile: () => import('./profile.js'),
|
||||
teams: () => import('./teams.js'),
|
||||
models: () => import('./models.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
};
|
||||
|
||||
// ── Bridge section config (deferred to old JS) ──
|
||||
const bridgeSections = {
|
||||
workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() },
|
||||
tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() },
|
||||
gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() },
|
||||
data: { id: 'dpMount', loader: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy() },
|
||||
knowledge: { id: 'settingsDynamic', loader: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.() },
|
||||
memory: { id: 'settingsDynamic', loader: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.() },
|
||||
notifications: { id: 'settingsDynamic', loader: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.() },
|
||||
general: () => import('./general.js'),
|
||||
appearance: () => import('./appearance.js'),
|
||||
profile: () => import('./profile.js'),
|
||||
teams: () => import('./teams.js'),
|
||||
models: () => import('./models.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
gitkeys: () => import('./gitkeys.js'),
|
||||
data: () => import('./data.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
memory: () => import('./memory.js'),
|
||||
notifications: () => import('./notifications.js'),
|
||||
};
|
||||
|
||||
// ── Nav structure ───────────────────────────
|
||||
@@ -52,8 +47,11 @@ const NAV_ITEMS = [
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'data', label: 'Data & Privacy' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||
{ key: 'memory', label: 'Memory' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'data', label: 'Data & Privacy' },
|
||||
];
|
||||
|
||||
const BYOK_ITEMS = [
|
||||
@@ -198,12 +196,9 @@ function SettingsSurface() {
|
||||
<div class="settings-content">
|
||||
<h2>${title}</h2>
|
||||
|
||||
${bridgeSections[section]
|
||||
? html`<${BridgeSection} id=${bridgeSections[section].id}
|
||||
loader=${bridgeSections[section].loader} />`
|
||||
: SectionComponent
|
||||
? html`<${SectionComponent} />`
|
||||
: html`<div class="settings-placeholder">Loading\u2026</div>`
|
||||
${SectionComponent
|
||||
? html`<${SectionComponent} />`
|
||||
: html`<div class="settings-placeholder">Loading\u2026</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
182
src/js/sw/surfaces/settings/knowledge.js
Normal file
182
src/js/sw/surfaces/settings/knowledge.js
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Settings > Knowledge — personal knowledge base management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
export default function KnowledgeSection() {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.knowledge.list();
|
||||
setKbs(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(kb) {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createKb(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.knowledge.create({
|
||||
name,
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Knowledge base created', 'success');
|
||||
form.reset();
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKb(id) {
|
||||
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.del(id);
|
||||
sw.toast('Knowledge base deleted', 'success');
|
||||
setDetail(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadDoc() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md,.csv,.html,.pdf';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(Array.isArray(d) ? d : d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
const ok = await sw.confirm('Delete this document?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.delDoc(detail.id, docId);
|
||||
sw.toast('Document deleted', 'success');
|
||||
setDocs(prev => prev.filter(d => d.id !== docId));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
// Detail view
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
${statusBadge(detail.status || 'active')}
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
|
||||
</div>
|
||||
|
||||
${detail.description && html`
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
|
||||
`}
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
|
||||
${docs.map(d => html`
|
||||
<div class="admin-surface-row" key=${d.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${d.filename}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
|
||||
${' '}${statusBadge(d.status || 'pending')}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// List view
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
|
||||
${showCreate ? 'Cancel' : '+ Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. Product Documentation" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input name="description" placeholder="Optional description" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
${statusBadge(k.status || 'active')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
117
src/js/sw/surfaces/settings/memory.js
Normal file
117
src/js/sw/surfaces/settings/memory.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Settings > Memory — personal memory management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function truncate(s, len) {
|
||||
if (!s) return '';
|
||||
return s.length > len ? s.substring(0, len) + '\u2026' : s;
|
||||
}
|
||||
|
||||
export default function MemorySection() {
|
||||
const [memories, setMemories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [count, setCount] = useState(0);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [data, c] = await Promise.all([
|
||||
sw.api.memory.list(),
|
||||
sw.api.memory.count().catch(() => null),
|
||||
]);
|
||||
setMemories(Array.isArray(data) ? data : data.data || []);
|
||||
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function startEdit(m) {
|
||||
setEditingId(m.id);
|
||||
setEditValue(m.value || m.content || '');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await sw.api.memory.update(id, { value: editValue, content: editValue });
|
||||
sw.toast('Memory updated', 'success');
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteMemory(id) {
|
||||
const ok = await sw.confirm('Delete this memory?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.memory.del(id);
|
||||
sw.toast('Memory deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
if (!status) return null;
|
||||
const cls = status === 'approved' ? 'badge-active'
|
||||
: status === 'rejected' ? 'badge-inactive'
|
||||
: 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
|
||||
${memories.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
|
||||
${editingId === m.id ? html`
|
||||
<div>
|
||||
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
|
||||
value=${editValue}
|
||||
onInput=${e => setEditValue(e.target.value)} />
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
|
||||
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
|
||||
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
|
||||
</div>
|
||||
${statusBadge(m.status)}
|
||||
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
96
src/js/sw/surfaces/settings/notifications.js
Normal file
96
src/js/sw/surfaces/settings/notifications.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Settings > Notifications — notification preferences
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function NotificationsSection() {
|
||||
const [prefs, setPrefs] = useState(null);
|
||||
const [saving, setSaving] = useState({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.notifications.prefs();
|
||||
// Normalize: could be an array of { type, enabled, ... } or an object keyed by type
|
||||
if (Array.isArray(data)) {
|
||||
const obj = {};
|
||||
data.forEach(p => { obj[p.type] = p; });
|
||||
setPrefs(obj);
|
||||
} else {
|
||||
setPrefs(data || {});
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); setPrefs({}); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function togglePref(type, currentEnabled) {
|
||||
setSaving(prev => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
await sw.api.notifications.setPref(type, { enabled: !currentEnabled });
|
||||
setPrefs(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], enabled: !currentEnabled },
|
||||
}));
|
||||
sw.toast('Preference updated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setSaving(prev => ({ ...prev, [type]: false })); }
|
||||
}
|
||||
|
||||
async function resetPref(type) {
|
||||
setSaving(prev => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
await sw.api.notifications.delPref(type);
|
||||
sw.toast('Reset to default', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setSaving(prev => ({ ...prev, [type]: false })); }
|
||||
}
|
||||
|
||||
if (prefs === null) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
const types = Object.keys(prefs);
|
||||
|
||||
function friendlyName(type) {
|
||||
return type
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${types.length === 0 && html`<div class="empty-hint">No notification preferences available.</div>`}
|
||||
|
||||
<div class="admin-list">
|
||||
${types.map(type => {
|
||||
const pref = prefs[type];
|
||||
const enabled = pref.enabled !== false;
|
||||
const isSaving = saving[type];
|
||||
return html`
|
||||
<div class="admin-surface-row" key=${type}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${friendlyName(type)}</strong>
|
||||
${pref.description && html`
|
||||
<div class="text-muted" style="font-size:12px;margin-top:2px;">${pref.description}</div>
|
||||
`}
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:0;">
|
||||
<input type="checkbox"
|
||||
checked=${enabled}
|
||||
disabled=${isSaving}
|
||||
onChange=${() => togglePref(type, enabled)} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
<button class="btn-small btn-ghost"
|
||||
disabled=${isSaving}
|
||||
onClick=${() => resetPref(type)}
|
||||
title="Reset to default">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
71
src/js/sw/surfaces/settings/tasks.js
Normal file
71
src/js/sw/surfaces/settings/tasks.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Settings > Tasks — user's tasks with run/stop controls
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function TasksSection() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.tasks.list();
|
||||
setTasks(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.start(id);
|
||||
sw.toast('Task started', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function stopTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.stop(id);
|
||||
sw.toast('Task stopped', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
${t.schedule && html`
|
||||
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${t.is_active ? 'running' : 'stopped'}
|
||||
</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
88
src/js/sw/surfaces/settings/workflows.js
Normal file
88
src/js/sw/surfaces/settings/workflows.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Settings > Workflows — read-only view of user's visible workflows
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function WorkflowsSection() {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [stages, setStages] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflows.list();
|
||||
setWorkflows(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(wf) {
|
||||
setDetail(wf);
|
||||
try {
|
||||
const d = await sw.api.workflows.get(wf.id);
|
||||
setStages(d.stages || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${detail.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${detail.description && html`
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
|
||||
`}
|
||||
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
|
||||
<span>Slug: <strong>/${detail.slug}</strong></span>
|
||||
<span>Entry: <strong>${detail.entry_mode}</strong></span>
|
||||
<span>Version: <strong>${detail.version || 0}</strong></span>
|
||||
</div>
|
||||
|
||||
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
|
||||
<div class="admin-list">
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
${stages.map((s, i) => html`
|
||||
<div class="admin-surface-row" key=${s.id}>
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode}</span>
|
||||
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
|
||||
${workflows.map(w => html`
|
||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${w.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
|
||||
</div>
|
||||
<span class="badge">${w.entry_mode}</span>
|
||||
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${w.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
88
src/js/sw/surfaces/team-admin/activity.js
Normal file
88
src/js/sw/surfaces/team-admin/activity.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Team Admin > Activity (Audit Log)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ActivitySection({ teamId }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [actions, setActions] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterResource, setFilterResource] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const perPage = 50;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const opts = { page, per_page: perPage };
|
||||
if (filterAction) opts.action = filterAction;
|
||||
if (filterResource) opts.resource_type = filterResource;
|
||||
const data = await sw.api.teams.audit(teamId, opts);
|
||||
setEntries(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId, page, filterAction, filterResource]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.teams.auditActions(teamId).then(d => setActions(d.actions || [])).catch(() => {});
|
||||
}, [teamId]);
|
||||
|
||||
const totalPages = Math.ceil(total / perPage) || 1;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
// Extract unique resource types from actions (action format: resource.verb)
|
||||
const resourceTypes = [...new Set(actions.map(a => a.split('.')[0]).filter(Boolean))];
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="form-row" style="margin-bottom:12px;">
|
||||
<div class="form-group"><label>Action</label>
|
||||
<select value=${filterAction} onChange=${e => { setFilterAction(e.target.value); setPage(1); }}>
|
||||
<option value="">All</option>
|
||||
${actions.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Resource</label>
|
||||
<select value=${filterResource} onChange=${e => { setFilterResource(e.target.value); setPage(1); }}>
|
||||
<option value="">All</option>
|
||||
${resourceTypes.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${loading
|
||||
? html`<div class="settings-placeholder">Loading\u2026</div>`
|
||||
: html`
|
||||
<div class="admin-list">
|
||||
${entries.length === 0 && html`<div class="empty-hint">No activity entries</div>`}
|
||||
${entries.map(e => html`
|
||||
<div class="admin-surface-row" key=${e.id} style="font-size:12px;">
|
||||
<span style="width:140px;">${fmtDate(e.created_at)}</span>
|
||||
<span style="width:100px;"><strong>${e.actor_name || '\u2014'}</strong></span>
|
||||
<span class="badge" style="margin:0 4px;">${e.action}</span>
|
||||
<span style="flex:1;color:var(--text-3);">${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}</span>
|
||||
<span class="text-muted" style="width:100px;">${e.ip_address || ''}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
|
||||
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
|
||||
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
|
||||
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
37
src/js/sw/surfaces/team-admin/groups.js
Normal file
37
src/js/sw/surfaces/team-admin/groups.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Team Admin > Groups
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function GroupsSection({ teamId }) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.groups(teamId);
|
||||
setGroups(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${groups.length === 0 && html`<div class="empty-hint">No groups</div>`}
|
||||
${groups.map(g => html`
|
||||
<div class="admin-surface-row" key=${g.id}>
|
||||
<strong style="flex:1;">${g.name}</strong>
|
||||
<span class="text-muted" style="font-size:12px;">${g.member_count || 0} members</span>
|
||||
<span class="badge">${(g.permissions || []).length} permissions</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
229
src/js/sw/surfaces/team-admin/index.js
Normal file
229
src/js/sw/surfaces/team-admin/index.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* TeamAdminSurface — root team admin surface
|
||||
*
|
||||
* Reads globals:
|
||||
* __SECTION__ — active section name (string)
|
||||
* __BASE__ — base path
|
||||
*
|
||||
* Layout: topbar (back + team name) + sidebar nav + content area.
|
||||
* All 10 sections are native Preact components loaded lazily.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useMemo } = hooks;
|
||||
const { render } = preact;
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
|
||||
// ── Section config ──────────────────────────
|
||||
const SECTIONS = [
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'providers', label: 'Providers' },
|
||||
{ key: 'personas', label: 'Personas' },
|
||||
{ key: 'knowledge', label: 'Knowledge' },
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
{ key: 'usage', label: 'Usage' },
|
||||
{ key: 'activity', label: 'Activity' },
|
||||
];
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
const sectionModules = {
|
||||
members: () => import('./members.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
settings: () => import('./settings.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
activity: () => import('./activity.js'),
|
||||
};
|
||||
|
||||
function TeamAdminSurface() {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const section = window.__SECTION__ || 'members';
|
||||
|
||||
const [SectionComponent, setSectionComponent] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
// Determine teams this user can admin
|
||||
const adminTeams = useMemo(() => {
|
||||
const teams = sw.auth?.teams || [];
|
||||
if (sw.isAdmin) return teams;
|
||||
return teams.filter(t => t.my_role === 'admin');
|
||||
}, []);
|
||||
|
||||
// Auto-select if only one team
|
||||
useEffect(() => {
|
||||
if (adminTeams.length === 1 && !selectedTeam) {
|
||||
setSelectedTeam(adminTeams[0]);
|
||||
}
|
||||
}, [adminTeams]);
|
||||
|
||||
// Load section component
|
||||
useEffect(() => {
|
||||
setSectionComponent(null);
|
||||
const loader = sectionModules[section];
|
||||
if (loader) {
|
||||
loader().then(mod => {
|
||||
const Comp = mod.default || Object.values(mod)[0];
|
||||
setSectionComponent(() => Comp);
|
||||
}).catch(e => console.error('[team-admin] Failed to load section:', section, e));
|
||||
}
|
||||
}, [section]);
|
||||
|
||||
// Back button with return URL stash
|
||||
useEffect(() => {
|
||||
const RETURN_KEY = 'sb_team_admin_return';
|
||||
if (!sessionStorage.getItem(RETURN_KEY)) {
|
||||
const ref = document.referrer;
|
||||
if (ref && ref.startsWith(location.origin) && !ref.includes('/team-admin')) {
|
||||
sessionStorage.setItem(RETURN_KEY, ref);
|
||||
} else {
|
||||
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const backClick = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const RETURN_KEY = 'sb_team_admin_return';
|
||||
const returnURL = sessionStorage.getItem(RETURN_KEY);
|
||||
sessionStorage.removeItem(RETURN_KEY);
|
||||
location.href = returnURL || BASE + '/';
|
||||
}, []);
|
||||
|
||||
const navClick = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const href = e.currentTarget.getAttribute('href');
|
||||
if (href) location.replace(href);
|
||||
}, []);
|
||||
|
||||
const teamId = selectedTeam?.id || selectedTeam?.team_id || null;
|
||||
const teamName = selectedTeam?.name || selectedTeam?.team_name || 'Team';
|
||||
const sectionLabel = SECTIONS.find(s => s.key === section)?.label || 'Members';
|
||||
|
||||
// Team picker if multiple teams
|
||||
if (!selectedTeam && adminTeams.length > 1) {
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<div class="settings-topbar-sep"></div>
|
||||
<span class="settings-topbar-title">Team Administration</span>
|
||||
</div>
|
||||
<div style="padding:32px;max-width:480px;margin:0 auto;">
|
||||
<h3 style="margin:0 0 16px;">Select a Team</h3>
|
||||
<div class="admin-list">
|
||||
${adminTeams.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id || t.team_id}
|
||||
style="cursor:pointer;" onClick=${() => setSelectedTeam(t)}>
|
||||
<strong style="flex:1;">${t.name || t.team_name}</strong>
|
||||
<span class="badge">${t.my_role || 'admin'}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// No teams
|
||||
if (!selectedTeam && adminTeams.length === 0) {
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<div class="settings-topbar-sep"></div>
|
||||
<span class="settings-topbar-title">Team Administration</span>
|
||||
</div>
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<div class="empty-hint">You are not an admin of any team.</div>
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
${/* Top Bar */``}
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<div class="settings-topbar-sep"></div>
|
||||
<span class="settings-topbar-title">
|
||||
${teamName}
|
||||
${adminTeams.length > 1 && html`
|
||||
<button class="btn-small btn-ghost" style="margin-left:8px;font-size:11px;"
|
||||
onClick=${() => setSelectedTeam(null)}>Switch Team</button>
|
||||
`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
${/* Left Nav */``}
|
||||
<div class="settings-nav">
|
||||
${SECTIONS.map(s => html`
|
||||
<a key=${s.key} href="${BASE}/team-admin/${s.key}"
|
||||
class="settings-nav-link ${section === s.key ? 'active' : ''}"
|
||||
onClick=${navClick}>
|
||||
${s.label}
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${/* Content */``}
|
||||
<div class="settings-content">
|
||||
<div class="settings-content-header">
|
||||
<h2>${sectionLabel}</h2>
|
||||
</div>
|
||||
<div class="settings-content-body">
|
||||
${SectionComponent && teamId
|
||||
? html`<${SectionComponent} teamId=${teamId} />`
|
||||
: html`<div class="settings-placeholder">Loading\u2026</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────────
|
||||
const mount = document.getElementById('team-admin-mount');
|
||||
if (mount) {
|
||||
render(html`<${TeamAdminSurface} />`, mount);
|
||||
console.log('[team-admin] Surface mounted');
|
||||
}
|
||||
|
||||
export { TeamAdminSurface };
|
||||
44
src/js/sw/surfaces/team-admin/knowledge.js
Normal file
44
src/js/sw/surfaces/team-admin/knowledge.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Team Admin > Knowledge
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function KnowledgeSection({ teamId }) {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.knowledgeBases(teamId);
|
||||
setKbs(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
114
src/js/sw/surfaces/team-admin/members.js
Normal file
114
src/js/sw/surfaces/team-admin/members.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Team Admin > Members
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function MembersSection({ teamId }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.members(teamId);
|
||||
setMembers(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function openAdd() {
|
||||
setShowAdd(true);
|
||||
try {
|
||||
const data = await sw.api.admin.users.list();
|
||||
setUsers(Array.isArray(data) ? data : data.users || data.data || []);
|
||||
} catch {
|
||||
// non-admin may not have access — leave empty
|
||||
setUsers([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const userId = form.userId.value;
|
||||
const role = form.role.value;
|
||||
if (!userId) return;
|
||||
try {
|
||||
await sw.api.teams.addMember(teamId, userId, role);
|
||||
sw.toast('Member added', 'success');
|
||||
setShowAdd(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateRole(memberId, newRole) {
|
||||
try {
|
||||
await sw.api.teams.updateMember(teamId, memberId, newRole);
|
||||
sw.toast('Role updated', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function removeMember(memberId) {
|
||||
const ok = await sw.confirm('Remove this member from the team?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.removeMember(teamId, memberId);
|
||||
sw.toast('Member removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Member</button>
|
||||
</div>
|
||||
|
||||
${showAdd && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${addMember}>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>User</label>
|
||||
<select name="userId">
|
||||
<option value="">Select\u2026</option>
|
||||
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.username || u.email}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Role</label>
|
||||
<select name="role">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:8px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Add</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setShowAdd(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
||||
${members.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
||||
<strong style="flex:1;">${m.username || m.user_id}</strong>
|
||||
<select value=${m.role || 'member'} style="width:100px;"
|
||||
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
106
src/js/sw/surfaces/team-admin/personas.js
Normal file
106
src/js/sw/surfaces/team-admin/personas.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Team Admin > Personas
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function PersonasSection({ teamId }) {
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | persona object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.personas(teamId);
|
||||
setPersonas(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function savePersona(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
system_prompt: form.system_prompt.value,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.teams.createPersona(teamId, data);
|
||||
sw.toast('Persona created', 'success');
|
||||
} else {
|
||||
await sw.api.teams.updatePersona(teamId, editing.id, data);
|
||||
sw.toast('Persona updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePersona(pid) {
|
||||
const ok = await sw.confirm('Delete this persona?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deletePersona(teamId, pid);
|
||||
sw.toast('Persona deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePersona}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" value=${p.name || ''} placeholder="Code Reviewer" />
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label>
|
||||
<input name="description" value=${p.description || ''} placeholder="Optional description" />
|
||||
</div>
|
||||
<div class="form-group"><label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="6" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${personas.length === 0 && html`<div class="empty-hint">No personas</div>`}
|
||||
${personas.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
${p.description && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${p.description.substring(0, 60)}${p.description.length > 60 ? '\u2026' : ''}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${p.kb_count || 0} KBs</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
123
src/js/sw/surfaces/team-admin/providers.js
Normal file
123
src/js/sw/surfaces/team-admin/providers.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Team Admin > Providers
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ProvidersSection({ teamId }) {
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [providerTypes, setProviderTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | config object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, t] = await Promise.all([
|
||||
sw.api.teams.providers(teamId),
|
||||
sw.api.admin.providers.types().catch(() => ({ types: [] })),
|
||||
]);
|
||||
setConfigs(Array.isArray(c) ? c : c.data || []);
|
||||
setProviderTypes(t.types || t.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function saveProvider(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
provider: form.provider.value,
|
||||
endpoint: form.endpoint.value.trim(),
|
||||
api_key: form.api_key.value || undefined,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.teams.createProvider(teamId, data);
|
||||
sw.toast('Provider created', 'success');
|
||||
} else {
|
||||
await sw.api.teams.updateProvider(teamId, editing.id, data);
|
||||
sw.toast('Provider updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function syncModels(pid) {
|
||||
try {
|
||||
const result = await sw.api.teams.providerModels(teamId, pid);
|
||||
sw.toast(`Models synced: ${result.added || result.count || 0}`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProvider(pid) {
|
||||
const ok = await sw.confirm('Delete this provider config?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteProvider(teamId, pid);
|
||||
sw.toast('Provider deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${editing && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
|
||||
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="provider">
|
||||
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2;"><label>Endpoint</label>
|
||||
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;"><label>API Key</label>
|
||||
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="provider-cards">
|
||||
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
|
||||
${configs.map(c => html`
|
||||
<div class="provider-card" key=${c.id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${c.name}</strong>
|
||||
<span class="badge">${c.provider}</span>
|
||||
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-inactive">no key</span>`}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
|
||||
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
72
src/js/sw/surfaces/team-admin/settings.js
Normal file
72
src/js/sw/surfaces/team-admin/settings.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Team Admin > Settings
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function SettingsSection({ teamId }) {
|
||||
const [team, setTeam] = useState(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.get(teamId);
|
||||
setTeam(data);
|
||||
setName(data.name || '');
|
||||
setDescription(data.description || '');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await sw.api.teams.update(teamId, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
sw.toast('Settings saved', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div class="admin-settings-form">
|
||||
<div class="settings-section">
|
||||
<h3>Team Details</h3>
|
||||
<div class="form-group">
|
||||
<label>Team Name</label>
|
||||
<input value=${name} onInput=${e => setName(e.target.value)} placeholder="My Team" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea rows="3" value=${description}
|
||||
onInput=${e => setDescription(e.target.value)}
|
||||
placeholder="Optional description\u2026"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${team && html`
|
||||
<div class="settings-section">
|
||||
<h3>Info</h3>
|
||||
<div class="text-muted" style="font-size:12px;">
|
||||
<div>ID: ${team.id}</div>
|
||||
${team.created_at && html`<div>Created: ${new Date(team.created_at).toLocaleString()}</div>`}
|
||||
${team.member_count != null && html`<div>Members: ${team.member_count}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:16px;">
|
||||
<button class="btn-md btn-primary" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
111
src/js/sw/surfaces/team-admin/tasks.js
Normal file
111
src/js/sw/surfaces/team-admin/tasks.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Team Admin > Tasks
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function TasksSection({ teamId }) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.tasks(teamId);
|
||||
setTasks(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function createTask(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.createTask(teamId, {
|
||||
name: form.name.value.trim(),
|
||||
schedule: form.schedule.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Task created', 'success');
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.teams.runTask(teamId, id);
|
||||
sw.toast('Task triggered', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function killTask(id) {
|
||||
try {
|
||||
await sw.api.teams.killTask(teamId, id);
|
||||
sw.toast('Task cancelled', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
const ok = await sw.confirm('Delete this task?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteTask(teamId, id);
|
||||
sw.toast('Task deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createTask}>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" placeholder="Daily Sync" /></div>
|
||||
<div class="form-group"><label>Schedule</label><input name="schedule" placeholder="0 9 * * *" /></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
|
||||
<div class="form-row" style="margin-top:8px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Create</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
57
src/js/sw/surfaces/team-admin/usage.js
Normal file
57
src/js/sw/surfaces/team-admin/usage.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Team Admin > Usage
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function UsageSection({ teamId }) {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.usage(teamId);
|
||||
setUsage(data);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '\u2014';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${usage?.totals && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${usage?.results && usage.results.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
|
||||
<div class="admin-list">
|
||||
${usage.results.map((r, i) => html`
|
||||
<div class="admin-surface-row" key=${i}>
|
||||
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
|
||||
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
|
||||
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
|
||||
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${(!usage?.totals && !usage?.results) && html`
|
||||
<div class="empty-hint">No usage data available</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
171
src/js/sw/surfaces/team-admin/workflows.js
Normal file
171
src/js/sw/surfaces/team-admin/workflows.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Team Admin > Workflows
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
|
||||
export default function WorkflowsSection({ teamId }) {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | workflow
|
||||
const [stages, setStages] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.workflows(teamId);
|
||||
setWorkflows(Array.isArray(data) ? data : data.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function openEdit(wf) {
|
||||
setEditing(wf);
|
||||
try {
|
||||
const detail = await sw.api.teams.workflows(teamId);
|
||||
const match = (Array.isArray(detail) ? detail : detail.data || []).find(w => w.id === wf.id);
|
||||
setStages(match?.stages || wf.stages || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||
}
|
||||
|
||||
async function createWorkflow(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.createWorkflow(teamId, {
|
||||
name: form.name.value.trim(),
|
||||
slug: form.slug.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Workflow created', 'success');
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateWorkflow(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.updateWorkflow(teamId, editing.id, {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
});
|
||||
sw.toast('Workflow updated', 'success');
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function publishWorkflow(wfId) {
|
||||
try {
|
||||
await sw.api.teams.publishWorkflow(teamId, wfId);
|
||||
sw.toast('Workflow published', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteWorkflow(id) {
|
||||
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteWorkflow(teamId, id);
|
||||
sw.toast('Workflow deleted', 'success');
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) return html`
|
||||
<form onSubmit=${updateWorkflow}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${editing.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
|
||||
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
|
||||
<div class="form-group"><label>Entry Mode</label>
|
||||
<select name="entry_mode">
|
||||
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
</label>
|
||||
|
||||
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
|
||||
<div class="admin-list">
|
||||
${stages.map((s, i) => html`
|
||||
<div class="admin-surface-row" key=${s.id || i}>
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||
</div>
|
||||
`)}
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" placeholder="Customer Intake" /></div>
|
||||
<div class="form-group"><label>Slug</label><input name="slug" placeholder="customer-intake" /></div>
|
||||
<div class="form-group"><label>Entry Mode</label>
|
||||
<select name="entry_mode">
|
||||
${ENTRY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
|
||||
<div class="form-row" style="margin-top:8px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Create</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
|
||||
${workflows.map(w => html`
|
||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${w.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
|
||||
</div>
|
||||
<span class="badge">${w.entry_mode}</span>
|
||||
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">${w.is_active ? 'active' : 'inactive'}</span>
|
||||
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Task Admin Panel
|
||||
// ==========================================
|
||||
// Admin section for managing scheduled tasks, run history, and global
|
||||
// task configuration. Registered as ADMIN_LOADERS.tasks in ui-admin.js.
|
||||
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' +
|
||||
'<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' +
|
||||
'<button class="btn-small" id="taskTabConfig">Configuration</button>' +
|
||||
'<button class="btn-small" id="taskTabSystem">+ System Task</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskListPane">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
'<h4 style="margin:0">Scheduled Tasks</h4>' +
|
||||
'<span id="taskCount" class="badge">0</span>' +
|
||||
'</div>' +
|
||||
'<div id="taskAdminList" class="admin-list"></div>' +
|
||||
'</div>' +
|
||||
'<div id="taskConfigPane" style="display:none">' +
|
||||
'<h4 style="margin:0 0 12px">Task Configuration</h4>' +
|
||||
'<div class="settings-section">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgEnabled"><span class="toggle-track"></span><span>Tasks Enabled</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgPersonal"><span class="toggle-track"></span><span>Allow Personal Tasks</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgRequireBYOK"><span class="toggle-track"></span><span>Require BYOK for Personal</span></label>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Concurrent</label>' +
|
||||
'<input type="number" id="taskCfgMaxConcurrent" min="1" max="50" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Tokens</label>' +
|
||||
'<input type="number" id="taskCfgMaxTokens" min="1" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Tool Calls</label>' +
|
||||
'<input type="number" id="taskCfgMaxToolCalls" min="1" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Wall Clock (seconds)</label>' +
|
||||
'<input type="number" id="taskCfgMaxWallClock" min="10" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="taskCfgSaveBtn" style="margin-top:12px">Save Configuration</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="taskRunPane" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="taskRunBackBtn">← Back</button>' +
|
||||
'<h4 id="taskRunTitle" style="margin:0;flex:1"></h4>' +
|
||||
'<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskRunList" class="admin-list"></div>' +
|
||||
'</div>' +
|
||||
'<div id="taskSystemPane" style="display:none">' +
|
||||
'<h4 style="margin:0 0 12px">Create System Task</h4>' +
|
||||
'<p style="font-size:12px;color:var(--text-2);margin:0 0 16px;line-height:1.5">' +
|
||||
'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
|
||||
'pure platform operations (cleanup, sweeps, compaction). Admin-only.</p>' +
|
||||
'<div class="settings-section" style="padding:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="sysTaskName" style="width:100%" placeholder="Nightly Session Cleanup"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Function</label>' +
|
||||
'<select id="sysTaskFunc" style="width:100%"><option value="">Loading…</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div id="sysTaskFuncDesc" style="font-size:11px;color:var(--text-3);margin:4px 0 12px;min-height:16px"></div>' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Schedule (cron)</label>' +
|
||||
'<input type="text" id="sysTaskCron" style="width:100%" placeholder="0 3 * * *"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
|
||||
'<input type="text" id="sysTaskTZ" style="width:100%" value="UTC"></div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="sysTaskCreateBtn" style="margin-top:12px">Create System Task</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
function showTab(tab) {
|
||||
var el = function(id) { return document.getElementById(id); };
|
||||
el('taskListPane').style.display = tab === 'list' ? '' : 'none';
|
||||
el('taskConfigPane').style.display = tab === 'config' ? '' : 'none';
|
||||
el('taskRunPane').style.display = tab === 'runs' ? '' : 'none';
|
||||
el('taskSystemPane').style.display = tab === 'system' ? '' : 'none';
|
||||
el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : '');
|
||||
el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : '');
|
||||
el('taskTabSystem').className = 'btn-small' + (tab === 'system' ? ' btn-primary' : '');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
var colors = {
|
||||
running: 'badge-info', completed: 'badge-success', failed: 'badge-danger',
|
||||
budget_exceeded: 'badge-warning', cancelled: 'badge-muted'
|
||||
};
|
||||
return '<span class="badge ' + (colors[status] || '') + '">' + status + '</span>';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/tasks');
|
||||
var tasks = resp.data || [];
|
||||
var list = document.getElementById('taskAdminList');
|
||||
document.getElementById('taskCount').textContent = tasks.length;
|
||||
|
||||
if (!tasks.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No scheduled tasks</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = tasks.map(function(t) {
|
||||
var active = t.is_active
|
||||
? '<span class="badge badge-success">active</span>'
|
||||
: '<span class="badge badge-muted">paused</span>';
|
||||
var scope = '<span class="badge">' + t.scope + '</span>';
|
||||
var type = '<span class="badge">' + t.task_type + '</span>';
|
||||
var funcBadge = t.task_type === 'system' && t.system_function
|
||||
? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + escapeHtml(t.system_function) + '</span>' : '';
|
||||
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
|
||||
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never';
|
||||
var triggerBtn = '';
|
||||
if (t.schedule === 'webhook' && t.trigger_token) {
|
||||
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + escapeHtml(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
|
||||
}
|
||||
|
||||
return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' +
|
||||
'<div style="flex:2">' +
|
||||
'<div style="font-weight:600;font-size:13px">' + escapeHtml(t.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
|
||||
escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' + type + funcBadge + scope + active + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' +
|
||||
'<div style="display:flex;gap:4px">' +
|
||||
triggerBtn +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' +
|
||||
'<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.task-run-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); triggerRun(btn.dataset.id); });
|
||||
});
|
||||
list.querySelectorAll('.task-history-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); showRuns(btn.dataset.id, btn.dataset.name); });
|
||||
});
|
||||
list.querySelectorAll('.task-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); });
|
||||
});
|
||||
list.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
UI.toast('Trigger URL copied', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Trigger URL:', url);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load tasks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerRun(taskId) {
|
||||
try {
|
||||
await API._post('/api/v1/admin/tasks/' + taskId + '/run', {});
|
||||
UI.toast('Task scheduled for immediate execution', 'success');
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to trigger run', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId) {
|
||||
if (!confirm('Delete this task and all run history?')) return;
|
||||
try {
|
||||
await API._delete('/api/v1/admin/tasks/' + taskId);
|
||||
UI.toast('Task deleted', 'success');
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to delete task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
var _currentTaskId = null;
|
||||
|
||||
async function showRuns(taskId, taskName) {
|
||||
_currentTaskId = taskId;
|
||||
document.getElementById('taskRunTitle').textContent = 'Run History: ' + taskName;
|
||||
showTab('runs');
|
||||
try {
|
||||
var resp = await API._get('/api/v1/tasks/' + taskId + '/runs');
|
||||
var runs = resp.data || [];
|
||||
var list = document.getElementById('taskRunList');
|
||||
|
||||
if (!runs.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No runs yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = runs.map(function(r) {
|
||||
var started = new Date(r.started_at).toLocaleString();
|
||||
var completed = r.completed_at ? new Date(r.completed_at).toLocaleString() : '\u2014';
|
||||
var duration = r.wall_clock ? r.wall_clock + 's' : '\u2014';
|
||||
var errLine = r.error
|
||||
? '<div style="font-size:11px;color:var(--danger);margin-top:2px">' + escapeHtml(r.error) + '</div>'
|
||||
: '';
|
||||
|
||||
return '<div class="admin-row" style="padding:10px 12px">' +
|
||||
'<div style="display:flex;align-items:center;gap:12px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-size:12px">' + started + ' \u2192 ' + completed + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
|
||||
'tokens: ' + r.tokens_used + ' \u00b7 tools: ' + r.tool_calls + ' \u00b7 wall: ' + duration +
|
||||
'</div>' +
|
||||
errLine +
|
||||
'</div>' +
|
||||
statusBadge(r.status) +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load runs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function killRun() {
|
||||
if (!_currentTaskId) return;
|
||||
try {
|
||||
await API._post('/api/v1/admin/tasks/' + _currentTaskId + '/kill', {});
|
||||
UI.toast('Active run cancelled', 'success');
|
||||
showRuns(_currentTaskId, document.getElementById('taskRunTitle').textContent.replace('Run History: ', ''));
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to kill run', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/settings/tasks');
|
||||
var cfg = resp || {};
|
||||
document.getElementById('taskCfgEnabled').checked = cfg.enabled !== false;
|
||||
document.getElementById('taskCfgPersonal').checked = cfg.allow_personal !== false;
|
||||
document.getElementById('taskCfgRequireBYOK').checked = cfg.personal_require_byok === true;
|
||||
document.getElementById('taskCfgMaxConcurrent').value = cfg.max_concurrent || 5;
|
||||
document.getElementById('taskCfgMaxTokens').value = cfg.default_max_tokens || 4096;
|
||||
document.getElementById('taskCfgMaxToolCalls').value = cfg.default_max_tool_calls || 10;
|
||||
document.getElementById('taskCfgMaxWallClock').value = cfg.default_max_wall_clock || 300;
|
||||
} catch (_) {
|
||||
document.getElementById('taskCfgEnabled').checked = true;
|
||||
document.getElementById('taskCfgPersonal').checked = true;
|
||||
document.getElementById('taskCfgRequireBYOK').checked = false;
|
||||
document.getElementById('taskCfgMaxConcurrent').value = 5;
|
||||
document.getElementById('taskCfgMaxTokens').value = 4096;
|
||||
document.getElementById('taskCfgMaxToolCalls').value = 10;
|
||||
document.getElementById('taskCfgMaxWallClock').value = 300;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
var payload = {
|
||||
enabled: document.getElementById('taskCfgEnabled').checked,
|
||||
allow_personal: document.getElementById('taskCfgPersonal').checked,
|
||||
personal_require_byok: document.getElementById('taskCfgRequireBYOK').checked,
|
||||
max_concurrent: parseInt(document.getElementById('taskCfgMaxConcurrent').value) || 5,
|
||||
default_max_tokens: parseInt(document.getElementById('taskCfgMaxTokens').value) || 4096,
|
||||
default_max_tool_calls: parseInt(document.getElementById('taskCfgMaxToolCalls').value) || 10,
|
||||
default_max_wall_clock: parseInt(document.getElementById('taskCfgMaxWallClock').value) || 300
|
||||
};
|
||||
await API._put('/api/v1/admin/settings/tasks', payload);
|
||||
UI.toast('Task configuration saved', 'success');
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to save config', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── System functions ──────────────────────
|
||||
var _sysFuncs = [];
|
||||
|
||||
async function loadSystemFuncs() {
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/system-functions');
|
||||
_sysFuncs = resp.data || [];
|
||||
var sel = document.getElementById('sysTaskFunc');
|
||||
sel.innerHTML = '<option value="">— select function —</option>';
|
||||
_sysFuncs.forEach(function(f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f.name;
|
||||
opt.textContent = f.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', function() {
|
||||
var desc = document.getElementById('sysTaskFuncDesc');
|
||||
var found = _sysFuncs.find(function(f) { return f.name === sel.value; });
|
||||
desc.textContent = found ? found.description : '';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load system functions:', err);
|
||||
}
|
||||
|
||||
// Wire create button
|
||||
document.getElementById('sysTaskCreateBtn').addEventListener('click', createSystemTask);
|
||||
}
|
||||
|
||||
async function createSystemTask() {
|
||||
var name = document.getElementById('sysTaskName').value.trim();
|
||||
var func_ = document.getElementById('sysTaskFunc').value;
|
||||
var cron = document.getElementById('sysTaskCron').value.trim();
|
||||
var tz = document.getElementById('sysTaskTZ').value.trim() || 'UTC';
|
||||
|
||||
if (!name) { UI.toast('Name is required', 'warning'); return; }
|
||||
if (!func_) { UI.toast('Select a system function', 'warning'); return; }
|
||||
if (!cron) { UI.toast('Schedule (cron) is required', 'warning'); return; }
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/tasks', {
|
||||
name: name,
|
||||
task_type: 'system',
|
||||
system_function: func_,
|
||||
schedule: cron,
|
||||
timezone: tz,
|
||||
scope: 'global',
|
||||
output_mode: 'channel',
|
||||
});
|
||||
UI.toast('System task created', 'success');
|
||||
showTab('list');
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to create system task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
sb.register('_loadAdminTasks', async function() {
|
||||
var container = document.getElementById('adminDynamic');
|
||||
if (!container) return;
|
||||
container.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); });
|
||||
document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); });
|
||||
document.getElementById('taskTabSystem').addEventListener('click', function() { showTab('system'); loadSystemFuncs(); });
|
||||
document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); });
|
||||
document.getElementById('taskKillBtn').addEventListener('click', killRun);
|
||||
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
|
||||
|
||||
await loadTasks();
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Task Settings (User)
|
||||
// ==========================================
|
||||
// Settings → Tasks section. CRUD for personal tasks with schedule builder,
|
||||
// persona/model picker, budget config, and starter templates.
|
||||
// Loaded on the settings surface.
|
||||
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Every morning (6am)', cron: '0 6 * * *' },
|
||||
{ label: 'Weekday mornings (8am)', cron: '0 8 * * 1-5' },
|
||||
{ label: 'Every hour', cron: '0 * * * *' },
|
||||
{ label: 'Weekly (Monday 9am)', cron: '0 9 * * 1' },
|
||||
{ label: 'Monthly (1st midnight)', cron: '0 0 1 * *' },
|
||||
{ label: 'Webhook trigger', cron: 'webhook' },
|
||||
{ label: 'Custom...', cron: '' },
|
||||
];
|
||||
|
||||
const TEMPLATES = [
|
||||
{ name: 'Morning News Digest', prompt: 'Summarize the top 5 tech news headlines from today. Be concise — one paragraph per story.', schedule: '0 6 * * *' },
|
||||
{ name: 'Daily Standup Prep', prompt: 'Review my recent notes and conversations. Generate 3 concise standup talking points covering what I worked on yesterday, what I plan today, and any blockers.', schedule: '0 8 * * 1-5' },
|
||||
{ name: 'Weekly Project Summary', prompt: 'Summarize this week\'s activity across my projects. Highlight completed items, open threads, and priorities for next week.', schedule: '0 17 * * 5' },
|
||||
{ name: 'Stock Watchlist Check', prompt: 'Check for unusual pre-market activity on major tech stocks (AAPL, GOOGL, MSFT, NVDA, AMZN). Flag any moves > 2%.', schedule: '0 9 * * 1-5' },
|
||||
{ name: 'Research Digest', prompt: 'Search for the latest papers and blog posts on AI agents and autonomous systems. Summarize the 3 most interesting findings.', schedule: '0 12 * * 1' },
|
||||
];
|
||||
|
||||
function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// Detect browser timezone
|
||||
function browserTZ() {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) { return 'UTC'; }
|
||||
}
|
||||
|
||||
// ── Task list ────────────────────────────
|
||||
async function loadTaskList() {
|
||||
var mount = document.getElementById('settingsTasksMount');
|
||||
if (!mount) return;
|
||||
|
||||
try {
|
||||
// Load personal tasks
|
||||
var resp = await API._get('/api/v1/tasks');
|
||||
var personalTasks = (resp.data || []).map(function(t) { t._source = 'personal'; return t; });
|
||||
|
||||
// Load team tasks from all user's teams
|
||||
var teamTasks = [];
|
||||
try {
|
||||
var teamsResp = await API._get('/api/v1/teams/mine');
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
for (var i = 0; i < teams.length; i++) {
|
||||
try {
|
||||
var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks');
|
||||
(tr.data || []).forEach(function(t) {
|
||||
t._source = 'team';
|
||||
t._teamName = teams[i].name;
|
||||
teamTasks.push(t);
|
||||
});
|
||||
} catch (_) { /* team may not have tasks */ }
|
||||
}
|
||||
} catch (_) { /* no teams */ }
|
||||
|
||||
// Deduplicate (user might own a team task)
|
||||
var seen = {};
|
||||
personalTasks.forEach(function(t) { seen[t.id] = true; });
|
||||
teamTasks = teamTasks.filter(function(t) { return !seen[t.id]; });
|
||||
|
||||
var tasks = personalTasks.concat(teamTasks);
|
||||
|
||||
var html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;margin:0">Your scheduled tasks. Tasks run automatically on their schedule.</p>' +
|
||||
'<button class="btn-small btn-primary" id="taskNewBtn">+ New Task</button>' +
|
||||
'</div>';
|
||||
|
||||
if (tasks.length > 0) {
|
||||
html += tasks.map(function(t) {
|
||||
var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>';
|
||||
var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : '';
|
||||
var sched = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
|
||||
var lastStatus = '';
|
||||
if (t.last_run_at) {
|
||||
lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString();
|
||||
}
|
||||
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var triggerBtn = '';
|
||||
if (t.schedule === 'webhook' && t.trigger_token) {
|
||||
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + esc(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
|
||||
}
|
||||
|
||||
return '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' +
|
||||
'<div style="display:flex;align-items:center;gap:12px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600;font-size:14px">' + esc(t.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2);margin-top:2px">' + esc(sched) + ' (' + esc(t.timezone) + ')' + lastStatus + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2)">next: ' + nextRun + '</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||||
teamBadge + status +
|
||||
triggerBtn +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' +
|
||||
'<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Templates section
|
||||
html += '<div style="margin-top:24px"><h4 style="margin:0 0 8px">Starter Templates</h4>' +
|
||||
'<p style="color:var(--text-2);font-size:12px;margin:0 0 12px">Quick-start with a pre-configured task. You can customize after creation.</p>' +
|
||||
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">';
|
||||
TEMPLATES.forEach(function(tmpl) {
|
||||
html += '<div class="settings-section" style="padding:10px 12px;cursor:pointer" data-action="_createFromTemplate" data-args=\'' + JSON.stringify([esc(tmpl.name)]) + '\'>' +
|
||||
'<div style="font-weight:600;font-size:13px">' + esc(tmpl.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
||||
mount.innerHTML = html;
|
||||
|
||||
// Wire buttons
|
||||
document.getElementById('taskNewBtn').addEventListener('click', function() { showCreateForm(); });
|
||||
mount.querySelectorAll('.task-run-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { runTask(btn.dataset.id); });
|
||||
});
|
||||
mount.querySelectorAll('.task-toggle-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { toggleTask(btn.dataset.id, btn.dataset.active === 'true'); });
|
||||
});
|
||||
mount.querySelectorAll('.task-del-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteTask(btn.dataset.id); });
|
||||
});
|
||||
mount.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
UI.toast('Trigger URL copied', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Trigger URL:', url);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create form ──────────────────────────
|
||||
function showCreateForm(defaults) {
|
||||
var d = defaults || {};
|
||||
var mount = document.getElementById('settingsTasksMount');
|
||||
if (!mount) return;
|
||||
|
||||
var schedOpts = PRESETS.map(function(p) {
|
||||
var sel = (d.schedule && d.schedule === p.cron) ? ' selected' : '';
|
||||
return '<option value="' + esc(p.cron) + '"' + sel + '>' + esc(p.label) + '</option>';
|
||||
}).join('');
|
||||
|
||||
mount.innerHTML =
|
||||
'<div style="margin-bottom:16px"><button class="btn-small" id="taskBackBtn">\u2190 Back</button></div>' +
|
||||
'<div class="settings-section" style="padding:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="taskName" value="' + esc(d.name || '') + '" style="width:100%" placeholder="Morning News Digest"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Model</label>' +
|
||||
'<input type="text" id="taskModel" value="' + esc(d.model || '') + '" style="width:100%" placeholder="(use default)"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group" style="margin-top:8px"><label>Prompt</label>' +
|
||||
'<textarea id="taskPrompt" rows="4" style="width:100%">' + esc(d.prompt || '') + '</textarea></div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Schedule</label>' +
|
||||
'<select id="taskPreset" style="width:100%">' + schedOpts + '</select></div>' +
|
||||
'<div class="form-group" style="flex:1" id="taskCustomCronGroup" style="display:none"><label>Custom Cron</label>' +
|
||||
'<input type="text" id="taskCron" value="' + esc(d.schedule || '') + '" style="width:100%" placeholder="0 6 * * *"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
|
||||
'<input type="text" id="taskTZ" value="' + esc(d.timezone || browserTZ()) + '" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Output Mode</label>' +
|
||||
'<select id="taskOutput" style="width:100%"><option value="channel">Channel</option><option value="note">Note</option><option value="webhook">Webhook</option></select></div>' +
|
||||
'<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' +
|
||||
'<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group" style="margin-top:8px" id="taskWebhookSecretGroup" style="display:none"><label>Webhook Secret (HMAC signing)</label>' +
|
||||
'<input type="text" id="taskWebhookSecret" style="width:100%" placeholder="auto-generated if empty">' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin:2px 0 0">Used to sign outbound webhook payloads. Leave blank for auto-generated secret.</p>' +
|
||||
'</div>' +
|
||||
'<div id="taskWebhookTriggerInfo" style="display:none;margin-top:12px;padding:12px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
|
||||
'<div style="font-weight:600;font-size:12px;margin-bottom:4px">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>' +
|
||||
' Webhook-triggered task</div>' +
|
||||
'<p style="font-size:12px;color:var(--text-2);margin:0;line-height:1.5">This task runs when an external service sends a POST request to its trigger URL. ' +
|
||||
'The trigger URL will be shown after creation — copy it into your CI, cron, or another task\\u2019s webhook URL for task-to-task chaining.</p>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Tokens</label>' +
|
||||
'<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Tool Calls</label>' +
|
||||
'<input type="number" id="taskMaxToolCalls" value="" style="width:100%" placeholder="default"></div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;margin-top:12px;align-items:center">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskNotifyComplete"><span class="toggle-track"></span><span>Notify on complete</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskNotifyFail" checked><span class="toggle-track"></span><span>Notify on failure</span></label>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="taskCreateBtn" style="margin-top:12px">Create Task</button>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('taskBackBtn').addEventListener('click', loadTaskList);
|
||||
document.getElementById('taskCreateBtn').addEventListener('click', createTask);
|
||||
|
||||
// Toggle custom cron visibility
|
||||
var preset = document.getElementById('taskPreset');
|
||||
var customGroup = document.getElementById('taskCustomCronGroup');
|
||||
var triggerInfo = document.getElementById('taskWebhookTriggerInfo');
|
||||
preset.addEventListener('change', function() {
|
||||
var isWebhook = preset.value === 'webhook';
|
||||
var isCustom = preset.value === '';
|
||||
customGroup.style.display = isCustom ? '' : 'none';
|
||||
triggerInfo.style.display = isWebhook ? '' : 'none';
|
||||
if (!isWebhook && !isCustom) document.getElementById('taskCron').value = preset.value;
|
||||
if (isWebhook) document.getElementById('taskCron').value = 'webhook';
|
||||
});
|
||||
// Initial state
|
||||
customGroup.style.display = preset.value === '' ? '' : 'none';
|
||||
triggerInfo.style.display = preset.value === 'webhook' ? '' : 'none';
|
||||
if (preset.value !== '' && preset.value !== 'webhook') document.getElementById('taskCron').value = preset.value;
|
||||
|
||||
// Toggle webhook URL and secret visibility
|
||||
var output = document.getElementById('taskOutput');
|
||||
var webhookGroup = document.getElementById('taskWebhookGroup');
|
||||
var webhookSecretGroup = document.getElementById('taskWebhookSecretGroup');
|
||||
output.addEventListener('change', function() {
|
||||
var isWH = output.value === 'webhook';
|
||||
webhookGroup.style.display = isWH ? '' : 'none';
|
||||
webhookSecretGroup.style.display = isWH ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
var schedule = document.getElementById('taskCron').value || document.getElementById('taskPreset').value;
|
||||
if (!schedule) { UI.toast('Schedule is required', 'error'); return; }
|
||||
|
||||
var payload = {
|
||||
name: document.getElementById('taskName').value,
|
||||
task_type: 'prompt',
|
||||
user_prompt: document.getElementById('taskPrompt').value,
|
||||
schedule: schedule,
|
||||
timezone: document.getElementById('taskTZ').value || 'UTC',
|
||||
output_mode: document.getElementById('taskOutput').value,
|
||||
notify_on_complete: document.getElementById('taskNotifyComplete').checked,
|
||||
notify_on_failure: document.getElementById('taskNotifyFail').checked,
|
||||
};
|
||||
|
||||
var model = document.getElementById('taskModel').value;
|
||||
if (model) payload.model_id = model;
|
||||
|
||||
var maxTokens = parseInt(document.getElementById('taskMaxTokens').value);
|
||||
if (maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
|
||||
var maxTools = parseInt(document.getElementById('taskMaxToolCalls').value);
|
||||
if (maxTools > 0) payload.max_tool_calls = maxTools;
|
||||
|
||||
var webhookURL = document.getElementById('taskWebhookURL')?.value;
|
||||
if (payload.output_mode === 'webhook' && webhookURL) {
|
||||
payload.webhook_url = webhookURL;
|
||||
}
|
||||
var webhookSecret = document.getElementById('taskWebhookSecret')?.value;
|
||||
if (payload.output_mode === 'webhook' && webhookSecret) {
|
||||
payload.webhook_secret = webhookSecret;
|
||||
}
|
||||
|
||||
try {
|
||||
var created = await API._post('/api/v1/tasks', payload);
|
||||
UI.toast('Task created', 'success');
|
||||
|
||||
// Show trigger URL for webhook-scheduled tasks
|
||||
if (created.schedule === 'webhook' && created.trigger_token) {
|
||||
var triggerURL = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + created.trigger_token;
|
||||
UI.modal('Webhook Trigger URL',
|
||||
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">POST to this URL to trigger the task. ' +
|
||||
'You can use this in CI pipelines, cron jobs, or as another task\\u2019s webhook URL for chaining.</p>' +
|
||||
'<div style="display:flex;gap:8px;align-items:center">' +
|
||||
'<input type="text" readonly value="' + esc(triggerURL) + '" style="flex:1;font-family:var(--mono);font-size:11px" id="_triggerURLInput">' +
|
||||
'<button class="btn-small btn-primary" id="_copyTriggerBtn">Copy</button>' +
|
||||
'</div>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin-top:8px">The request body is passed to the task as trigger_payload. ' +
|
||||
'For task-to-task chaining: set Task A\\u2019s webhook_url to Task B\\u2019s trigger URL.</p>',
|
||||
[{ label: 'Done', variant: 'primary' }]
|
||||
);
|
||||
setTimeout(function() {
|
||||
var copyBtn = document.getElementById('_copyTriggerBtn');
|
||||
if (copyBtn) copyBtn.addEventListener('click', function() {
|
||||
var input = document.getElementById('_triggerURLInput');
|
||||
if (input) { navigator.clipboard.writeText(input.value); UI.toast('Copied', 'success'); }
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
loadTaskList();
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to create task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await API._post('/api/v1/tasks/' + id + '/run', {});
|
||||
UI.toast('Task scheduled for immediate run', 'success');
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
async function toggleTask(id, isActive) {
|
||||
try {
|
||||
await API._put('/api/v1/tasks/' + id, { is_active: !isActive });
|
||||
UI.toast(isActive ? 'Task paused' : 'Task resumed', 'success');
|
||||
loadTaskList();
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
if (!confirm('Delete this task?')) return;
|
||||
try {
|
||||
await API._delete('/api/v1/tasks/' + id);
|
||||
UI.toast('Task deleted', 'success');
|
||||
loadTaskList();
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
// Template launcher
|
||||
sb.register('_createFromTemplate', function(name) {
|
||||
var tmpl = TEMPLATES.find(function(t) { return t.name === name; });
|
||||
if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule });
|
||||
});
|
||||
|
||||
// ── Entry point (called from settings loader) ──
|
||||
sb.register('_loadSettingsTasks', function() {
|
||||
loadTaskList();
|
||||
});
|
||||
1690
src/js/ui-admin.js
1690
src/js/ui-admin.js
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Settings & Teams
|
||||
// ==========================================
|
||||
// Extends UI with settings modal tabs, team management,
|
||||
// providers, usage, model roles, and user preferences.
|
||||
//
|
||||
// Exports: none (extends window.UI via Object.assign)
|
||||
|
||||
|
||||
Object.assign(UI, {
|
||||
// ── General Settings (Settings Surface) ──
|
||||
|
||||
async loadGeneralSettings() {
|
||||
// Fetch settings from API into App.settings
|
||||
try {
|
||||
const remote = await API.getSettings();
|
||||
if (remote && typeof remote === 'object') {
|
||||
if (remote.model) App.settings.model = remote.model;
|
||||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
if (remote.show_thinking !== undefined) App.settings.showThinking = remote.show_thinking;
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
|
||||
// Fetch models for the model dropdown
|
||||
await fetchModels();
|
||||
|
||||
// Populate form elements
|
||||
const modelSel = document.getElementById('settingsModel');
|
||||
if (modelSel) {
|
||||
modelSel.innerHTML = '';
|
||||
App.models.filter(m => !m.hidden).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
if (App.settings.model) modelSel.value = App.settings.model;
|
||||
}
|
||||
|
||||
const sysEl = document.getElementById('settingsSystemPrompt');
|
||||
if (sysEl) sysEl.value = App.settings.systemPrompt || '';
|
||||
|
||||
const maxEl = document.getElementById('settingsMaxTokens');
|
||||
if (maxEl) maxEl.value = App.settings.maxTokens || '';
|
||||
|
||||
const tempEl = document.getElementById('settingsTemp');
|
||||
const tempVal = document.getElementById('settingsTempValue');
|
||||
if (tempEl) {
|
||||
tempEl.value = App.settings.temperature;
|
||||
if (tempVal) tempVal.textContent = App.settings.temperature;
|
||||
tempEl.addEventListener('input', () => {
|
||||
if (tempVal) tempVal.textContent = tempEl.value;
|
||||
});
|
||||
}
|
||||
|
||||
const thinkEl = document.getElementById('settingsThinking');
|
||||
if (thinkEl) thinkEl.checked = App.settings.showThinking;
|
||||
|
||||
// Model change → update max tokens hint
|
||||
if (modelSel) {
|
||||
modelSel.addEventListener('change', () => {
|
||||
const model = App.findModel(modelSel.value);
|
||||
const caps = model?.capabilities || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : '';
|
||||
}
|
||||
});
|
||||
// Trigger once to show current model's hint
|
||||
modelSel.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// Save button (reuse existing pattern — form auto-saves are not a thing yet)
|
||||
const section = document.querySelector('.settings-section');
|
||||
if (section && !section.querySelector('.settings-save-btn')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-md btn-primary settings-save-btn';
|
||||
btn.textContent = 'Save';
|
||||
btn.style.marginTop = '12px';
|
||||
btn.addEventListener('click', async () => {
|
||||
App.settings.model = modelSel?.value || App.settings.model;
|
||||
App.settings.systemPrompt = sysEl?.value?.trim() || '';
|
||||
App.settings.maxTokens = parseInt(maxEl?.value) || 0;
|
||||
App.settings.temperature = parseFloat(tempEl?.value) || 0.7;
|
||||
App.settings.showThinking = thinkEl?.checked || false;
|
||||
try {
|
||||
await API.updateSettings({
|
||||
model: App.settings.model,
|
||||
system_prompt: App.settings.systemPrompt,
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
show_thinking: App.settings.showThinking,
|
||||
});
|
||||
UI.toast('Settings saved', 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
section.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Appearance Settings ─────────────────
|
||||
|
||||
saveAppearance() {
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
|
||||
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
|
||||
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
|
||||
const activeThemeBtn = document.querySelector('#themeToggle .toggle-btn.active');
|
||||
if (activeThemeBtn && typeof Theme !== 'undefined') {
|
||||
Theme.set(activeThemeBtn.dataset.theme);
|
||||
}
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
|
||||
UI.toast('Appearance saved', 'success');
|
||||
},
|
||||
|
||||
// ── Teams Settings (Settings Surface) ────
|
||||
|
||||
async loadTeamsSettings() {
|
||||
const el = document.getElementById('settingsDynamic');
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await API.listMyTeams();
|
||||
const teams = resp.data || [];
|
||||
if (!teams.length) {
|
||||
el.innerHTML = '<div class="empty-hint">You are not a member of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<h3 style="margin:0">${esc(t.name)}</h3>
|
||||
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}" style="font-size:11px;">${t.my_role}</span>
|
||||
</div>
|
||||
${t.description ? `<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">${esc(t.description)}</p>` : ''}
|
||||
<div style="font-size:12px;color:var(--text-3);">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed to load teams.</div>';
|
||||
}
|
||||
},
|
||||
|
||||
loadAppearanceSettings() {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
// v0.23.2: Read theme from Theme API
|
||||
const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
|
||||
const keymap = prefs.editorKeymap || 'standard';
|
||||
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
|
||||
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
|
||||
|
||||
// Highlight active theme button and wire click
|
||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight active keymap button
|
||||
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.keymap === keymap);
|
||||
});
|
||||
},
|
||||
|
||||
initAppearance() {
|
||||
// Theme and appearance are already applied by the universal init
|
||||
// in base.html (Theme.init() + UI.restoreAppearance()). This method
|
||||
// only wires up the settings UI controls.
|
||||
|
||||
// Scale slider: show value live, but apply zoom only on release to avoid
|
||||
// jarring layout jumps mid-drag (zoom changes the layout viewport).
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
if (scaleEl) {
|
||||
scaleEl.addEventListener('input', () => {
|
||||
document.getElementById('scaleValue').textContent = parseInt(scaleEl.value) + '%';
|
||||
});
|
||||
scaleEl.addEventListener('change', () => {
|
||||
UI.applyAppearance(parseInt(scaleEl.value), parseInt(msgFontEl?.value || 14));
|
||||
});
|
||||
}
|
||||
if (msgFontEl) {
|
||||
msgFontEl.addEventListener('input', () => {
|
||||
document.getElementById('msgFontValue').textContent = parseInt(msgFontEl.value) + 'px';
|
||||
});
|
||||
msgFontEl.addEventListener('change', () => {
|
||||
UI.applyAppearance(parseInt(scaleEl?.value || 100), parseInt(msgFontEl.value));
|
||||
});
|
||||
}
|
||||
|
||||
// Theme toggle buttons
|
||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const theme = btn.dataset.theme;
|
||||
UI.applyTheme(theme); // delegates to Theme.set() — single source of truth
|
||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
|
||||
b.classList.toggle('active', b === btn)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Editor keymap toggle buttons
|
||||
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.keymap;
|
||||
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(b =>
|
||||
b.classList.toggle('active', b === btn)
|
||||
);
|
||||
// Persist
|
||||
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
p.editorKeymap = mode;
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
||||
// Notify open editors
|
||||
if (typeof sw !== 'undefined') {
|
||||
sw.emit('keymap.changed', { mode });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// System theme change listener (for "system" mode)
|
||||
// Note: Theme.set('system') registers its own listener, but this
|
||||
// additionally fires the theme.changed event for CM6 editors.
|
||||
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
UI._systemThemeQuery.addEventListener('change', () => {
|
||||
const mode = typeof Theme !== 'undefined' ? Theme.get() : 'system';
|
||||
if (mode === 'system') UI.applyTheme('system');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply theme to the document.
|
||||
* @param {'light'|'dark'|'system'} mode
|
||||
*/
|
||||
applyTheme(mode) {
|
||||
// Delegate to Theme API which handles system-mode resolution and
|
||||
// listens for OS preference changes.
|
||||
if (typeof Theme !== 'undefined') {
|
||||
Theme.set(mode);
|
||||
} else {
|
||||
// Fallback if Theme not loaded — resolve manually
|
||||
let resolved = mode;
|
||||
if (mode === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
// Publish event for CM6 editors and extensions
|
||||
if (typeof sw !== 'undefined') {
|
||||
const resolved = sw.theme?.current || mode;
|
||||
sw.emit('theme.changed', { mode, resolved });
|
||||
}
|
||||
},
|
||||
|
||||
/** Get the currently resolved theme ('dark' or 'light') */
|
||||
getResolvedTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
},
|
||||
|
||||
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
const addBtn = document.getElementById('providerShowAddBtn');
|
||||
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
||||
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
|
||||
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
|
||||
const kbTabBtn = document.getElementById('settingsKBTabBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.policies?.allow_user_byok === 'true';
|
||||
if (!kbTabBtn) console.warn('[Settings] settingsKBTabBtn not found in DOM');
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
|
||||
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
|
||||
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
checkUserPersonasAllowed() {
|
||||
const addBtn = document.getElementById('userAddPersonaBtn');
|
||||
const addForm = document.getElementById('userAddPersonaForm');
|
||||
const tabBtn = document.getElementById('settingsPersonasTabBtn');
|
||||
const allowed = App.policies?.allow_user_personas === 'true';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (addForm && !allowed) addForm.style.display = 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const p = await API.getProfile();
|
||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||
document.getElementById('profileEmail').value = p.email || '';
|
||||
updateAvatarPreview(p.avatar || null);
|
||||
} catch (e) { /* optional */ }
|
||||
},
|
||||
|
||||
async loadMyTeams() {
|
||||
const section = document.getElementById('settingsTeamsSection');
|
||||
const el = document.getElementById('settingsTeamsList');
|
||||
const menuBtn = document.getElementById('menuTeamAdmin');
|
||||
if (!section || !el) return;
|
||||
try {
|
||||
const resp = await API.listMyTeams();
|
||||
const teams = resp.data || [];
|
||||
UI._myTeams = teams;
|
||||
|
||||
// Show/hide the Team Management flyout item for team admins
|
||||
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
|
||||
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
|
||||
|
||||
if (teams.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
section.style.display = '';
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" data-action="UI.openTeamManage" data-args='${JSON.stringify([t.id, esc(t.name)])}'"` : ''}>
|
||||
<div class="team-card-info">
|
||||
<strong>${esc(t.name)}</strong>
|
||||
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
|
||||
${t.my_role === 'admin' ? '<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>' : ''}
|
||||
</div>
|
||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
|
||||
},
|
||||
|
||||
_myTeams: [],
|
||||
_managingTeamId: null,
|
||||
|
||||
loadTeamsTab() {
|
||||
// No longer used — picker is in team admin modal
|
||||
},
|
||||
|
||||
async openTeamManage(teamId, teamName) {
|
||||
UI._managingTeamId = teamId;
|
||||
UI._teamAuditPage = 1;
|
||||
|
||||
// If the modal isn't open yet (called from settings "Manage →"), open it
|
||||
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
|
||||
openModal('teamAdminModal');
|
||||
}
|
||||
|
||||
// Show tabbed content, hide picker
|
||||
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||
const titleEl = document.getElementById('teamAdminTitle');
|
||||
if (adminTeams.length > 1) {
|
||||
titleEl.innerHTML = `<span class="team-admin-back" data-action="UI.showTeamPicker" " title="Back to teams">←</span> ${esc(teamName)}`;
|
||||
} else {
|
||||
titleEl.textContent = teamName;
|
||||
}
|
||||
document.getElementById('teamAdminPicker').style.display = 'none';
|
||||
document.getElementById('teamAdminContent').style.display = 'flex';
|
||||
|
||||
// Reset forms (null-safe — not all form containers may exist)
|
||||
['settingsTeamAddMember', 'settingsTeamAddPersona', 'settingsTeamProviderForm'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
const af = document.getElementById('teamAuditFilterAction');
|
||||
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||
|
||||
// Show Members tab first, load its data
|
||||
UI.switchTeamTab('members');
|
||||
},
|
||||
|
||||
showTeamPicker() {
|
||||
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||
document.getElementById('teamAdminContent').style.display = 'none';
|
||||
document.getElementById('teamAdminPicker').style.display = '';
|
||||
},
|
||||
|
||||
async loadTeamManageMembers(teamId) {
|
||||
const el = document.getElementById('settingsTeamMembers');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</div>'; return; }
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>${members.map(m => `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500;font-size:13px">${esc(m.display_name || m.email)}</div>
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="icon-btn icon-btn-danger" data-action="settingsRemoveTeamMember" data-args='${JSON.stringify([teamId, m.id, esc(m.email)])}'" title="Remove">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManageProviders(teamId) {
|
||||
const el = document.getElementById('settingsTeamProviders');
|
||||
if (!el) return;
|
||||
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
|
||||
const formEl = document.getElementById('settingsTeamProviderForm');
|
||||
|
||||
// Initialize team provider form primitive (once)
|
||||
if (!UI._teamProvForm && formEl) {
|
||||
UI._teamProvForm = renderProviderForm(formEl, {
|
||||
prefix: 'teamProv',
|
||||
showPrivate: true,
|
||||
showDefaultModel: true,
|
||||
onSubmit: async (vals, isEdit) => {
|
||||
const tid = UI._managingTeamId;
|
||||
try {
|
||||
if (isEdit) {
|
||||
const updates = {};
|
||||
if (vals.name) updates.name = vals.name;
|
||||
if (vals.endpoint) updates.endpoint = vals.endpoint;
|
||||
if (vals.api_key) updates.api_key = vals.api_key;
|
||||
if (vals.model_default) updates.model_default = vals.model_default;
|
||||
updates.is_private = vals.is_private || false;
|
||||
await API.teamUpdateProvider(tid, vals._editId, updates);
|
||||
UI.toast('Provider updated');
|
||||
} else {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.endpoint) return UI.toast('Endpoint required', 'error');
|
||||
await API.teamCreateProvider(tid, {
|
||||
name: vals.name, provider: vals.provider, endpoint: vals.endpoint,
|
||||
api_key: vals.api_key, is_private: vals.is_private || false,
|
||||
});
|
||||
UI.toast('Provider added');
|
||||
}
|
||||
formEl.style.display = 'none';
|
||||
UI._teamProvForm.setCreateMode();
|
||||
UI._teamProvList.refresh();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => {
|
||||
formEl.style.display = 'none';
|
||||
UI._teamProvForm.setCreateMode();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize team provider list primitive (once)
|
||||
if (!UI._teamProvList) {
|
||||
UI._teamProvList = renderProviderList(el, {
|
||||
apiFetch: async () => {
|
||||
const resp = await API.teamListProviders(UI._managingTeamId);
|
||||
// Handle policy check
|
||||
const allowed = resp.allow_team_providers !== false;
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (!allowed && !(resp.data || resp.providers || []).length) {
|
||||
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
showPrivate: true,
|
||||
showActive: true,
|
||||
emptyMsg: 'No team providers — add one to give your team access to additional models',
|
||||
onEdit: (prov) => {
|
||||
if (UI._teamProvForm) {
|
||||
UI._teamProvForm.setEditMode(prov.id, prov);
|
||||
formEl.style.display = '';
|
||||
}
|
||||
},
|
||||
onDelete: async (prov) => {
|
||||
if (!await showConfirm(`Delete team provider "${prov.name}"? This will remove all models from this provider for your team.`)) return;
|
||||
try {
|
||||
await API.teamDeleteProvider(UI._managingTeamId, prov.id);
|
||||
UI.toast('Provider deleted');
|
||||
UI._teamProvList.refresh();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onToggleActive: async (prov) => {
|
||||
try {
|
||||
await API.teamUpdateProvider(UI._managingTeamId, prov.id, { is_active: !prov.is_active });
|
||||
UI.toast(prov.is_active ? 'Provider deactivated' : 'Provider activated');
|
||||
UI._teamProvList.refresh();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await UI._teamProvList.refresh();
|
||||
},
|
||||
|
||||
async loadTeamManagePersonas(teamId) {
|
||||
const el = document.getElementById('settingsTeamPersonas');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListPersonas(teamId);
|
||||
const personas = resp.data || [];
|
||||
el.innerHTML = personas.map(p => {
|
||||
const icon = p.icon || '';
|
||||
return `<div class="admin-persona-row" style="padding:6px 0">
|
||||
<div class="persona-info">
|
||||
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
|
||||
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
|
||||
</div>
|
||||
<button class="btn-delete" data-action="settingsDeleteTeamPersona" data-args='${JSON.stringify([teamId, p.id, esc(p.name)])}'" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManageGroups(teamId) {
|
||||
const el = document.getElementById('settingsTeamGroups');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListGroups(teamId);
|
||||
const groups = resp.data || [];
|
||||
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Group</th><th>Members</th></tr></thead>
|
||||
<tbody>${groups.map(g => `<tr>
|
||||
<td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
|
||||
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManageSettings(teamId) {
|
||||
if (!teamId) return;
|
||||
try {
|
||||
const team = await API.teamGetTeam ? API.teamGetTeam(teamId) : await API.adminGetTeam(teamId);
|
||||
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||
const privEl = document.getElementById('teamSettingPrivatePolicy');
|
||||
const allowEl = document.getElementById('teamSettingAllowProviders');
|
||||
if (privEl) privEl.checked = !!settings.require_private_providers;
|
||||
if (allowEl) allowEl.checked = settings.allow_team_providers !== false;
|
||||
|
||||
// Wire save button (once)
|
||||
const saveBtn = document.getElementById('teamSettingSaveBtn');
|
||||
if (saveBtn && !saveBtn._wired) {
|
||||
saveBtn._wired = true;
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const tid = UI._managingTeamId;
|
||||
const priv = document.getElementById('teamSettingPrivatePolicy')?.checked || false;
|
||||
const allow = document.getElementById('teamSettingAllowProviders')?.checked !== false;
|
||||
try {
|
||||
await API.adminUpdateTeam(tid, { settings: JSON.stringify({ require_private_providers: priv, allow_team_providers: allow }) });
|
||||
UI.toast('Team settings saved', 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* proceed with defaults */ }
|
||||
},
|
||||
|
||||
async loadMyUsage() {
|
||||
const el = document.getElementById('myUsageTotals')?.parentElement;
|
||||
if (!el) return;
|
||||
if (!UI._myUsageDash) {
|
||||
UI._myUsageDash = renderUsageDashboard(el, {
|
||||
apiFetch: (p) => API.getMyUsage(p),
|
||||
periodElId: 'myUsagePeriod',
|
||||
groupByElId: 'myUsageGroupBy',
|
||||
compact: true,
|
||||
});
|
||||
}
|
||||
await UI._myUsageDash.refresh();
|
||||
},
|
||||
|
||||
async loadTeamUsage() {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!teamId) return;
|
||||
const el = document.getElementById('settingsTeamUsageTotals')?.parentElement;
|
||||
if (!el) return;
|
||||
if (!UI._teamUsageDash) {
|
||||
UI._teamUsageDash = renderUsageDashboard(el, {
|
||||
apiFetch: (p) => API.teamGetUsage(teamId, p),
|
||||
periodElId: 'teamUsagePeriod',
|
||||
groupByElId: 'teamUsageGroupBy',
|
||||
compact: true,
|
||||
showUserColumn: true,
|
||||
});
|
||||
}
|
||||
await UI._teamUsageDash.refresh();
|
||||
},
|
||||
|
||||
_teamAuditPage: 1,
|
||||
_teamAuditPerPage: 20,
|
||||
|
||||
// ── User Model Roles (BYOK overrides) ──
|
||||
|
||||
async loadUserRoles() {
|
||||
const el = document.getElementById('userRolesConfig');
|
||||
const notice = document.getElementById('userRolesDisabled');
|
||||
if (!el) return;
|
||||
|
||||
// Only show if BYOK is enabled
|
||||
const byokAllowed = App.policies?.allow_user_byok === 'true';
|
||||
if (!byokAllowed) {
|
||||
el.style.display = 'none';
|
||||
if (notice) {
|
||||
notice.innerHTML = '<p style="color:var(--text-2);font-size:13px;">BYOK (Bring Your Own Key) must be enabled by your admin before you can configure model role overrides.</p>';
|
||||
notice.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has personal providers
|
||||
try {
|
||||
const configs = await API.listConfigs();
|
||||
const configList = configs.configs || configs.data || [];
|
||||
const personalProviders = configList.filter(c => c.scope === 'personal');
|
||||
|
||||
if (personalProviders.length === 0) {
|
||||
el.style.display = 'none';
|
||||
if (notice) {
|
||||
notice.innerHTML = '<p style="color:var(--text-2);font-size:13px;">Add a personal provider in <strong>My Providers</strong> first to configure model role overrides.</p>';
|
||||
notice.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.style.display = '';
|
||||
if (notice) notice.style.display = 'none';
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_userRoleConfig) await _userRoleConfig.refresh();
|
||||
},
|
||||
|
||||
async loadTeamAuditLog(page) {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!teamId) return;
|
||||
if (page) UI._teamAuditPage = page;
|
||||
|
||||
const el = document.getElementById('settingsTeamAudit');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
// Populate action filter on first load
|
||||
const actionSel = document.getElementById('teamAuditFilterAction');
|
||||
if (actionSel && actionSel.options.length <= 1) {
|
||||
try {
|
||||
const resp = await API.teamListAuditActions(teamId);
|
||||
(resp.actions || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a;
|
||||
opt.textContent = a;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: UI._teamAuditPage,
|
||||
per_page: UI._teamAuditPerPage,
|
||||
action: document.getElementById('teamAuditFilterAction')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await API.teamListAudit(teamId, params);
|
||||
const entries = resp.data || [];
|
||||
const total = resp.total || 0;
|
||||
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pgEl = document.getElementById('teamAuditPagination');
|
||||
if (totalPages > 1) {
|
||||
pgEl.style.display = 'flex';
|
||||
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
|
||||
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
|
||||
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
|
||||
} else {
|
||||
pgEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPersonaModelDropdown(teamId) {
|
||||
const sel = document.getElementById('teamPersona_model');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Loading models...</option>';
|
||||
try {
|
||||
const resp = await API.teamListAvailableModels(teamId);
|
||||
const models = resp.models || [];
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
|
||||
// Group by source for clarity
|
||||
const globalModels = models.filter(m => m.source !== 'team');
|
||||
const teamModels = models.filter(m => m.source === 'team');
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = label;
|
||||
items.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.model_id || m.id;
|
||||
const vis = m.visibility === 'team' ? ' [team-only]' : '';
|
||||
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
|
||||
group.appendChild(opt);
|
||||
});
|
||||
sel.appendChild(group);
|
||||
};
|
||||
|
||||
addGroup('Global Models', globalModels);
|
||||
addGroup('Team Provider Models', teamModels);
|
||||
} catch (e) {
|
||||
// Fallback to App.models if team endpoint fails
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPersona).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Providers ────────────────────────────
|
||||
|
||||
async loadProviderList() {
|
||||
if (_userProvList) { await _userProvList.refresh(); return; }
|
||||
// Fallback if primitives not yet initialized
|
||||
const el = document.getElementById('providerList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
},
|
||||
|
||||
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
|
||||
hideProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = 'none';
|
||||
if (_userProvForm) _userProvForm.setCreateMode();
|
||||
},
|
||||
|
||||
// ── User Model List ─────────────────────
|
||||
|
||||
async loadUserModels() {
|
||||
const el = document.getElementById('userModelList');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading models...</div>';
|
||||
try {
|
||||
// Load preferences if not already loaded
|
||||
if (!App.hiddenModels) {
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
App.hiddenModels = new Set(
|
||||
(prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p =>
|
||||
(p.provider_config_id || '') + ':' + p.model_id
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const data = await API.listEnabledModels();
|
||||
const models = (data.data || data.models || []).filter(m => !m.is_persona);
|
||||
if (!models.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No models available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const hiddenCount = models.filter(m => {
|
||||
const cfgId = m.config_id || m.provider_config_id || '';
|
||||
return App.hiddenModels.has((cfgId || '') + ':' + (m.model_id || m.id));
|
||||
}).length;
|
||||
const visibleCount = models.length - hiddenCount;
|
||||
|
||||
let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px">${visibleCount} visible, ${hiddenCount} hidden of ${models.length} total</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[true]'">Show All</button>
|
||||
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[false]'">Hide All</button>
|
||||
</div>`;
|
||||
|
||||
html += models.map(m => {
|
||||
const mid = m.model_id || m.id;
|
||||
const cfgId = m.config_id || m.provider_config_id || '';
|
||||
const compositeKey = (cfgId || '') + ':' + mid;
|
||||
const caps = m.capabilities || {};
|
||||
const badges = renderCapBadges(caps, { compact: true });
|
||||
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
|
||||
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
|
||||
const hidden = App.hiddenModels.has(compositeKey);
|
||||
const toggleCls = hidden ? '' : 'enabled';
|
||||
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
||||
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
|
||||
<span class="model-name">${esc(mid)}</span>
|
||||
<span class="model-caps-inline">${badges}</span>
|
||||
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||
${src}
|
||||
<button class="admin-model-toggle ${toggleCls}" data-action="toggleUserModelVisibility" data-args='${JSON.stringify([esc(mid), esc(cfgId), hidden])}'" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.innerHTML = html;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadUserPersonas() {
|
||||
const el = document.getElementById('userPersonaList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.listUserPersonas();
|
||||
// Only show personal personas owned by user
|
||||
const personas = (data.data || []).filter(p => p.scope === 'personal');
|
||||
if (!personas.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = personas.map(p => {
|
||||
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` : '';
|
||||
return `<div class="admin-persona-row" style="padding:6px 0">
|
||||
<div class="persona-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong>
|
||||
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
</div>
|
||||
<div class="persona-actions">
|
||||
<button class="btn-delete" data-action="deleteUserPersona" data-args='${JSON.stringify([p.id, esc(p.name)])}'" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -1,826 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Workflow Builder (Admin)
|
||||
// ==========================================
|
||||
// Admin panel for managing workflow definitions, stages, and publishing.
|
||||
// Registered as ADMIN_LOADERS.workflows in ui-admin.js.
|
||||
|
||||
|
||||
// ── Scaffold HTML ────────────────────────
|
||||
// Injected directly into adminDynamic by the loader since SCAFFOLDING
|
||||
// is scoped inside admin-scaffold.js's IIFE and not accessible here.
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div id="wfAdminList" class="admin-list"></div>' +
|
||||
'<div id="wfAdminDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
|
||||
'<button class="btn-small" id="wfBackBtn">← Back</button>' +
|
||||
'<h4 id="wfDetailName" style="margin:0;flex:1"></h4>' +
|
||||
'<span id="wfDetailVersion" class="badge"></span>' +
|
||||
'<span id="wfDetailStatus" class="badge"></span>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="settings-section" style="margin-bottom:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="wfEditName" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Slug</label>' +
|
||||
'<input type="text" id="wfEditSlug" style="width:100%" readonly></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Description</label>' +
|
||||
'<textarea id="wfEditDesc" rows="2" style="width:100%"></textarea></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
|
||||
'<select id="wfEditEntry" style="width:100%">' +
|
||||
'<option value="authenticated">Authenticated</option>' +
|
||||
'<option value="public_link">Public Link</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
|
||||
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
|
||||
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
|
||||
'<button class="btn-small" id="wfExportBtn">Export .pkg</button>' +
|
||||
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<h5 style="margin:0 0 8px">Stages</h5>' +
|
||||
'<div id="wfStageList" class="admin-list"></div>' +
|
||||
'<button class="btn-small" id="wfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
|
||||
|
||||
'<div id="wfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
|
||||
'<input type="text" id="wfStageName" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
|
||||
'<select id="wfStageHistory" style="width:100%">' +
|
||||
'<option value="full">Full</option>' +
|
||||
'<option value="summary">Summary</option>' +
|
||||
'<option value="fresh">Fresh</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Persona</label>' +
|
||||
'<select id="wfStagePersona" style="width:100%">' +
|
||||
'<option value="">None (no AI for this stage)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>System Prompt</label>' +
|
||||
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
|
||||
'<select id="wfStageMode" style="width:100%">' +
|
||||
'<option value="chat_only">Chat Only</option>' +
|
||||
'<option value="form_only">Form Only (no AI)</option>' +
|
||||
'<option value="form_chat">Form + Chat</option>' +
|
||||
'<option value="review">Review (Human)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Custom Surface Package</label>' +
|
||||
'<select id="wfStageSurfacePkg" style="width:100%">' +
|
||||
'<option value="">— Built-in (use stage mode) —</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div id="wfFormBuilderWrap" style="display:none;margin-top:8px">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
|
||||
'<label style="margin:0;font-weight:600">Form Fields</label>' +
|
||||
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="wfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
|
||||
'</div>' +
|
||||
'<div id="wfFormFields"></div>' +
|
||||
'<button class="btn-small" id="wfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
|
||||
'<textarea id="wfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px;gap:8px">' +
|
||||
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
|
||||
'<button class="btn-small" id="wfCancelStageBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
|
||||
'<div id="wfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
|
||||
'</div>';
|
||||
|
||||
// ── State ───────────────────────────────
|
||||
|
||||
var _currentWf = null;
|
||||
var _editingStageId = null;
|
||||
var _personaCache = []; // [{id, name, icon}]
|
||||
var _surfacePkgCache = []; // [{id, title}]
|
||||
|
||||
// Load personas into the stage editor dropdown.
|
||||
async function _wfLoadPersonaSelect(selectedId) {
|
||||
var sel = document.getElementById('wfStagePersona');
|
||||
if (!sel) return;
|
||||
|
||||
// Fetch once per detail session
|
||||
if (_personaCache.length === 0) {
|
||||
try {
|
||||
var resp = await API.adminListPersonas();
|
||||
_personaCache = (resp.data || resp || []).map(function(p) {
|
||||
return { id: p.id, name: p.name, icon: p.icon || '' };
|
||||
});
|
||||
} catch (e) {
|
||||
// Fall back to user-visible personas
|
||||
try {
|
||||
var resp2 = await API.listPersonas();
|
||||
_personaCache = (resp2.data || resp2 || []).map(function(p) {
|
||||
return { id: p.id, name: p.name, icon: p.icon || '' };
|
||||
});
|
||||
} catch (e2) { /* no personas available */ }
|
||||
}
|
||||
}
|
||||
|
||||
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
|
||||
_personaCache.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
if (selectedId) sel.value = selectedId;
|
||||
}
|
||||
|
||||
// Load surface packages into the stage editor dropdown.
|
||||
async function _wfLoadSurfacePkgSelect(selectedId) {
|
||||
var sel = document.getElementById('wfStageSurfacePkg');
|
||||
if (!sel) return;
|
||||
|
||||
if (_surfacePkgCache.length === 0) {
|
||||
try {
|
||||
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages', {
|
||||
headers: API._authHeaders ? API._authHeaders() : {},
|
||||
});
|
||||
var data = await resp.json();
|
||||
var pkgs = data.data || data || [];
|
||||
// Include packages that could have surface JS (type surface or workflow)
|
||||
_surfacePkgCache = pkgs.filter(function(p) {
|
||||
return p.type === 'surface' || p.type === 'workflow' || p.type === 'full';
|
||||
}).map(function(p) {
|
||||
return { id: p.id, title: p.title || p.id };
|
||||
});
|
||||
} catch (e) { /* no packages available */ }
|
||||
}
|
||||
|
||||
sel.innerHTML = '<option value="">— Built-in (use stage mode) —</option>';
|
||||
_surfacePkgCache.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.title;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
if (selectedId) sel.value = selectedId;
|
||||
}
|
||||
|
||||
// ── Loader (called from ADMIN_LOADERS.workflows) ──
|
||||
|
||||
sb.register('_loadAdminWorkflows', async function() {
|
||||
// Self-scaffold: inject our HTML into adminDynamic
|
||||
var target = document.getElementById('adminDynamic');
|
||||
if (target && !document.getElementById('wfAdminList')) {
|
||||
target.innerHTML = SCAFFOLD_HTML;
|
||||
_wfWireFormBuilder();
|
||||
}
|
||||
|
||||
const list = document.getElementById('wfAdminList');
|
||||
const detail = document.getElementById('wfAdminDetail');
|
||||
if (!list) return;
|
||||
|
||||
detail.style.display = 'none';
|
||||
list.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await API.listWorkflows();
|
||||
const wfs = resp.data || resp || [];
|
||||
|
||||
if (wfs.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="empty-hint">No workflows yet</div>' +
|
||||
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
|
||||
'data-action="_wfCreate">+ Create Workflow</button>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
||||
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
|
||||
'<div style="display:flex;gap:6px">' +
|
||||
'<button class="btn-small" data-action="_wfImportPkg">Import .pkg</button>' +
|
||||
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button>' +
|
||||
'</div></div>';
|
||||
html += '<table class="admin-table"><thead><tr>' +
|
||||
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
for (const wf of wfs) {
|
||||
html += '<tr style="cursor:pointer" data-action="_wfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
|
||||
'<td>' + esc(wf.name) + '</td>' +
|
||||
'<td><code>' + esc(wf.slug) + '</code></td>' +
|
||||
'<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' +
|
||||
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
|
||||
'<td>v' + (wf.version || 1) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
list.innerHTML = html;
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Create ──────────────────────────────
|
||||
|
||||
sb.register('_wfCreate', async function() {
|
||||
const name = prompt('Workflow name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const wf = await API.createWorkflow({ name });
|
||||
UI.toast('Workflow created', 'success');
|
||||
_wfOpen(wf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Open Detail ─────────────────────────
|
||||
|
||||
sb.register('_wfOpen', async function(id) {
|
||||
const list = document.getElementById('wfAdminList');
|
||||
const detail = document.getElementById('wfAdminDetail');
|
||||
try {
|
||||
const wf = await API.getWorkflow(id);
|
||||
_currentWf = wf;
|
||||
list.style.display = 'none';
|
||||
detail.style.display = '';
|
||||
|
||||
document.getElementById('wfDetailName').textContent = wf.name;
|
||||
document.getElementById('wfDetailVersion').textContent = 'v' + (wf.version || 1);
|
||||
document.getElementById('wfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
|
||||
document.getElementById('wfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
|
||||
|
||||
document.getElementById('wfEditName').value = wf.name;
|
||||
document.getElementById('wfEditSlug').value = wf.slug;
|
||||
document.getElementById('wfEditDesc').value = wf.description || '';
|
||||
document.getElementById('wfEditEntry').value = wf.entry_mode || 'authenticated';
|
||||
document.getElementById('wfEditActive').checked = wf.is_active;
|
||||
|
||||
// Public URL
|
||||
const scope = wf.team_id || 'global';
|
||||
const base = location.origin + (window.__BASE__ || '');
|
||||
document.getElementById('wfPublicUrl').textContent =
|
||||
wf.entry_mode === 'public_link'
|
||||
? base + '/w/' + scope + '/' + wf.slug
|
||||
: 'Enable "Public Link" entry mode to generate a URL';
|
||||
|
||||
// Preload persona cache for stage list display
|
||||
await _wfLoadPersonaSelect('');
|
||||
|
||||
// Load stages
|
||||
await _wfLoadStages(id);
|
||||
_wfWireEvents();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load workflow: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Stage List ──────────────────────────
|
||||
|
||||
async function _wfLoadStages(wfId) {
|
||||
const el = document.getElementById('wfStageList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await API.listStages(wfId);
|
||||
const stages = resp.data || resp || [];
|
||||
if (stages.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
stages.forEach(function(s, i) {
|
||||
var personaLabel = '';
|
||||
if (s.persona_id) {
|
||||
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
|
||||
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
||||
'<span class="badge">persona</span>';
|
||||
}
|
||||
var modeLabel = '';
|
||||
if (s.stage_mode === 'review') {
|
||||
modeLabel = '<span class="badge badge-warn">review</span>';
|
||||
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
|
||||
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
|
||||
}
|
||||
var surfaceLabel = '';
|
||||
if (s.surface_pkg_id) {
|
||||
surfaceLabel = '<span class="badge badge-accent">pkg: ' + esc(s.surface_pkg_id) + '</span>';
|
||||
}
|
||||
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
||||
'<span class="wf-stage-grip">⠿</span>' +
|
||||
'<span class="wf-stage-ord">' + i + '</span>' +
|
||||
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
||||
personaLabel + modeLabel + surfaceLabel +
|
||||
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
||||
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
||||
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
|
||||
// v0.27.0: Wire DnD reorder on stage rows
|
||||
_wfWireStageDnD(el, wfId);
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire Events (called once per detail open) ──
|
||||
|
||||
// v0.27.0: Wire drag-and-drop reorder on stage rows
|
||||
function _wfWireStageDnD(container, wfId) {
|
||||
var dragSrc = null;
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
dragSrc = row;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
|
||||
r.classList.remove('drag-over');
|
||||
});
|
||||
dragSrc = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (row !== dragSrc) row.classList.add('drag-over');
|
||||
});
|
||||
row.addEventListener('dragleave', function() {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
row.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
row.classList.remove('drag-over');
|
||||
if (!dragSrc || dragSrc === row) return;
|
||||
|
||||
// Reorder in DOM
|
||||
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
|
||||
var fromIdx = rows.indexOf(dragSrc);
|
||||
var toIdx = rows.indexOf(row);
|
||||
if (fromIdx < toIdx) {
|
||||
row.parentNode.insertBefore(dragSrc, row.nextSibling);
|
||||
} else {
|
||||
row.parentNode.insertBefore(dragSrc, row);
|
||||
}
|
||||
|
||||
// Collect new order and send to backend
|
||||
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
|
||||
return r.dataset.id;
|
||||
});
|
||||
API.reorderStages(wfId, orderedIDs).then(function() {
|
||||
UI.toast('Stages reordered', 'success');
|
||||
_wfLoadStages(wfId);
|
||||
}).catch(function(err) {
|
||||
UI.toast('Reorder failed: ' + (err.message || err), 'error');
|
||||
_wfLoadStages(wfId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var _wired = false;
|
||||
function _wfWireEvents() {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
|
||||
document.getElementById('wfBackBtn').onclick = function() {
|
||||
_currentWf = null;
|
||||
_editingStageId = null;
|
||||
_personaCache = [];
|
||||
_surfacePkgCache = [];
|
||||
_wired = false;
|
||||
_loadAdminWorkflows();
|
||||
};
|
||||
|
||||
document.getElementById('wfSaveBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
try {
|
||||
await API.updateWorkflow(_currentWf.id, {
|
||||
name: document.getElementById('wfEditName').value.trim(),
|
||||
description: document.getElementById('wfEditDesc').value.trim(),
|
||||
entry_mode: document.getElementById('wfEditEntry').value,
|
||||
is_active: document.getElementById('wfEditActive').checked,
|
||||
});
|
||||
UI.toast('Saved', 'success');
|
||||
_wfOpen(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('wfPublishBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
try {
|
||||
const ver = await API.publishWorkflow(_currentWf.id);
|
||||
UI.toast('Published v' + ver.version_number, 'success');
|
||||
_wfOpen(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('wfExportBtn').onclick = function() {
|
||||
if (!_currentWf) return;
|
||||
API.exportWorkflowPkg(_currentWf.id);
|
||||
};
|
||||
|
||||
document.getElementById('wfDeleteBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await API.deleteWorkflow(_currentWf.id);
|
||||
UI.toast('Deleted', 'success');
|
||||
_currentWf = null;
|
||||
_personaCache = [];
|
||||
_surfacePkgCache = [];
|
||||
_wired = false;
|
||||
_loadAdminWorkflows();
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('wfAddStageBtn').onclick = async function() {
|
||||
_editingStageId = null;
|
||||
document.getElementById('wfStageName').value = '';
|
||||
document.getElementById('wfStageHistory').value = 'full';
|
||||
document.getElementById('wfStagePersona').value = '';
|
||||
document.getElementById('wfStagePrompt').value = '';
|
||||
document.getElementById('wfStageMode').value = 'chat_only';
|
||||
document.getElementById('wfStageForm').value = '';
|
||||
document.getElementById('wfFormFields').innerHTML = '';
|
||||
_wfUpdateFormBuilderVisibility();
|
||||
await _wfLoadPersonaSelect('');
|
||||
await _wfLoadSurfacePkgSelect('');
|
||||
document.getElementById('wfStageEditor').style.display = '';
|
||||
};
|
||||
|
||||
document.getElementById('wfCancelStageBtn').onclick = function() {
|
||||
document.getElementById('wfStageEditor').style.display = 'none';
|
||||
_editingStageId = null;
|
||||
};
|
||||
|
||||
document.getElementById('wfSaveStageBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
var name = document.getElementById('wfStageName').value.trim();
|
||||
if (!name) { UI.toast('Stage name required', 'error'); return; }
|
||||
|
||||
var stageMode = document.getElementById('wfStageMode').value;
|
||||
var formTemplate = null;
|
||||
|
||||
if (stageMode === 'form_only' || stageMode === 'form_chat') {
|
||||
// Build from visual builder or JSON textarea
|
||||
var jsonToggle = document.getElementById('wfFormJsonToggle');
|
||||
if (jsonToggle && jsonToggle.checked) {
|
||||
var formRaw = document.getElementById('wfStageForm').value.trim();
|
||||
if (formRaw) {
|
||||
try { formTemplate = JSON.parse(formRaw); }
|
||||
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
|
||||
}
|
||||
} else {
|
||||
formTemplate = _wfBuildFormTemplateFromUI();
|
||||
}
|
||||
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
|
||||
UI.toast('Form modes require at least one form field', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// chat_only: still allow optional legacy form template
|
||||
var formRaw = document.getElementById('wfStageForm').value.trim();
|
||||
if (formRaw) {
|
||||
try { formTemplate = JSON.parse(formRaw); }
|
||||
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
|
||||
}
|
||||
}
|
||||
|
||||
var personaId = document.getElementById('wfStagePersona').value || null;
|
||||
var surfacePkgId = document.getElementById('wfStageSurfacePkg').value || null;
|
||||
|
||||
var data = {
|
||||
name: name,
|
||||
history_mode: document.getElementById('wfStageHistory').value,
|
||||
stage_mode: stageMode,
|
||||
persona_id: personaId,
|
||||
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
|
||||
form_template: formTemplate,
|
||||
surface_pkg_id: surfacePkgId,
|
||||
};
|
||||
|
||||
try {
|
||||
if (_editingStageId) {
|
||||
await API.updateStage(_currentWf.id, _editingStageId, data);
|
||||
} else {
|
||||
data.ordinal = document.querySelectorAll('.wf-stage-row').length;
|
||||
await API.createStage(_currentWf.id, data);
|
||||
}
|
||||
UI.toast('Stage saved', 'success');
|
||||
document.getElementById('wfStageEditor').style.display = 'none';
|
||||
_editingStageId = null;
|
||||
await _wfLoadStages(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Edit / Delete Stage ─────────────────
|
||||
|
||||
sb.register('_wfEditStage', async function(stageId) {
|
||||
if (!_currentWf) return;
|
||||
try {
|
||||
const resp = await API.listStages(_currentWf.id);
|
||||
const stages = resp.data || resp || [];
|
||||
const stage = stages.find(function(s) { return s.id === stageId; });
|
||||
if (!stage) return;
|
||||
|
||||
_editingStageId = stageId;
|
||||
document.getElementById('wfStageName').value = stage.name;
|
||||
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
|
||||
document.getElementById('wfStageMode').value = stage.stage_mode || 'chat_only';
|
||||
await _wfLoadPersonaSelect(stage.persona_id || '');
|
||||
document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
|
||||
|
||||
// Populate form builder from existing template
|
||||
var tpl = stage.form_template;
|
||||
document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
|
||||
_wfPopulateFormBuilder(tpl);
|
||||
_wfUpdateFormBuilderVisibility();
|
||||
|
||||
// Surface package selector
|
||||
await _wfLoadSurfacePkgSelect(stage.surface_pkg_id || '');
|
||||
|
||||
document.getElementById('wfStageEditor').style.display = '';
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Import .pkg ──────────────────────────
|
||||
|
||||
sb.register('_wfImportPkg', function() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pkg,.zip';
|
||||
input.onchange = async function() {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var formData = new FormData();
|
||||
formData.append('file', input.files[0]);
|
||||
try {
|
||||
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages/install', {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders ? { 'Authorization': API._authHeaders()['Authorization'] } : {},
|
||||
body: formData,
|
||||
});
|
||||
var result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
UI.toast('Import failed: ' + (result.error || 'unknown'), 'error');
|
||||
return;
|
||||
}
|
||||
UI.toast('Workflow imported: ' + (result.title || result.id), 'success');
|
||||
_loadAdminWorkflows();
|
||||
} catch (e) {
|
||||
UI.toast('Import error: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
|
||||
sb.register('_wfDeleteStage', async function(stageId) {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete this stage?')) return;
|
||||
try {
|
||||
await API.deleteStage(_currentWf.id, stageId);
|
||||
UI.toast('Stage deleted', 'success');
|
||||
await _wfLoadStages(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Form Builder Helpers ────────────────
|
||||
|
||||
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
|
||||
|
||||
// Show/hide the form builder based on stage_mode
|
||||
function _wfUpdateFormBuilderVisibility() {
|
||||
var mode = document.getElementById('wfStageMode').value;
|
||||
var wrap = document.getElementById('wfFormBuilderWrap');
|
||||
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
|
||||
}
|
||||
|
||||
// Build a single field row element
|
||||
function _wfCreateFieldRow(field) {
|
||||
field = field || { key: '', type: 'text', label: '', required: false };
|
||||
var row = document.createElement('div');
|
||||
row.className = 'wf-field-row';
|
||||
row.draggable = true;
|
||||
row.innerHTML =
|
||||
'<span class="wf-stage-grip" style="cursor:grab">⠿</span>' +
|
||||
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
|
||||
'<select class="wf-fb-type" style="width:90px">' +
|
||||
FIELD_TYPES.map(function(t) {
|
||||
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
|
||||
}).join('') +
|
||||
'</select>' +
|
||||
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
|
||||
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
|
||||
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
|
||||
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
|
||||
|
||||
// Delete handler
|
||||
row.querySelector('.wf-fb-del').onclick = function() {
|
||||
row.remove();
|
||||
_wfSyncFormJSON();
|
||||
};
|
||||
|
||||
// Type change: show options editor for select
|
||||
row.querySelector('.wf-fb-type').onchange = function() {
|
||||
var optWrap = row.querySelector('.wf-fb-options');
|
||||
if (this.value === 'select') {
|
||||
if (!optWrap) {
|
||||
optWrap = document.createElement('div');
|
||||
optWrap.className = 'wf-fb-options';
|
||||
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
|
||||
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||
row.appendChild(optWrap);
|
||||
}
|
||||
optWrap.style.display = '';
|
||||
} else if (optWrap) {
|
||||
optWrap.style.display = 'none';
|
||||
}
|
||||
_wfSyncFormJSON();
|
||||
};
|
||||
|
||||
// If select type, add options input
|
||||
if (field.type === 'select') {
|
||||
var optWrap = document.createElement('div');
|
||||
optWrap.className = 'wf-fb-options';
|
||||
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
|
||||
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||
row.appendChild(optWrap);
|
||||
}
|
||||
|
||||
// Sync JSON on any input change
|
||||
row.querySelectorAll('input, select').forEach(function(el) {
|
||||
el.addEventListener('change', _wfSyncFormJSON);
|
||||
el.addEventListener('input', _wfSyncFormJSON);
|
||||
});
|
||||
|
||||
// DnD within field list
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
_wfDragFieldSrc = row;
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
document.querySelectorAll('.wf-field-row').forEach(function(r) { r.classList.remove('drag-over'); });
|
||||
_wfDragFieldSrc = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (row !== _wfDragFieldSrc) row.classList.add('drag-over');
|
||||
});
|
||||
row.addEventListener('dragleave', function() { row.classList.remove('drag-over'); });
|
||||
row.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
row.classList.remove('drag-over');
|
||||
if (!_wfDragFieldSrc || _wfDragFieldSrc === row) return;
|
||||
var container = document.getElementById('wfFormFields');
|
||||
var rows = Array.from(container.querySelectorAll('.wf-field-row'));
|
||||
var from = rows.indexOf(_wfDragFieldSrc);
|
||||
var to = rows.indexOf(row);
|
||||
if (from < to) {
|
||||
row.parentNode.insertBefore(_wfDragFieldSrc, row.nextSibling);
|
||||
} else {
|
||||
row.parentNode.insertBefore(_wfDragFieldSrc, row);
|
||||
}
|
||||
_wfSyncFormJSON();
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
var _wfDragFieldSrc = null;
|
||||
|
||||
// Populate the form builder from an existing template object
|
||||
function _wfPopulateFormBuilder(tpl) {
|
||||
var container = document.getElementById('wfFormFields');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
|
||||
|
||||
tpl.fields.forEach(function(f) {
|
||||
// Support both typed objects and legacy string-only fields
|
||||
if (typeof f === 'string') {
|
||||
f = { key: f, type: 'text', label: f, required: false };
|
||||
}
|
||||
container.appendChild(_wfCreateFieldRow(f));
|
||||
});
|
||||
}
|
||||
|
||||
// Build a typed form_template from the visual builder
|
||||
function _wfBuildFormTemplateFromUI() {
|
||||
var container = document.getElementById('wfFormFields');
|
||||
if (!container) return null;
|
||||
var rows = container.querySelectorAll('.wf-field-row');
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
var fields = [];
|
||||
rows.forEach(function(row) {
|
||||
var key = row.querySelector('.wf-fb-key').value.trim();
|
||||
var type = row.querySelector('.wf-fb-type').value;
|
||||
var label = row.querySelector('.wf-fb-label').value.trim();
|
||||
var required = row.querySelector('.wf-fb-required').checked;
|
||||
if (!key) return; // skip empty key fields
|
||||
|
||||
var field = { key: key, type: type, label: label || key, required: required };
|
||||
|
||||
// Parse select options
|
||||
if (type === 'select') {
|
||||
var optsInput = row.querySelector('.wf-fb-opts-input');
|
||||
if (optsInput && optsInput.value.trim()) {
|
||||
field.options = optsInput.value.split(',').map(function(s) {
|
||||
s = s.trim();
|
||||
return { value: s, label: s };
|
||||
}).filter(function(o) { return o.value; });
|
||||
}
|
||||
}
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
return fields.length > 0 ? { fields: fields } : null;
|
||||
}
|
||||
|
||||
// Sync visual builder → JSON textarea (one-way, when builder is active)
|
||||
function _wfSyncFormJSON() {
|
||||
var jsonToggle = document.getElementById('wfFormJsonToggle');
|
||||
if (!jsonToggle) return;
|
||||
var tpl = _wfBuildFormTemplateFromUI();
|
||||
document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
|
||||
}
|
||||
|
||||
// Wire form builder controls (called once after scaffold injection)
|
||||
function _wfWireFormBuilder() {
|
||||
var modeSelect = document.getElementById('wfStageMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.onchange = _wfUpdateFormBuilderVisibility;
|
||||
}
|
||||
|
||||
var addBtn = document.getElementById('wfAddFieldBtn');
|
||||
if (addBtn) {
|
||||
addBtn.onclick = function() {
|
||||
var container = document.getElementById('wfFormFields');
|
||||
if (container) {
|
||||
container.appendChild(_wfCreateFieldRow());
|
||||
_wfSyncFormJSON();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var jsonToggle = document.getElementById('wfFormJsonToggle');
|
||||
if (jsonToggle) {
|
||||
jsonToggle.onchange = function() {
|
||||
var textarea = document.getElementById('wfStageForm');
|
||||
if (this.checked) {
|
||||
// Sync builder → JSON before showing
|
||||
_wfSyncFormJSON();
|
||||
textarea.style.display = '';
|
||||
} else {
|
||||
// Sync JSON → builder
|
||||
var raw = textarea.value.trim();
|
||||
if (raw) {
|
||||
try {
|
||||
var tpl = JSON.parse(raw);
|
||||
_wfPopulateFormBuilder(tpl);
|
||||
} catch (e) {
|
||||
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
|
||||
this.checked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
textarea.style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Team Workflow Builder (v0.31.2)
|
||||
// ==========================================
|
||||
// Team admin panel for managing team-scoped workflow definitions,
|
||||
// stages, and publishing. Adapted from workflow-admin.js using
|
||||
// team-scoped API endpoints. Loaded as a team admin tab.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var esc = typeof window.esc === 'function' ? window.esc : function (s) {
|
||||
var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
|
||||
};
|
||||
|
||||
// ── Scaffold HTML ────────────────────────
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div id="twfList" class="admin-list"></div>' +
|
||||
'<div id="twfDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
|
||||
'<button class="btn-small" id="twfBackBtn">← Back</button>' +
|
||||
'<h4 id="twfDetailName" style="margin:0;flex:1"></h4>' +
|
||||
'<span id="twfDetailVersion" class="badge"></span>' +
|
||||
'<span id="twfDetailStatus" class="badge"></span>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="settings-section" style="margin-bottom:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="twfEditName" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Slug</label>' +
|
||||
'<input type="text" id="twfEditSlug" style="width:100%" readonly></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Description</label>' +
|
||||
'<textarea id="twfEditDesc" rows="2" style="width:100%"></textarea></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
|
||||
'<select id="twfEditEntry" style="width:100%">' +
|
||||
'<option value="team_only">Team Only</option>' +
|
||||
'<option value="public_link">Public Link</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="twfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
|
||||
'<button class="btn-small btn-primary" id="twfSaveBtn">Save Changes</button>' +
|
||||
'<button class="btn-small" id="twfPublishBtn">Publish</button>' +
|
||||
'<button class="btn-small btn-danger" id="twfDeleteBtn" style="margin-left:auto">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<h5 style="margin:0 0 8px">Stages</h5>' +
|
||||
'<div id="twfStageList" class="admin-list"></div>' +
|
||||
'<button class="btn-small" id="twfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
|
||||
|
||||
'<div id="twfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
|
||||
'<input type="text" id="twfStageName" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
|
||||
'<select id="twfStageHistory" style="width:100%">' +
|
||||
'<option value="full">Full</option>' +
|
||||
'<option value="summary">Summary</option>' +
|
||||
'<option value="fresh">Fresh</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Persona</label>' +
|
||||
'<select id="twfStagePersona" style="width:100%">' +
|
||||
'<option value="">None (no AI for this stage)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
|
||||
'<select id="twfStageMode" style="width:100%">' +
|
||||
'<option value="chat_only">Chat Only</option>' +
|
||||
'<option value="form_only">Form Only (no AI)</option>' +
|
||||
'<option value="form_chat">Form + Chat</option>' +
|
||||
'<option value="review">Review (Human)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div id="twfFormBuilderWrap" style="display:none;margin-top:8px">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
|
||||
'<label style="margin:0;font-weight:600">Form Fields</label>' +
|
||||
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="twfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
|
||||
'</div>' +
|
||||
'<div id="twfFormFields"></div>' +
|
||||
'<button class="btn-small" id="twfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
|
||||
'<textarea id="twfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px;gap:8px">' +
|
||||
'<button class="btn-small btn-primary" id="twfSaveStageBtn">Save Stage</button>' +
|
||||
'<button class="btn-small" id="twfCancelStageBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
|
||||
'<div id="twfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
|
||||
'</div>';
|
||||
|
||||
// ── State ───────────────────────────────
|
||||
|
||||
var _teamId = null;
|
||||
var _teamSlug = null;
|
||||
var _currentWf = null;
|
||||
var _editingStageId = null;
|
||||
var _personaCache = [];
|
||||
|
||||
function _getTeamId() {
|
||||
return _teamId || (UI && UI._managingTeamId) || null;
|
||||
}
|
||||
|
||||
// Load team personas into the stage editor dropdown.
|
||||
async function _twfLoadPersonaSelect(selectedId) {
|
||||
var sel = document.getElementById('twfStagePersona');
|
||||
if (!sel) return;
|
||||
|
||||
var teamId = _getTeamId();
|
||||
if (_personaCache.length === 0 && teamId) {
|
||||
try {
|
||||
var resp = await API._get('/api/v1/teams/' + teamId + '/personas');
|
||||
_personaCache = (resp.data || resp || []).map(function(p) {
|
||||
return { id: p.id, name: p.name, icon: p.icon || '' };
|
||||
});
|
||||
} catch (e) {
|
||||
// Fall back to user-visible personas
|
||||
try {
|
||||
var resp2 = await API.listPersonas();
|
||||
_personaCache = (resp2.data || resp2 || []).map(function(p) {
|
||||
return { id: p.id, name: p.name, icon: p.icon || '' };
|
||||
});
|
||||
} catch (e2) { /* no personas available */ }
|
||||
}
|
||||
}
|
||||
|
||||
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
|
||||
_personaCache.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
if (selectedId) sel.value = selectedId;
|
||||
}
|
||||
|
||||
// ── Loader (called from switchTeamTab) ──
|
||||
|
||||
UI.loadTeamWorkflows = async function(teamId) {
|
||||
_teamId = teamId;
|
||||
_teamSlug = null;
|
||||
_currentWf = null;
|
||||
_editingStageId = null;
|
||||
_personaCache = [];
|
||||
_twfWired = false;
|
||||
|
||||
// Fetch team slug for clean public URLs
|
||||
try {
|
||||
var teamResp = await API._get('/api/v1/admin/teams/' + teamId);
|
||||
_teamSlug = teamResp.slug || null;
|
||||
} catch (e) { /* fallback to team ID in URLs */ }
|
||||
|
||||
var target = document.getElementById('teamTabWorkflows');
|
||||
if (!target) return;
|
||||
|
||||
// Self-scaffold
|
||||
if (!document.getElementById('twfList')) {
|
||||
target.innerHTML = SCAFFOLD_HTML;
|
||||
_twfWireFormBuilder();
|
||||
}
|
||||
|
||||
var list = document.getElementById('twfList');
|
||||
var detail = document.getElementById('twfDetail');
|
||||
if (!list) return;
|
||||
|
||||
detail.style.display = 'none';
|
||||
list.style.display = '';
|
||||
|
||||
try {
|
||||
var resp = await API.listTeamWorkflows(teamId);
|
||||
var wfs = resp.data || resp || [];
|
||||
|
||||
if (wfs.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="empty-hint">No workflows for this team</div>' +
|
||||
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
|
||||
'data-action="_twfCreate">+ Create Workflow</button>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
||||
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
|
||||
'<button class="btn-small btn-primary" data-action="_twfCreate">+ New</button>' +
|
||||
'</div>';
|
||||
html += '<table class="admin-table"><thead><tr>' +
|
||||
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
for (var i = 0; i < wfs.length; i++) {
|
||||
var wf = wfs[i];
|
||||
html += '<tr style="cursor:pointer" data-action="_twfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
|
||||
'<td>' + esc(wf.name) + '</td>' +
|
||||
'<td><code>' + esc(wf.slug) + '</code></td>' +
|
||||
'<td>' + esc(wf.entry_mode || 'team_only') + '</td>' +
|
||||
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
|
||||
'<td>v' + (wf.version || 1) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
list.innerHTML = html;
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Create ──────────────────────────────
|
||||
|
||||
sb.register('_twfCreate', async function() {
|
||||
var teamId = _getTeamId();
|
||||
if (!teamId) return;
|
||||
var name = prompt('Workflow name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
var wf = await API.createTeamWorkflow(teamId, { name: name });
|
||||
UI.toast('Workflow created', 'success');
|
||||
_twfOpen(wf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Open Detail ─────────────────────────
|
||||
|
||||
async function _twfOpen(id) {
|
||||
var teamId = _getTeamId();
|
||||
var list = document.getElementById('twfList');
|
||||
var detail = document.getElementById('twfDetail');
|
||||
try {
|
||||
var wf = await API.getTeamWorkflow(teamId, id);
|
||||
_currentWf = wf;
|
||||
list.style.display = 'none';
|
||||
detail.style.display = '';
|
||||
|
||||
document.getElementById('twfDetailName').textContent = wf.name;
|
||||
document.getElementById('twfDetailVersion').textContent = 'v' + (wf.version || 1);
|
||||
document.getElementById('twfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
|
||||
document.getElementById('twfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
|
||||
|
||||
document.getElementById('twfEditName').value = wf.name;
|
||||
document.getElementById('twfEditSlug').value = wf.slug;
|
||||
document.getElementById('twfEditDesc').value = wf.description || '';
|
||||
document.getElementById('twfEditEntry').value = wf.entry_mode || 'team_only';
|
||||
document.getElementById('twfEditActive').checked = wf.is_active;
|
||||
|
||||
// Public URL (v0.31.2: use team slug for clean URLs)
|
||||
var scope = _teamSlug || wf.team_id || teamId;
|
||||
var base = location.origin + (window.__BASE__ || '');
|
||||
document.getElementById('twfPublicUrl').textContent =
|
||||
wf.entry_mode === 'public_link'
|
||||
? base + '/w/' + scope + '/' + wf.slug
|
||||
: 'Enable "Public Link" entry mode to generate a URL';
|
||||
|
||||
// Preload persona cache
|
||||
await _twfLoadPersonaSelect('');
|
||||
|
||||
// Load stages
|
||||
await _twfLoadStages(id);
|
||||
_twfWireEvents();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load workflow: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
sb.register('_twfOpen', function(id) { return _twfOpen(id); });
|
||||
|
||||
// ── Stage List ──────────────────────────
|
||||
|
||||
async function _twfLoadStages(wfId) {
|
||||
var teamId = _getTeamId();
|
||||
var el = document.getElementById('twfStageList');
|
||||
if (!el) return;
|
||||
try {
|
||||
var resp = await API.listTeamWorkflowStages(teamId, wfId);
|
||||
var stages = resp.data || resp || [];
|
||||
if (stages.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
stages.forEach(function(s, i) {
|
||||
var personaLabel = '';
|
||||
if (s.persona_id) {
|
||||
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
|
||||
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
||||
'<span class="badge">persona</span>';
|
||||
}
|
||||
var modeLabel = '';
|
||||
if (s.stage_mode === 'review') {
|
||||
modeLabel = '<span class="badge badge-warn">review</span>';
|
||||
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
|
||||
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
|
||||
}
|
||||
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
||||
'<span class="wf-stage-grip">⠿</span>' +
|
||||
'<span class="wf-stage-ord">' + i + '</span>' +
|
||||
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
|
||||
personaLabel + modeLabel +
|
||||
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
|
||||
'<button class="btn-small" data-action="_twfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
|
||||
'<button class="btn-small btn-danger" data-action="_twfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
_twfWireStageDnD(el, wfId);
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage DnD ───────────────────────────
|
||||
|
||||
function _twfWireStageDnD(container, wfId) {
|
||||
var dragSrc = null;
|
||||
var teamId = _getTeamId();
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
dragSrc = row;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
|
||||
r.classList.remove('drag-over');
|
||||
});
|
||||
dragSrc = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (row !== dragSrc) row.classList.add('drag-over');
|
||||
});
|
||||
row.addEventListener('dragleave', function() {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
row.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
row.classList.remove('drag-over');
|
||||
if (!dragSrc || dragSrc === row) return;
|
||||
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
|
||||
var fromIdx = rows.indexOf(dragSrc);
|
||||
var toIdx = rows.indexOf(row);
|
||||
if (fromIdx < toIdx) {
|
||||
row.parentNode.insertBefore(dragSrc, row.nextSibling);
|
||||
} else {
|
||||
row.parentNode.insertBefore(dragSrc, row);
|
||||
}
|
||||
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
|
||||
return r.dataset.id;
|
||||
});
|
||||
API.reorderTeamWorkflowStages(teamId, wfId, orderedIDs).then(function() {
|
||||
UI.toast('Stages reordered', 'success');
|
||||
_twfLoadStages(wfId);
|
||||
}).catch(function(err) {
|
||||
UI.toast('Reorder failed: ' + (err.message || err), 'error');
|
||||
_twfLoadStages(wfId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Wire Events ─────────────────────────
|
||||
|
||||
var _twfWired = false;
|
||||
function _twfWireEvents() {
|
||||
if (_twfWired) return;
|
||||
_twfWired = true;
|
||||
|
||||
document.getElementById('twfBackBtn').onclick = function() {
|
||||
_currentWf = null;
|
||||
_editingStageId = null;
|
||||
_personaCache = [];
|
||||
_twfWired = false;
|
||||
UI.loadTeamWorkflows(_getTeamId());
|
||||
};
|
||||
|
||||
document.getElementById('twfSaveBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
var teamId = _getTeamId();
|
||||
try {
|
||||
await API.updateTeamWorkflow(teamId, _currentWf.id, {
|
||||
name: document.getElementById('twfEditName').value.trim(),
|
||||
description: document.getElementById('twfEditDesc').value.trim(),
|
||||
entry_mode: document.getElementById('twfEditEntry').value,
|
||||
is_active: document.getElementById('twfEditActive').checked,
|
||||
});
|
||||
UI.toast('Saved', 'success');
|
||||
_twfOpen(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('twfPublishBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
var teamId = _getTeamId();
|
||||
try {
|
||||
var ver = await API.publishTeamWorkflow(teamId, _currentWf.id);
|
||||
UI.toast('Published v' + ver.version_number, 'success');
|
||||
_twfOpen(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('twfDeleteBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
|
||||
var teamId = _getTeamId();
|
||||
try {
|
||||
await API.deleteTeamWorkflow(teamId, _currentWf.id);
|
||||
UI.toast('Deleted', 'success');
|
||||
_currentWf = null;
|
||||
_personaCache = [];
|
||||
_twfWired = false;
|
||||
UI.loadTeamWorkflows(teamId);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('twfAddStageBtn').onclick = async function() {
|
||||
_editingStageId = null;
|
||||
document.getElementById('twfStageName').value = '';
|
||||
document.getElementById('twfStageHistory').value = 'full';
|
||||
document.getElementById('twfStagePersona').value = '';
|
||||
document.getElementById('twfStageMode').value = 'chat_only';
|
||||
document.getElementById('twfStageForm').value = '';
|
||||
document.getElementById('twfFormFields').innerHTML = '';
|
||||
_twfUpdateFormBuilderVisibility();
|
||||
await _twfLoadPersonaSelect('');
|
||||
document.getElementById('twfStageEditor').style.display = '';
|
||||
};
|
||||
|
||||
document.getElementById('twfCancelStageBtn').onclick = function() {
|
||||
document.getElementById('twfStageEditor').style.display = 'none';
|
||||
_editingStageId = null;
|
||||
};
|
||||
|
||||
document.getElementById('twfSaveStageBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
var teamId = _getTeamId();
|
||||
var name = document.getElementById('twfStageName').value.trim();
|
||||
if (!name) { UI.toast('Stage name required', 'error'); return; }
|
||||
|
||||
var stageMode = document.getElementById('twfStageMode').value;
|
||||
var formTemplate = null;
|
||||
|
||||
if (stageMode === 'form_only' || stageMode === 'form_chat') {
|
||||
var jsonToggle = document.getElementById('twfFormJsonToggle');
|
||||
if (jsonToggle && jsonToggle.checked) {
|
||||
var formRaw = document.getElementById('twfStageForm').value.trim();
|
||||
if (formRaw) {
|
||||
try { formTemplate = JSON.parse(formRaw); }
|
||||
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
|
||||
}
|
||||
} else {
|
||||
formTemplate = _twfBuildFormTemplateFromUI();
|
||||
}
|
||||
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
|
||||
UI.toast('Form modes require at least one form field', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
var formRaw = document.getElementById('twfStageForm').value.trim();
|
||||
if (formRaw) {
|
||||
try { formTemplate = JSON.parse(formRaw); }
|
||||
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
|
||||
}
|
||||
}
|
||||
|
||||
var personaId = document.getElementById('twfStagePersona').value || null;
|
||||
|
||||
var data = {
|
||||
name: name,
|
||||
history_mode: document.getElementById('twfStageHistory').value,
|
||||
stage_mode: stageMode,
|
||||
persona_id: personaId,
|
||||
form_template: formTemplate,
|
||||
};
|
||||
|
||||
try {
|
||||
if (_editingStageId) {
|
||||
await API.updateTeamWorkflowStage(teamId, _currentWf.id, _editingStageId, data);
|
||||
} else {
|
||||
data.ordinal = document.querySelectorAll('#twfStageList .wf-stage-row').length;
|
||||
await API.createTeamWorkflowStage(teamId, _currentWf.id, data);
|
||||
}
|
||||
UI.toast('Stage saved', 'success');
|
||||
document.getElementById('twfStageEditor').style.display = 'none';
|
||||
_editingStageId = null;
|
||||
await _twfLoadStages(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Edit / Delete Stage ─────────────────
|
||||
|
||||
sb.register('_twfEditStage', async function(stageId) {
|
||||
if (!_currentWf) return;
|
||||
var teamId = _getTeamId();
|
||||
try {
|
||||
var resp = await API.listTeamWorkflowStages(teamId, _currentWf.id);
|
||||
var stages = resp.data || resp || [];
|
||||
var stage = stages.find(function(s) { return s.id === stageId; });
|
||||
if (!stage) return;
|
||||
|
||||
_editingStageId = stageId;
|
||||
document.getElementById('twfStageName').value = stage.name;
|
||||
document.getElementById('twfStageHistory').value = stage.history_mode || 'full';
|
||||
document.getElementById('twfStageMode').value = stage.stage_mode || 'chat_only';
|
||||
await _twfLoadPersonaSelect(stage.persona_id || '');
|
||||
|
||||
var tpl = stage.form_template;
|
||||
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
|
||||
_twfPopulateFormBuilder(tpl);
|
||||
_twfUpdateFormBuilderVisibility();
|
||||
|
||||
document.getElementById('twfStageEditor').style.display = '';
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
sb.register('_twfDeleteStage', async function(stageId) {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete this stage?')) return;
|
||||
var teamId = _getTeamId();
|
||||
try {
|
||||
await API.deleteTeamWorkflowStage(teamId, _currentWf.id, stageId);
|
||||
UI.toast('Stage deleted', 'success');
|
||||
await _twfLoadStages(_currentWf.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Form Builder Helpers ────────────────
|
||||
|
||||
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
|
||||
|
||||
function _twfUpdateFormBuilderVisibility() {
|
||||
var mode = document.getElementById('twfStageMode').value;
|
||||
var wrap = document.getElementById('twfFormBuilderWrap');
|
||||
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
|
||||
}
|
||||
|
||||
function _twfCreateFieldRow(field) {
|
||||
field = field || { key: '', type: 'text', label: '', required: false };
|
||||
var row = document.createElement('div');
|
||||
row.className = 'wf-field-row';
|
||||
row.innerHTML =
|
||||
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
|
||||
'<select class="wf-fb-type" style="width:90px">' +
|
||||
FIELD_TYPES.map(function(t) {
|
||||
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
|
||||
}).join('') +
|
||||
'</select>' +
|
||||
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
|
||||
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
|
||||
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
|
||||
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
|
||||
|
||||
row.querySelector('.wf-fb-del').onclick = function() {
|
||||
row.remove();
|
||||
_twfSyncFormJSON();
|
||||
};
|
||||
|
||||
row.querySelector('.wf-fb-type').onchange = function() {
|
||||
var optWrap = row.querySelector('.wf-fb-options');
|
||||
if (this.value === 'select') {
|
||||
if (!optWrap) {
|
||||
optWrap = document.createElement('div');
|
||||
optWrap.className = 'wf-fb-options';
|
||||
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
|
||||
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||
row.appendChild(optWrap);
|
||||
}
|
||||
optWrap.style.display = '';
|
||||
} else if (optWrap) {
|
||||
optWrap.style.display = 'none';
|
||||
}
|
||||
_twfSyncFormJSON();
|
||||
};
|
||||
|
||||
if (field.type === 'select') {
|
||||
var optWrap = document.createElement('div');
|
||||
optWrap.className = 'wf-fb-options';
|
||||
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
|
||||
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
|
||||
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
|
||||
row.appendChild(optWrap);
|
||||
}
|
||||
|
||||
row.querySelectorAll('input, select').forEach(function(el) {
|
||||
el.addEventListener('change', _twfSyncFormJSON);
|
||||
el.addEventListener('input', _twfSyncFormJSON);
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function _twfPopulateFormBuilder(tpl) {
|
||||
var container = document.getElementById('twfFormFields');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
|
||||
tpl.fields.forEach(function(f) {
|
||||
if (typeof f === 'string') {
|
||||
f = { key: f, type: 'text', label: f, required: false };
|
||||
}
|
||||
container.appendChild(_twfCreateFieldRow(f));
|
||||
});
|
||||
}
|
||||
|
||||
function _twfBuildFormTemplateFromUI() {
|
||||
var container = document.getElementById('twfFormFields');
|
||||
if (!container) return null;
|
||||
var rows = container.querySelectorAll('.wf-field-row');
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
var fields = [];
|
||||
rows.forEach(function(row) {
|
||||
var key = row.querySelector('.wf-fb-key').value.trim();
|
||||
var type = row.querySelector('.wf-fb-type').value;
|
||||
var label = row.querySelector('.wf-fb-label').value.trim();
|
||||
var required = row.querySelector('.wf-fb-required').checked;
|
||||
if (!key) return;
|
||||
|
||||
var field = { key: key, type: type, label: label || key, required: required };
|
||||
|
||||
if (type === 'select') {
|
||||
var optsInput = row.querySelector('.wf-fb-opts-input');
|
||||
if (optsInput && optsInput.value.trim()) {
|
||||
field.options = optsInput.value.split(',').map(function(s) {
|
||||
s = s.trim();
|
||||
return { value: s, label: s };
|
||||
}).filter(function(o) { return o.value; });
|
||||
}
|
||||
}
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
return fields.length > 0 ? { fields: fields } : null;
|
||||
}
|
||||
|
||||
function _twfSyncFormJSON() {
|
||||
var jsonToggle = document.getElementById('twfFormJsonToggle');
|
||||
if (!jsonToggle) return;
|
||||
var tpl = _twfBuildFormTemplateFromUI();
|
||||
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
|
||||
}
|
||||
|
||||
function _twfWireFormBuilder() {
|
||||
var modeSelect = document.getElementById('twfStageMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.onchange = _twfUpdateFormBuilderVisibility;
|
||||
}
|
||||
|
||||
var addBtn = document.getElementById('twfAddFieldBtn');
|
||||
if (addBtn) {
|
||||
addBtn.onclick = function() {
|
||||
var container = document.getElementById('twfFormFields');
|
||||
if (container) {
|
||||
container.appendChild(_twfCreateFieldRow());
|
||||
_twfSyncFormJSON();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var jsonToggle = document.getElementById('twfFormJsonToggle');
|
||||
if (jsonToggle) {
|
||||
jsonToggle.onchange = function() {
|
||||
var textarea = document.getElementById('twfStageForm');
|
||||
if (this.checked) {
|
||||
_twfSyncFormJSON();
|
||||
textarea.style.display = '';
|
||||
} else {
|
||||
var raw = textarea.value.trim();
|
||||
if (raw) {
|
||||
try {
|
||||
var tpl = JSON.parse(raw);
|
||||
_twfPopulateFormBuilder(tpl);
|
||||
} catch (e) {
|
||||
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
|
||||
this.checked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
textarea.style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user