diff --git a/CHANGELOG.md b/CHANGELOG.md index 5758172..fc0b20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,111 @@ # Changelog +## [0.37.7] — 2026-03-21 + +### Summary + +Scorched earth: all legacy admin JS removed. Team Admin becomes a +standalone surface. Settings bridge sections replaced with native Preact +components. Chat surface stripped of all admin/settings script +dependencies. 16 legacy JS files deleted (~5,400+ lines). + +### New + +- **Team Admin surface** (`src/js/sw/surfaces/team-admin/`) — standalone + at `/team-admin`, 10 native Preact sections: + - `TeamAdminSurface` — root with team picker (multi-team admins), + tab sidebar nav, lazy `import()` per section + - `MembersSection` — list, add, change role, remove via + `sw.api.teams.members/addMember/updateMember/removeMember` + - `ProvidersSection` — CRUD provider configs, card layout, model sync + - `PersonasSection` — CRUD personas with KB bindings + - `KnowledgeSection` — read-only team KB list + - `GroupsSection` — read-only team groups list + - `WorkflowsSection` — full CRUD + publish, stage viewer + - `TasksSection` — CRUD + run/kill, schedule display + - `SettingsSection` — team name/description form + - `UsageSection` — stat cards + usage-by-day table + - `ActivitySection` — paginated audit log with filters +- **7 native settings sections** (replace all bridge sections): + - `WorkflowsSection` — user's visible workflows (read-only) + - `TasksSection` — user tasks with run/stop actions + - `GitKeysSection` — SSH credential management + - `DataSection` — data export + account deletion (danger zone) + - `KnowledgeSection` — personal KB CRUD with document upload + - `MemorySection` — memory list with inline edit, status badges + - `NotificationsSection` — toggle-based notification preferences +- **SDK additions** (`api-domains.js`): + - `teams.workflows/createWorkflow/getWorkflow/updateWorkflow/ + deleteWorkflow/publishWorkflow` — team-scoped workflow CRUD + - `teams.tasks/createTask/updateTask/deleteTask/runTask/killTask` — + team-scoped task CRUD + - `teams.knowledgeBases` — team KB list + - `teams.update` — team settings update + - `teams.updatePersona` — team persona update + - `git.credentials.list/create/del` — git SSH credentials + - `dataPortability.exportMe/deleteAccount` — data portability +- **Go template** — `server/pages/templates/surfaces/team-admin.html` + (Preact mount + vendor boot, no legacy modals) +- **Surface registration** — `team-admin` surface registered in + `pages.go`, `loaders.go`, `base.html` template dispatch, `nginx.conf` + proxy rule + +### Changed + +- `server/version.go` — `0.37.6` → `0.37.7` +- `VERSION` — `0.37.6` → `0.37.7` +- `settings/index.js` — removed `BridgeSection` import, emptied + `bridgeSections` object, added 7 native section modules to + `sectionModules`, removed bridge rendering ternary, added Knowledge + Bases / Memory / Notifications / Data & Privacy to `NAV_ITEMS` +- `chat.js` — menu handlers navigate to `/admin/users`, + `/team-admin/members`, `/settings/general` instead of opening modals. + Removed `UI.initAppearance()` call (was in deleted settings-handlers). +- `app.js` — removed `_initSettingsListeners()` and + `_initAdminListeners()` calls +- `chat.html` — stripped 5 legacy admin script tags (`ui-settings.js`, + `ui-admin.js`, `settings-handlers.js`, `admin-handlers.js`, + `workflow-admin.js`) + `memory-ui.js`, `persona-kb.js`, + `notification-prefs.js`. Removed team admin modal HTML (~110 lines). +- `admin.html` — removed 7 legacy modal overlays (~130 lines of HTML): + createUserModal, resetPwModal, approveUserModal, createTeamModal, + createGroupModal, providerFormModal, personaFormModal +- `settings.html` — removed 9 bridge loader script tags, updated + docstring to reflect full Preact (no bridges) +- `base.html` — added `team-admin` surface template + scripts dispatch +- `nginx.conf` — added `/team-admin` proxy rule to Go backend + +### Deleted + +- `ui-admin.js` (1,690 lines) — old admin panel, team admin modal, + ADMIN_LOADERS +- `ui-settings.js` (867 lines) — team management functions +- `admin-handlers.js` (~900 lines) — admin event listeners +- `settings-handlers.js` (908 lines) — settings save/load, bridge + loaders +- `admin-scaffold.js` (367 lines) — old admin DOM scaffolding +- `admin-packages.js` — old package management UI +- `broadcast-admin.js` — old broadcast admin UI +- `task-admin.js` — old task admin UI +- `task-settings.js` — old task settings UI +- `workflow-admin.js` — old workflow admin builder +- `workflow-team-admin.js` — old team workflow builder +- `memory-ui.js` — old memory management UI +- `notification-prefs.js` — old notification preferences UI +- `persona-kb.js` — old persona KB binding UI +- `data-portability.js` — old data export/import UI +- `git-credentials-ui.js` — old git credentials UI +- `bridge-section.js` — settings bridge wrapper (no longer needed) + +### Retained (chat surface still uses) + +- `knowledge-ui.js` — `KnowledgeUI.onChatChanged()` called by chat.js + for channel KB popup. Dies in v0.37.10 (chat surface rebuild). +- `channel-models.js` — `ChannelModels.*` used by chat.js for @mention + autocomplete. Dies in v0.37.10. +- `notifications.js` — `Notifications.init()` called by app.js for + notification badge/list. Dies in v0.37.10. + ## [0.37.5] — 2026-03-21 ### Summary diff --git a/VERSION b/VERSION index c52d370..bb5d745 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.6 \ No newline at end of file +0.37.7 \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index cab7345..47465a4 100644 --- a/nginx.conf +++ b/nginx.conf @@ -78,6 +78,7 @@ server { } location /admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } diff --git a/server/pages/loaders.go b/server/pages/loaders.go index ee8cb9b..3173924 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -119,11 +119,17 @@ type SettingsPageData struct { // ── Loader registration ────────────────────── +// TeamAdminPageData is what the team-admin surface receives. +type TeamAdminPageData struct { + Section string `json:"section"` +} + func (e *Engine) registerLoaders() { e.RegisterLoader("admin", e.adminLoader) e.RegisterLoader("chat", e.chatLoader) -e.RegisterLoader("notes", e.notesLoader) + e.RegisterLoader("notes", e.notesLoader) e.RegisterLoader("settings", e.settingsLoader) + e.RegisterLoader("team-admin", e.teamAdminLoader) } // ListDataProviders returns the keys of all registered data providers. @@ -396,6 +402,14 @@ func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) { // v0.22.7: Reads feature gates from GlobalConfig to control // which nav links/tabs are visible (BYOK, User Personas). +func (e *Engine) teamAdminLoader(c *gin.Context, s store.Stores) (any, error) { + section := c.Param("section") + if section == "" { + section = "members" + } + return &TeamAdminPageData{Section: section}, nil +} + func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { section := c.Param("section") if section == "" { diff --git a/server/pages/pages.go b/server/pages/pages.go index 645ff97..6e1bc51 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -217,6 +217,12 @@ func (e *Engine) registerCoreSurfaces() { DataRequires: []string{"settings"}, Layout: "single", Source: "core", }, + { + ID: "team-admin", Route: "/team-admin/:section", AltRoutes: []string{"/team-admin"}, + Title: "Team Admin", Template: "surface-team-admin", Auth: "authenticated", + DataRequires: []string{"team-admin"}, + Layout: "single", Source: "core", + }, { ID: "workflow", Route: "/w/:id", Title: "Workflow", Template: "workflow", Auth: "session", @@ -337,6 +343,9 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc { if section == "" && surfaceID == "settings" { section = "general" } + if section == "" && surfaceID == "team-admin" { + section = "members" + } e.Render(c, "base.html", PageData{ Surface: surfaceID, diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index a513e33..e5bbbae 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -64,6 +64,7 @@
{{if eq .Surface "chat"}}{{template "surface-chat" .}} {{else if eq .Surface "admin"}}{{template "surface-admin" .}} + {{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}} {{else if eq .Surface "notes"}}{{template "surface-notes" .}} {{else if eq .Surface "settings"}}{{template "surface-settings" .}} {{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}} @@ -140,6 +141,7 @@ {{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}} {{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}} + {{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}} {{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}} {{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}} {{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}} diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html index fda44a4..6cc286f 100644 --- a/server/pages/templates/surfaces/admin.html +++ b/server/pages/templates/surfaces/admin.html @@ -10,135 +10,7 @@
Loading admin…
-{{/* Modals — kept outside mount for legacy compat during transition. */}} -{{/* These will be removed once all modal usage is confirmed replaced by Dialog primitives. */}} - -{{/* Create User modal */}} - - -{{/* Reset Password modal */}} - - -{{/* Approve User modal */}} - - -{{/* Create Team modal */}} - - -{{/* Create Group modal */}} - - -{{/* Provider form modal */}} - - -{{/* Persona form modal */}} - +{{/* Legacy modals removed in v0.37.7 — all handled by Dialog primitives in Preact components */}} {{end}} {{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}} diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index bb0a696..087050c 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -354,112 +354,7 @@ window.addEventListener('unhandledrejection', function(e) { {{/* ── Hidden File Input ─────── */}} -{{/* ── Team Admin Modal ────────────────────── */}} - +{{/* Team Admin Modal — removed in v0.37.7. Team admin is now at /team-admin. */}} {{/* ── Debug Modal moved to base.html (available on all surfaces) ── */}} @@ -506,24 +401,20 @@ window.addEventListener('unhandledrejection', function(e) { {{/* App JS — order matches index.html (minus api/events/ui-primitives/ui-format already in base) */}} - - +{{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}} - - +{{/* memory-ui.js and persona-kb.js removed in v0.37.7 — no chat surface callers */}} - +{{/* notification-prefs.js removed in v0.37.7 — now native Preact in settings */}} - - +{{/* settings-handlers.js, admin-handlers.js, workflow-admin.js removed in v0.37.7 */}} - diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index e2d49e2..4589a25 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -1,7 +1,6 @@ {{/* - Settings surface — v0.37.5: Preact component tree. - Replaces server-rendered DOM with a mount point. - Bridge sections (tasks, gitkeys, workflows, data) still load old JS. + Settings surface — v0.37.7: Full Preact component tree. + All sections are native Preact components. No bridges. */}} {{define "surface-settings"}} @@ -21,16 +20,7 @@ window.__SECTION__ = '{{.Section}}'; -{{/* Bridge section JS — deferred sections still use old imperative code */}} - - - - - - - - - +{{/* Bridge section scripts removed in v0.37.7 — all sections are native Preact */}} {{/* Vendor: Preact + htm → globals, then SDK boot, then settings surface */}} + + +{{end}} diff --git a/server/version.go b/server/version.go index 60c4824..1b55ad4 100644 --- a/server/version.go +++ b/server/version.go @@ -1,3 +1,3 @@ package main -const Version = "0.37.6" +const Version = "0.37.7" diff --git a/src/js/admin-handlers.js b/src/js/admin-handlers.js deleted file mode 100644 index 1a39535..0000000 --- a/src/js/admin-handlers.js +++ /dev/null @@ -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 = 'Loading teams...'; - openModal('approveUserModal'); - - try { - const resp = await API.adminListTeams(); - const teams = (resp.data || []).filter(t => t.is_active); - if (teams.length === 0) { - teamsEl.innerHTML = 'No teams — user will be activated without team assignment'; - } else { - teamsEl.innerHTML = '' + - teams.map(t => ``).join(''); - } - } catch (e) { teamsEl.innerHTML = `Could not load teams`; } -} - -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 = '' + - teams.map(t => ``).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 - ? `
- ⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching. -
` - : ''; - - // Use container divs instead of textareas when CM6 is available - const useCM6 = !!window.CM?.codeEditor; - const manifestEditor = useCM6 - ? `
` - : ``; - const scriptEditor = useCM6 - ? `
` - : ``; - - const formHTML = ` -
- ${systemWarning} -
- - -
-
- - -
-
- - ${manifestEditor} -
-
- - ${scriptEditor} -
-
- - -
-
- `; - - // 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); diff --git a/src/js/admin-packages.js b/src/js/admin-packages.js deleted file mode 100644 index f726c2c..0000000 --- a/src/js/admin-packages.js +++ /dev/null @@ -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 = - '
' + - '

' + - 'Surfaces, extensions, and full packages. Install .pkg or .surface archives.' + - '

' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '
' + - '' + - '' + - '' + - '
Loading...
'; - - 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 = '
Loading...
'; - - 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 = '
No packages found
'; - return; - } - - var html = '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - packages.forEach(function(pkg) { - var typeBadge = { - surface: 'surface', - extension: 'extension', - full: 'full', - }[pkg.type] || '' + pkg.type + ''; - - var sourceBadge = { - core: 'core', - builtin: 'builtin', - extension: 'installed', - registry: 'registry', - }[pkg.source] || pkg.source; - - var scopeBadge = { - global: 'global', - team: 'team', - personal: 'personal', - }[pkg.scope] || pkg.scope; - - var statusBadge = pkg.enabled - ? 'enabled' - : 'disabled'; - - var actions = ''; - // Toggle enable/disable (not for chat/admin) - if (pkg.id !== 'chat' && pkg.id !== 'admin') { - if (pkg.enabled) { - actions += ' '; - } else { - actions += ' '; - } - } - // Settings (if manifest has settings) - if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) { - actions += ' '; - } - // Export (non-core only) - if (pkg.source !== 'core') { - actions += ' '; - } - // Delete (non-core only) - if (pkg.source !== 'core') { - actions += ''; - } - - var desc = pkg.description ? '
' + pkg.description + '
' : ''; - var system = pkg.is_system ? ' system' : ''; - var schemaVer = pkg.schema_version > 0 ? ' v' + pkg.schema_version + '' : ''; - - html += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - - html += '
PackageTypeVersionScopeSourceStatusActions
' + pkg.title + '' + system + schemaVer + '
' + pkg.id + '
' + desc + '
' + typeBadge + '' + (pkg.version || '\u2014') + '' + scopeBadge + '' + sourceBadge + '' + statusBadge + '' + actions + '
'; - 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 = '
Failed to load packages: ' + e.message + '
'; - } - } - - // ── Settings panel ────────────────────────── - - async function loadSettings(pkgId) { - var panel = document.getElementById('adminPkgSettingsPanel'); - panel.style.display = 'block'; - panel.innerHTML = '
Loading settings...
'; - - 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 = '

No settings declared by this package.

' + - ''; - document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; }); - return; - } - - var html = '

Settings: ' + pkgId + '

'; - schema.forEach(function(s) { - var key = s.key; - var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : ''); - html += '
' + - ''; - - if (s.type === 'select' && s.options) { - html += ''; - } else if (s.type === 'boolean') { - html += ''; - } else if (s.type === 'number') { - html += ''; - } else { - html += ''; - } - html += '
'; - }); - - html += '
' + - '' + - '' + - '
'; - - 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 = '
Failed to load settings: ' + e.message + '
'; - } - } - - // ── Registry browse ───────────────────────── - - async function loadRegistry() { - var panel = document.getElementById('adminPkgRegistryPanel'); - panel.style.display = 'block'; - panel.innerHTML = '
Loading registry...
'; - - try { - var resp = await API._get('/api/v1/admin/packages/registry'); - var packages = resp.packages || []; - var registryUrl = resp.registry_url || ''; - - var html = '
' + - '

Package Registry

' + - '' + - '' + - '
'; - - if (!registryUrl) { - html += '

No registry URL configured. Set package_registry in Admin Settings with a {"url": "https://..."} value.

'; - } else if (packages.length === 0) { - html += '

Registry is empty or returned no packages.

'; - } else { - html += '' + - '' + - ''; - - packages.forEach(function(pkg) { - var installBtn = pkg.installed - ? 'Installed' - : ''; - - html += '' + - '' + - '' + - '' + - '' + - ''; - }); - html += '
PackageVersionAuthorActions
' + pkg.title + '
' + - (pkg.description || '') + '
' + (pkg.version || '') + '' + (pkg.author || '') + '' + installBtn + '
'; - } - - 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 = '
Failed to load registry: ' + e.message + '
'; - } - } - - loadList(); -} - -sb.register('_loadAdminPackages', _loadAdminPackages); diff --git a/src/js/admin-scaffold.js b/src/js/admin-scaffold.js deleted file mode 100644 index bf5c7fc..0000000 --- a/src/js/admin-scaffold.js +++ /dev/null @@ -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 = - '
'; - - SCAFFOLDING.teams = - '
' + - ''; - - SCAFFOLDING.groups = - '
' + - ''; - - // -- AI --------------------------------------------------------------- - - SCAFFOLDING.providers = - '
'; - - SCAFFOLDING.models = - '
' + - '' + - '' + - '
' + - '
'; - - SCAFFOLDING.personas = - '
'; - - SCAFFOLDING.roles = '
'; - SCAFFOLDING.knowledgeBases = '
'; - SCAFFOLDING.memory = '
'; - - // -- System ----------------------------------------------------------- - - SCAFFOLDING.settings = - '
' + - '

Registration

' + - '' + - '
' + - '' + - '
' + - '
' + - '

System Prompt

' + - '' + - '
' + - '

Default Model

' + - '' + - '
' + - '

Policies

' + - '' + - '' + - '' + - '
' + - '

Environment Banner

' + - '' + - '' + - '
' + - '

Web Search

' + - '
' + - '' + - '
' + - '' + - '
' + - '
' + - '

Auto-Compaction

' + - '' + - '' + - '
' + - '

Memory Extraction

' + - '' + - '' + - '
' + - '

Encryption Vault

' + - '
Loading...
' + - '
' + - '
' + - '
' + - '' + - '
' + - '' + - '' + - '' + - '
' + - '
'; - - SCAFFOLDING.storage = '
'; - - SCAFFOLDING.packages = - '
'; - - // -- Routing ---------------------------------------------------------- - - SCAFFOLDING.health = - '
' + - '
'; - - SCAFFOLDING.routing = - '
' + - '
' + - '' + - '

Test Routing

' + - '
' + - '
' + - '
' + - '' + - '
' + - '' + - '
'; - - SCAFFOLDING.capabilities = - '
Loading...
'; - - // -- Monitoring ------------------------------------------------------- - - SCAFFOLDING.dashboard = - '
' + - '
Loading...
'; - - SCAFFOLDING.usage = - '
' + - '
' + - '
' + - '
' + - '
'; - - SCAFFOLDING.audit = - '
' + - '
' + - '
' + - '
' + - '
Loading...
' + - '
' + - '' + - '' + - '' + - '
'; - - SCAFFOLDING.stats = - '
Loading...
'; - - SCAFFOLDING.channels = - '
' + - '
Archived
' + - '
Retention Mode
' + - '
' + - '
Loading...
'; - - - // === 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 '' + s.l + ''; - }).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 = '
Section: ' + section + '
'; - } - - // -- 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](); - } - }); diff --git a/src/js/app.js b/src/js/app.js index 6973862..72a883a 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -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 diff --git a/src/js/broadcast-admin.js b/src/js/broadcast-admin.js deleted file mode 100644 index 18cbecc..0000000 --- a/src/js/broadcast-admin.js +++ /dev/null @@ -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 = - '
' + - '

' + - 'Send a system announcement to all active users. Notifications appear in their ' + - 'bell menu and optionally via email (based on user preferences).' + - '

' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
'; - - 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 = - ' ' + - '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 = - ' ' + - 'Failed: ' + (err.message || err); - UI.toast('Broadcast failed', 'error'); - } finally { - sendBtn.disabled = false; - sendBtn.textContent = ' Send Broadcast'; - } - }); - }); diff --git a/src/js/chat.js b/src/js/chat.js index 28ac8f0..1788eab 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -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); diff --git a/src/js/data-portability.js b/src/js/data-portability.js deleted file mode 100644 index 885314f..0000000 --- a/src/js/data-portability.js +++ /dev/null @@ -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 = ` -
-

- This will permanently delete your account and all associated data. - This action cannot be undone. -

-
- - -
-
- - -
-
`; - document.getElementById('dpDeleteConfirmBtn') - .addEventListener('click', _executeDelete); - document.getElementById('dpDeleteCancelBtn') - .addEventListener('click', () => { _renderDeleteButton(el); }); - } - - function _renderDeleteButton(el) { - el.innerHTML = ` - `; - 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 = ` -
-

Export My Data

-

- Download all your data as a .switchboard archive: conversations, - notes, memories, projects, files, settings, and usage history. -

- -
- -
-

Import Data

-

- Restore from a .switchboard archive exported from this or another - instance. Existing items with the same ID are skipped (no duplicates). -

-
- - -
-
- -
-

Import from ChatGPT

-

- Import conversations from a ChatGPT export. Go to - ChatGPT Settings → Data Controls → Export, - download the zip, and upload it here. Conversations become direct chats. -

-
- - -
-
- -
-

Danger Zone

-

- Permanently delete your account and all associated data. Your messages in - shared channels will be anonymized. This cannot be undone. -

-
-
`; - - // 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); - -})(); diff --git a/src/js/git-credentials-ui.js b/src/js/git-credentials-ui.js deleted file mode 100644 index 8aaa3e3..0000000 --- a/src/js/git-credentials-ui.js +++ /dev/null @@ -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 = - '
' + - '

' + - '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.' + - '

' + - '
' + - '

SSH Keys

' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '' + - 'Security' + - '
' + - '

' + - '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.' + - '

' + - '
' + - '
'; - - // ── Generate key dialog ── - var GENERATE_HTML = - '
' + - '
' + - '' + - '' + - '
' + - '' + - '
'; - - 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 ? '' + esc(c.fingerprint) + '' : ''; - var persona = c.persona_id ? 'persona' : ''; - return '
' + - '
' + - '
' + esc(c.name) + ' ' + persona + '
' + - '
' + - '' + esc(c.auth_type) + '' + - fp + - '' + dateStr + '' + - '
' + - '
' + - (c.public_key ? '' : '') + - '' + - '
'; - }).join(''); - } catch (err) { - listEl.innerHTML = '
Failed to load credentials
'; - } - } - - function showPublicKey(cred) { - if (!cred.public_key) return; - UI.modal('Public Key — ' + cred.name, - '

Add this key to your git host (GitHub → Settings → SSH Keys):

' + - '' + - '

Fingerprint: ' + esc(cred.fingerprint) + '

', - [{ 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); diff --git a/src/js/memory-ui.js b/src/js/memory-ui.js deleted file mode 100644 index aa0082f..0000000 --- a/src/js/memory-ui.js +++ /dev/null @@ -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 = '
Loading memories...
'; - - try { - // Load memory count summary - const count = await API.getMemoryCount(); - const active = count.active || 0; - const pending = count.pending || 0; - - panel.innerHTML = ` -
-
- ${active} - Active -
-
- ${pending} - Pending Review -
-
- -
-
- - -
-
- - ${pending > 0 ? `` : ''} -
-
- -
- `; - - // 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 = `
${esc(e.message)}
`; - } - }, - - async loadMemories() { - const el = document.getElementById('memoryList'); - if (!el) return; - el.innerHTML = '
Loading...
'; - - try { - const data = await API.listMemories(this._filter.status, this._filter.query); - this._memories = data.data || []; - - if (!this._memories.length) { - el.innerHTML = `
No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}
`; - return; - } - - el.innerHTML = this._memories.map(m => this._renderMemoryCard(m)).join(''); - this._wireMemoryCards(el); - - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } - }, - - _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 ` -
-
- ${esc(m.key)} -
- ${scopeLabel} - ${confidence}% -
-
-
${esc(m.value)}
- -
`; - }, - - _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 = ` -
-
- - -
-
- - -
-
- - -
-
- `; - - 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 = '
Loading...
'; - - try { - const data = await API.adminListPendingMemories(); - const pending = data.data || []; - - el.innerHTML = ` -
- ${pending.length} pending memories - ${pending.length > 0 ? ` - - ` : ''} - -
-
- ${pending.length === 0 ? '
No memories pending review
' : ''} - ${pending.map(m => this._renderAdminMemoryRow(m)).join('')} -
- `; - - // 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 = `
${esc(e.message)}
`; - } - }, - - _renderAdminMemoryRow(m) { - const confidence = Math.round(m.confidence * 100); - const scope = m.scope === 'persona_user' ? 'persona' : m.scope; - return ` -
-
- ${esc(m.key)} -
- ${scope} - ${confidence}% - ${esc(m.owner_id?.substring(0, 8) || '?')}… -
-
-
${esc(m.value)}
- -
`; - }, - - // ═════════════════════════════════════════ - // 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 = ` -
-

Memory

- -

When enabled, this persona can save and recall memories from conversations.

-
- - -
- `; - - // 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); diff --git a/src/js/notification-prefs.js b/src/js/notification-prefs.js deleted file mode 100644 index 1d7a893..0000000 --- a/src/js/notification-prefs.js +++ /dev/null @@ -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 += ` - ${esc(t.label)} - - - - - - - `; - } - - container.innerHTML = ` - - - - - - ${html} -
Notification TypeIn-AppEmail
`; - - // 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); diff --git a/src/js/persona-kb.js b/src/js/persona-kb.js deleted file mode 100644 index 6d79a99..0000000 --- a/src/js/persona-kb.js +++ /dev/null @@ -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 = ` -
- -
Bind KBs to this persona — users will search these KBs automatically when using this persona.
-
-
Loading knowledge bases…
-
-
- `; - - // ── 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 = '
No knowledge bases with content available.
'; - return; - } - - list.innerHTML = _allKBs.map(kb => { - const sel = _selected[kb.id] || { selected: false, auto_search: false }; - return ` - `; - }).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); diff --git a/src/js/settings-handlers.js b/src/js/settings-handlers.js deleted file mode 100644 index 583481d..0000000 --- a/src/js/settings-handlers.js +++ /dev/null @@ -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: '', action: () => newChat() }, - { id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '', 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: '', action: () => UI.toggleSidebar() }, - - // Tools - { id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '', action: () => openNotes() }, - { id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('md') }, - { id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('json') }, - - // Settings - { id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '', action: () => UI.openSettings() }, - { id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' }, - { id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '', action: () => openDebugModal() }, - - // Account - { id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '', 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: '', - 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 = '
No matching commands
'; - 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 += `
${c.group}
`; - } - html += `
- ${c.icon} - ${c.label} - ${c.hint ? `${c.hint}` : ''} -
`; - }); - 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 = ''; - try { - const resp = await API.teamListMembers(UI._managingTeamId); - const existingIds = new Set((resp.data || []).map(m => m.user_id)); - sel.innerHTML = ''; - } catch (e) { sel.innerHTML = ''; } - }); - 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 = ''; - 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 = '
You are not a member of any teams.
'; - return; - } - - var html = '

' + - 'Manage workflows owned by your teams. Use the Admin panel for global workflows.

'; - - 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 += '
' + - '

' + esc(team.name) + '

'; - - if (workflows.length === 0) { - html += '
No workflows for this team.
'; - } else { - workflows.forEach(function(wf) { - var statusBadge = wf.is_active ? - 'Active' : - 'Inactive'; - var base = window.__BASE__ || ''; - html += '
' + - '' + esc(wf.name) + '' + - statusBadge + - 'v' + wf.version + '' + - '' + esc(wf.slug) + '' + - (API.isAdmin ? - 'Edit in Admin' : '') + - '
'; - }); - } - html += '
'; - } - - el.innerHTML = html; - } catch (e) { - el.innerHTML = '
Failed to load workflows: ' + esc(e.message) + '
'; - } -}); - -// ── 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); diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index a73a891..49d3c32 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -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'), diff --git a/src/js/sw/surfaces/settings/bridge-section.js b/src/js/sw/surfaces/settings/bridge-section.js deleted file mode 100644 index d672f91..0000000 --- a/src/js/sw/surfaces/settings/bridge-section.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * BridgeSection — thin Preact wrapper for deferred settings sections - * - * Renders a container
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` -
-
Loading\u2026
-
- `; -} diff --git a/src/js/sw/surfaces/settings/data.js b/src/js/sw/surfaces/settings/data.js new file mode 100644 index 0000000..6abb912 --- /dev/null +++ b/src/js/sw/surfaces/settings/data.js @@ -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` +
+
+

Export Your Data

+

+ Download a copy of all your data including conversations, settings, and memories. + The export will be prepared and a download link will be provided. +

+ +
+ +
+

Danger Zone

+

+ Permanently delete your account and all associated data. This action cannot be undone. + We recommend exporting your data before proceeding. +

+ +
+
+ `; +} diff --git a/src/js/sw/surfaces/settings/gitkeys.js b/src/js/sw/surfaces/settings/gitkeys.js new file mode 100644 index 0000000..c6954da --- /dev/null +++ b/src/js/sw/surfaces/settings/gitkeys.js @@ -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`
Loading\u2026
`; + + return html` +
+
+ ${keys.length} credential${keys.length !== 1 ? 's' : ''} +
+ +
+ + ${showAdd && html` +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + } + + return html` +
+
+
+ +
+ +
+ ${personas.length === 0 && html`
No personas
`} + ${personas.map(p => html` +
+
+ ${p.name} + ${p.description && html`${p.description.substring(0, 60)}${p.description.length > 60 ? '\u2026' : ''}`} +
+ ${p.kb_count || 0} KBs +
+ + +
+
+ `)} +
+
+ `; +} diff --git a/src/js/sw/surfaces/team-admin/providers.js b/src/js/sw/surfaces/team-admin/providers.js new file mode 100644 index 0000000..cc1279d --- /dev/null +++ b/src/js/sw/surfaces/team-admin/providers.js @@ -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`
Loading\u2026
`; + + return html` +
+
+
+ +
+ + ${editing && html` +
+

${editing === 'new' ? 'Add Provider' : 'Edit Provider'}

+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ + +
+
+ `} + +
+ ${configs.length === 0 && html`
No provider configs
`} + ${configs.map(c => html` +
+
+ ${c.name} + ${c.provider} + ${c.has_key ? html`key set` : html`no key`} +
+
${c.endpoint || '(default)'}
+
+ + + +
+
+ `)} +
+
+ `; +} diff --git a/src/js/sw/surfaces/team-admin/settings.js b/src/js/sw/surfaces/team-admin/settings.js new file mode 100644 index 0000000..be9a1a5 --- /dev/null +++ b/src/js/sw/surfaces/team-admin/settings.js @@ -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`
Loading\u2026
`; + + return html` +
+
+

Team Details

+
+ + setName(e.target.value)} placeholder="My Team" /> +
+
+ + +
+
+ + ${team && html` +
+

Info

+
+
ID: ${team.id}
+ ${team.created_at && html`
Created: ${new Date(team.created_at).toLocaleString()}
`} + ${team.member_count != null && html`
Members: ${team.member_count}
`} +
+
+ `} + +
+ +
+
+ `; +} diff --git a/src/js/sw/surfaces/team-admin/tasks.js b/src/js/sw/surfaces/team-admin/tasks.js new file mode 100644 index 0000000..eb9d794 --- /dev/null +++ b/src/js/sw/surfaces/team-admin/tasks.js @@ -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`
Loading\u2026
`; + + return html` +
+
+
+ +
+ + ${showCreate && html` +
+
+
+
+
+
+
+ + +
+
+ `} + +
+ ${tasks.length === 0 && html`
No tasks
`} + ${tasks.map(t => html` +
+
+ ${t.name} + ${t.schedule && html`${t.schedule}`} +
+ + last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)} + + ${t.is_active ? 'active' : 'paused'} +
+ + + +
+
+ `)} +
+
+ `; +} diff --git a/src/js/sw/surfaces/team-admin/usage.js b/src/js/sw/surfaces/team-admin/usage.js new file mode 100644 index 0000000..522cd1c --- /dev/null +++ b/src/js/sw/surfaces/team-admin/usage.js @@ -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`
Loading\u2026
`; + + return html` +
+ ${usage?.totals && html` +
+
${fmtNum(usage.totals.total_tokens)}
Total Tokens
+
${fmtNum(usage.totals.total_requests)}
Total Requests
+
$${(usage.totals.total_cost || 0).toFixed(2)}
Total Cost
+
+ `} + + ${usage?.results && usage.results.length > 0 && html` +

Usage by Day

+
+ ${usage.results.map((r, i) => html` +
+ ${r.date || r.day || '\u2014'} + ${fmtNum(r.tokens)} tokens + ${r.requests || 0} requests + $${(r.cost || 0).toFixed(2)} +
+ `)} +
+ `} + + ${(!usage?.totals && !usage?.results) && html` +
No usage data available
+ `} +
+ `; +} diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js new file mode 100644 index 0000000..cfd7945 --- /dev/null +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -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`
Loading\u2026
`; + + if (editing) return html` +
+
+ +

Edit: ${editing.name}

+
+ + +
+
+
+
+ +
+
+
+ + +
Stages (${stages.length})
+
+ ${stages.map((s, i) => html` +
+ #${i + 1} + ${s.name || `Stage ${i + 1}`} + ${s.stage_mode || '\u2014'} +
+ `)} + ${stages.length === 0 && html`
No stages defined
`} +
+ +
+ +
+
+ `; + + return html` +
+
+
+ +
+ + ${showCreate && html` +
+
+
+
+
+ +
+
+
+
+ + +
+
+ `} + +
+ ${workflows.length === 0 && html`
No workflows
`} + ${workflows.map(w => html` +
openEdit(w)}> +
+ ${w.name} + /${w.slug} +
+ ${w.entry_mode} + ${w.is_active ? 'active' : 'inactive'} + v${w.version || 0} +
+ `)} +
+
+ `; +} diff --git a/src/js/task-admin.js b/src/js/task-admin.js deleted file mode 100644 index d79dbfa..0000000 --- a/src/js/task-admin.js +++ /dev/null @@ -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 = - '
' + - '' + - '' + - '' + - '
' + - '
' + - '
' + - '

Scheduled Tasks

' + - '0' + - '
' + - '
' + - '
' + - '' + - '' + - ''; - - 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 '' + status + ''; - } - - function escapeHtml(s) { - if (!s) return ''; - return s.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 = '
No scheduled tasks
'; - return; - } - - list.innerHTML = tasks.map(function(t) { - var active = t.is_active - ? 'active' - : 'paused'; - var scope = '' + t.scope + ''; - var type = '' + t.task_type + ''; - var funcBadge = t.task_type === 'system' && t.system_function - ? '' + escapeHtml(t.system_function) + '' : ''; - 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 = ''; - } - - return '
' + - '
' + - '
' + escapeHtml(t.name) + '
' + - '
' + - escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun + - '
' + - '
' + - '
' + type + funcBadge + scope + active + '
' + - '
next: ' + nextRun + '
' + - '
' + - triggerBtn + - '' + - '' + - '' + - '
' + - '
'; - }).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 = '
No runs yet
'; - 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 - ? '
' + escapeHtml(r.error) + '
' - : ''; - - return '
' + - '
' + - '
' + - '
' + started + ' \u2192 ' + completed + '
' + - '
' + - 'tokens: ' + r.tokens_used + ' \u00b7 tools: ' + r.tool_calls + ' \u00b7 wall: ' + duration + - '
' + - errLine + - '
' + - statusBadge(r.status) + - '
' + - '
'; - }).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 = ''; - _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(); - }); diff --git a/src/js/task-settings.js b/src/js/task-settings.js deleted file mode 100644 index 8359c41..0000000 --- a/src/js/task-settings.js +++ /dev/null @@ -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,'"') : ''; } - - // 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 = '
' + - '

Your scheduled tasks. Tasks run automatically on their schedule.

' + - '' + - '
'; - - if (tasks.length > 0) { - html += tasks.map(function(t) { - var status = t.is_active ? 'active' : 'paused'; - var teamBadge = t._source === 'team' ? '' + esc(t._teamName || 'team') + '' : ''; - 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 = ''; - } - - return '
' + - '
' + - '
' + - '
' + esc(t.name) + '
' + - '
' + esc(sched) + ' (' + esc(t.timezone) + ')' + lastStatus + '
' + - '
next: ' + nextRun + '
' + - '
' + - '
' + - teamBadge + status + - triggerBtn + - '' + - '' + - '' + - '
' + - '
' + - '
'; - }).join(''); - } - - // Templates section - html += '

Starter Templates

' + - '

Quick-start with a pre-configured task. You can customize after creation.

' + - '
'; - TEMPLATES.forEach(function(tmpl) { - html += '
' + - '
' + esc(tmpl.name) + '
' + - '
' + esc(tmpl.schedule) + '
' + - '
'; - }); - html += '
'; - - 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 = '

Failed to load tasks: ' + esc(err.message) + '

'; - } - } - - // ── 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 ''; - }).join(''); - - mount.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '

Used to sign outbound webhook payloads. Leave blank for auto-generated secret.

' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '' + - '
'; - - 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', - '

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.

' + - '
' + - '' + - '' + - '
' + - '

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.

', - [{ 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(); - }); diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js deleted file mode 100644 index 9c85965..0000000 --- a/src/js/ui-admin.js +++ /dev/null @@ -1,1690 +0,0 @@ -// ========================================== -// Chat Switchboard – UI Admin -// ========================================== -// Fullscreen admin panel with category + section navigation. -// All loadAdmin*() functions unchanged — same endpoints, same DOM IDs. - - -// ── Category → Section mapping ──────────── -const ADMIN_SECTIONS = { - people: ['users', 'teams', 'groups'], - ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'], - workflows: ['workflows', 'tasks'], - routing: ['health', 'routing', 'capabilities'], - system: ['settings', 'storage', 'packages', 'channels', 'broadcast'], - monitoring: ['dashboard', 'usage', 'audit', 'stats'], -}; - -const ADMIN_LABELS = { - users: 'Users', teams: 'Teams', groups: 'Groups', - providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory', - health: 'Health', routing: 'Routing', capabilities: 'Capabilities', - workflows: 'Workflows', tasks: 'Tasks', - settings: 'Settings', storage: 'Storage', packages: 'Packages', channels: 'Channels', broadcast: 'Broadcast', - dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats', -}; - -// Section → loader mapping (reuses all existing loadAdmin* functions) -const ADMIN_LOADERS = { - users: () => UI.loadAdminUsers(), - teams: () => UI.loadAdminTeams(), - groups: () => UI.loadAdminGroups(), - roles: () => UI.loadAdminRoles(), - providers: () => UI.loadAdminProviders(), - models: () => UI.loadAdminModels(), - personas: () => UI.loadAdminPersonas(), - knowledgeBases: () => { - if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; } - KnowledgeUI.openAdminPanel(); - }, - memory: () => { - if (typeof MemoryUI !== 'undefined') { MemoryUI.openAdminPanel(); } - else { console.error('[Admin] MemoryUI not loaded — check js/memory-ui.js'); } - }, - settings: () => UI.loadAdminSettings(), - storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null, - packages: () => typeof _loadAdminPackages === 'function' ? _loadAdminPackages() : null, - health: () => UI.loadAdminHealth(), - routing: () => UI.loadAdminRouting(), - capabilities: () => UI.loadAdminCapabilities(), - dashboard: () => UI.loadAdminDashboard(), - usage: () => UI.loadAdminUsage(), - audit: () => UI.loadAuditLog(), - stats: () => UI.loadAdminStats(), - channels: () => UI.loadAdminChannels(), - workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null, - tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null, - broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null, -}; - -// Find which category a section belongs to -function _adminCatForSection(section) { - for (const [cat, sections] of Object.entries(ADMIN_SECTIONS)) { - if (sections.includes(section)) return cat; - } - return 'people'; -} - -Object.assign(UI, { - // ── Admin Panel State ───────────────────── - _adminCat: 'people', - _adminSection: 'users', - - // ── Open / Close ────────────────────────── - openAdmin(cat, section) { - const base = window.__BASE__ || ''; - window.location.href = base + '/admin/' + (section || 'users'); - }, - - closeAdmin() { - const base = window.__BASE__ || ''; - const returnURL = sessionStorage.getItem('sb_admin_return'); - sessionStorage.removeItem('sb_admin_return'); - window.location.href = returnURL || (base + '/'); - }, - - // ── Navigate to specific section ────────── - openAdminSection(section) { - const base = window.__BASE__ || ''; - location.replace(base + '/admin/' + (section || 'users')); - }, - - // ── Category switch ─────────────────────── - switchAdminCategory(cat) { - UI._adminCat = cat; - UI._adminSection = ADMIN_SECTIONS[cat][0]; - UI._renderAdminNav(); - UI._switchAdminSection(UI._adminSection); - }, - - // ── Render navigation state ─────────────── - _renderAdminNav() { - // Highlight active category - document.querySelectorAll('.admin-cat').forEach(btn => { - btn.classList.toggle('active', btn.dataset.cat === UI._adminCat); - }); - // Populate sidebar - const sidebar = document.getElementById('adminSidebar'); - sidebar.innerHTML = ADMIN_SECTIONS[UI._adminCat].map(sec => - `` - ).join(''); - // Wire sidebar clicks - sidebar.querySelectorAll('button').forEach(btn => { - btn.addEventListener('click', () => UI._switchAdminSection(btn.dataset.section)); - }); - }, - - // ── Section switch (shows content + lazy-loads) ── - async _switchAdminSection(section) { - UI._adminSection = section; - // Update sidebar highlight - document.querySelectorAll('.admin-sidebar button').forEach(btn => { - btn.classList.toggle('active', btn.dataset.section === section); - }); - // Hide all, show target - document.querySelectorAll('.admin-section-content').forEach(c => c.style.display = 'none'); - const panel = document.getElementById(`admin${section.charAt(0).toUpperCase() + section.slice(1)}Tab`); - if (panel) panel.style.display = ''; - // Lazy-load data - await ADMIN_LOADERS[section]?.(); - }, - - openTeamAdmin() { - const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin'); - if (adminTeams.length === 0) { - UI.toast('You are not an admin of any teams', 'error'); - return; - } - openModal('teamAdminModal'); - if (adminTeams.length === 1) { - // Skip picker, go directly to the team - UI.openTeamManage(adminTeams[0].id, adminTeams[0].name); - } else { - // Show team picker - document.getElementById('teamAdminTitle').textContent = 'Team Management'; - document.getElementById('teamAdminPicker').style.display = ''; - document.getElementById('teamAdminContent').style.display = 'none'; - document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => ` -
-
- ${esc(t.name)} - admin - Manage → -
- ${t.description ? `
${esc(t.description)}
` : ''} -
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
-
- `).join(''); - } - }, - closeTeamAdmin() { closeModal('teamAdminModal'); }, - - switchTeamTab(tab) { - const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab'); - tabs.forEach(t => { - t.classList.remove('active'); - if (t.dataset.ttab === tab) t.classList.add('active'); - }); - document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none'); - const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`); - if (panel) panel.style.display = ''; - - // Lazy-load tab data - const teamId = UI._managingTeamId; - if (tab === 'members') UI.loadTeamManageMembers(teamId); - if (tab === 'providers') UI.loadTeamManageProviders(teamId); - if (tab === 'personas') UI.loadTeamManagePersonas(teamId); - if (tab === 'workflows') UI.loadTeamWorkflows(teamId); - if (tab === 'usage') UI.loadTeamUsage(); - if (tab === 'activity') UI.loadTeamAuditLog(1); - if (tab === 'settings') UI.loadTeamManageSettings(teamId); - if (tab === 'knowledge') { - if (typeof KnowledgeUI === 'undefined') { - console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js'); - const c = document.getElementById('teamKBContent'); - if (c) c.innerHTML = '
Knowledge UI failed to load. Check browser console.
'; - } else { - KnowledgeUI.openTeamPanel(teamId); - } - } - if (tab === 'groups') UI.loadTeamManageGroups(teamId); - }, - - async loadAdminUsers(quiet) { - const el = document.getElementById('adminUserList'); - if (!quiet) el.innerHTML = '
Loading...
'; - try { - const resp = await API.adminListUsers(); - const users = resp.users || resp.data || []; - if (!users.length) { el.innerHTML = '
No users
'; return; } - - const rows = users.map(u => { - const initial = (u.display_name || u.username || '?')[0].toUpperCase(); - const name = esc(u.display_name || u.username); - const handle = '@' + esc(u.username); - const roleCls = u.role === 'admin' ? 'badge-admin' : u.role === 'service' ? 'badge-service' : 'badge-user'; - const isPending = !u.is_active; - const statusCls = isPending ? 'badge-warning' : 'badge-active'; - const statusLabel = isPending ? 'pending' : 'active'; - const teams = (u.teams || []).map(t => esc(t.team_name)).join(', ') || '\u2014'; - const lastSeen = u.last_login_at ? _relativeTime(u.last_login_at) : '\u2014'; - - return ` -
-
${initial}
-
${name}
${handle}
-
- ${u.role} - ${statusLabel} - ${teams} - ${lastSeen} - - ${isPending - ? `` - : `` - } - - - - - `; - }).join(''); - - el.innerHTML = ` - - - ${rows} -
UserRoleStatusTeamsLast Seen
- `; - - // Approve form placeholder (injected per-user when needed) - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - // ── Admin: Dashboard (v0.33.0) ──────── - async loadAdminDashboard() { - const el = document.getElementById('adminDashboardContent'); - if (!el) return; - el.innerHTML = '
Loading dashboard...
'; - - try { - const d = await API.adminGetDashboard(); - - // Stat cards row - const providers = d.provider_health || []; - const healthy = providers.filter(p => p.status === 'healthy').length; - const degraded = providers.filter(p => p.status === 'degraded' || p.status === 'down' || p.status === 'unhealthy').length; - const usage = d.usage_24h; - const tokenStr = usage && usage.total_tokens ? Number(usage.total_tokens).toLocaleString() : '0'; - const reqStr = usage && usage.total_requests ? Number(usage.total_requests).toLocaleString() : '0'; - - const rt = d.runtime || {}; - - const cardsEl = document.getElementById('adminDashboardCards'); - if (cardsEl) { - cardsEl.innerHTML = ` -
Uptime
${esc(d.uptime || '—')}
-
WS Connections
${d.ws_connections || 0}
-
Providers
${healthy}
${degraded ? degraded + ' degraded' : 'all healthy'}
-
Tokens (24h)
${tokenStr}
${reqStr} requests
-
Goroutines
${rt.goroutines || '—'}
-
Heap
${rt.heap_mb != null ? rt.heap_mb + ' MB' : '—'}
Sys: ${rt.sys_mb || '—'} MB
`; - } - - // Provider health cards - let html = ''; - if (providers.length > 0) { - html += '

Provider Health

'; - html += '
'; - providers.forEach(p => { - const name = esc(p.provider_name || p.provider_config_id?.slice(0, 12) || '—'); - const statusCls = p.status === 'healthy' ? 'badge-healthy' : p.status === 'degraded' ? 'badge-degraded' : 'badge-unhealthy'; - const latency = p.avg_latency_ms != null ? Math.round(p.avg_latency_ms) + 'ms' : '—'; - html += `
-
- ${name} - ${esc(p.status || 'unknown')} -
-
- Latency: ${latency} - Reqs: ${p.request_count || 0} - Errors: ${p.error_count || 0} -
-
`; - }); - html += '
'; - } - - // DB pool stats - if (d.db_pool) { - const pool = d.db_pool; - const pct = pool.OpenConnections > 0 ? Math.round(pool.InUse / pool.OpenConnections * 100) : 0; - html += '

DB Connection Pool

'; - html += `
-
-
- Open: ${pool.OpenConnections} - In Use: ${pool.InUse} - Idle: ${pool.Idle} - Wait Count: ${pool.WaitCount} -
-
`; - } - - // Storage status (fetched in parallel) - try { - const storage = await API._get('/api/v1/admin/storage/status'); - if (storage && storage.configured) { - html += '

Storage

'; - html += '
'; - html += `
`; - html += `Backend: ${esc(storage.backend || '—')}`; - if (storage.total_bytes != null) html += `Total: ${(storage.total_bytes / 1024 / 1024).toFixed(1)} MB`; - if (storage.file_count != null) html += `Files: ${storage.file_count}`; - html += `${storage.healthy ? 'Healthy' : 'Unhealthy'}`; - html += `
`; - } - } catch (_) { /* storage status optional */ } - - // Recent errors - const errors = d.recent_errors || []; - if (errors.length > 0) { - html += '

Recent Errors

'; - html += '
'; - errors.forEach(e => { - const time = e.created_at ? new Date(e.created_at).toLocaleString() : '—'; - html += `
- ${time} - ${esc(e.action || '')} ${esc(e.resource_type || '')} ${esc(e.resource_id || '')} -
`; - }); - html += '
'; - } - - el.innerHTML = html || '
No data yet.
'; - - // Auto-refresh every 30s - if (UI._dashboardRefreshTimer) clearInterval(UI._dashboardRefreshTimer); - UI._dashboardRefreshTimer = setInterval(() => { - if (document.getElementById('adminDashboardContent')) { - UI.loadAdminDashboard(); - } else { - clearInterval(UI._dashboardRefreshTimer); - } - }, 30000); - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async loadAdminStats() { - const el = document.getElementById('adminStats'); - el.innerHTML = '
Loading...
'; - try { - const s = await API.adminGetStats(); - const labels = { users: 'Users', channels: 'Channels', messages: 'Messages', models: 'Models', providers: 'Providers', personas: 'Personas' }; - el.innerHTML = Object.entries(s).map(([k, v]) => ` -
-
${labels[k] || k.replace(/_/g, ' ')}
-
${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}
-
`).join(''); - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - // ── Admin: Roles ──────────────────────── - - async loadAdminRoles() { - const el = document.getElementById('adminRolesContent'); - if (!el) return; - - if (!UI._adminRoleConfig) { - UI._adminRoleConfig = renderRoleConfig(el, { - scope: 'admin', - showFallback: true, - modelIdField: 'model_id', - modelNameField: 'display_name', - providerIdField: 'provider_config_id', - fetchProviders: async () => { - const configs = await API.adminListGlobalConfigs(); - return configs.configs || configs || []; - }, - fetchModels: async () => { - const models = await API.adminListModels(); - return models.models || models.data || models || []; - }, - fetchRoles: () => API.adminListRoles(), - onSave: async (roleId, config) => { - await API.adminUpdateRole(roleId, config); - }, - onTest: (roleId) => API.adminTestRole(roleId), - }); - } - - await UI._adminRoleConfig.refresh(); - }, - - // ── Admin: Usage ──────────────────────── - - async loadAdminUsage() { - // Stat cards + bar chart - const cardsEl = document.getElementById('adminUsageCards'); - const chartEl = document.getElementById('adminUsageChart'); - - // Fetch 7-day usage for cards and chart - if (cardsEl) { - try { - const weekData = await API.adminGetUsage({ period: '7d', group_by: 'day' }); - const buckets = weekData.data || weekData.buckets || []; - const totalReqs = buckets.reduce((s, b) => s + (b.request_count || 0), 0); - const totalIn = buckets.reduce((s, b) => s + (b.input_tokens || 0), 0); - const totalOut = buckets.reduce((s, b) => s + (b.output_tokens || 0), 0); - const totalTokens = totalIn + totalOut; - const tokenStr = totalTokens > 1e6 ? (totalTokens / 1e6).toFixed(1) + 'M' : totalTokens > 1e3 ? (totalTokens / 1e3).toFixed(0) + 'K' : totalTokens; - const userSet = new Set(buckets.filter(b => b.user_id).map(b => b.user_id)); - const avgMs = buckets.reduce((s, b) => s + (b.avg_latency_ms || 0), 0); - const avgLatency = buckets.length ? (avgMs / buckets.length / 1000).toFixed(1) + 's' : '—'; - - cardsEl.innerHTML = ` -
Total Requests
${totalReqs.toLocaleString()}
Last 7 days
-
Tokens Used
${tokenStr}
Input + output
-
Active Users
${userSet.size || '—'}
Last 7 days
-
Avg Response
${avgLatency}
p50 latency
`; - - // Bar chart — aggregate by day label - if (chartEl && buckets.length > 0) { - const dayMap = {}; - buckets.forEach(b => { - const d = new Date(b.window_start || b.date || b.bucket); - const label = d.toLocaleDateString('en', { weekday: 'short' }); - dayMap[label] = (dayMap[label] || 0) + (b.request_count || 0); - }); - const days = Object.entries(dayMap); - const maxVal = Math.max(...days.map(d => d[1]), 1); - chartEl.innerHTML = ` -
-
Requests per Day
-
- ${days.map(([label, val]) => { - const pct = Math.round(val / maxVal * 100); - return `
- ${val} -
- ${label} -
`; - }).join('')} -
-
`; - } - } catch (e) { - cardsEl.innerHTML = ''; - if (chartEl) chartEl.innerHTML = ''; - } - } - - // Usage breakdown via primitive (preserves existing period/group-by controls) - const totalsEl = document.getElementById('adminUsageTotals'); - if (!totalsEl) return; - let usageContainer = document.getElementById('_adminUsageContainer'); - if (!usageContainer) { - usageContainer = document.createElement('div'); - usageContainer.id = '_adminUsageContainer'; - const resultsEl = document.getElementById('adminUsageResults'); - totalsEl.parentElement.insertBefore(usageContainer, totalsEl); - usageContainer.appendChild(totalsEl); - if (resultsEl) usageContainer.appendChild(resultsEl); - } - - if (!UI._adminUsageDash) { - UI._adminUsageDash = renderUsageDashboard(usageContainer, { - apiFetch: (p) => API.adminGetUsage(p), - periodElId: 'usagePeriod', - groupByElId: 'usageGroupBy', - compact: false, - showUserColumn: true, - }); - } - await UI._adminUsageDash.refresh(); - - // Pricing table - const pricingEl = document.getElementById('adminPricingTable'); - if (pricingEl) { - try { - const pricing = await API.adminListPricing(); - const entries = pricing || []; - if (entries.length === 0) { - pricingEl.innerHTML = '
No pricing configured. Sync providers to populate from catalog.
'; - } else { - pricingEl.innerHTML = ` - - - - - - - - ${entries.map(p => ` - - - - - `).join('')} -
ModelInput $/MOutput $/MSource
${esc(p.model_id)}${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}${p.source}
`; - } - } catch (e) { pricingEl.innerHTML = `
${esc(e.message)}
`; } - } - }, - - async loadAdminExtensions() { - const el = document.getElementById('adminExtensionsList'); - el.innerHTML = '
Loading...
'; - try { - const resp = await API._get('/api/v1/admin/extensions'); - const exts = resp.data || []; - this._adminExtensions = exts; - if (exts.length === 0) { - el.innerHTML = '
No extensions installed
'; - return; - } - el.innerHTML = ` - - - ${exts.map(ext => { - const statusBadge = ext.is_enabled ? 'enabled' : 'disabled'; - const systemBadge = ext.is_system ? ' system' : ''; - return ` - - - - - - `; - }).join('')} -
ExtensionStatusTierVersion
-
${esc(ext.name)}${systemBadge}
-
${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}
-
${statusBadge}${esc(ext.tier)}v${esc(ext.version)} - - - -
- `; - } catch (e) { - el.innerHTML = '
Failed to load extensions
'; - } - }, - - _auditPage: 1, - _auditPerPage: 30, - - async loadAuditLog(page) { - if (page) UI._auditPage = page; - const el = document.getElementById('adminAuditList'); - el.innerHTML = '
Loading...
'; - - // Populate action filter on first load - const actionSel = document.getElementById('auditFilterAction'); - if (actionSel && actionSel.options.length <= 1) { - try { - const resp = await API.adminListAuditActions(); - (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._auditPage, - per_page: UI._auditPerPage, - action: document.getElementById('auditFilterAction')?.value || '', - resource_type: document.getElementById('auditFilterResource')?.value || '', - }; - - try { - const resp = await API.adminListAudit(params); - const entries = resp.data || []; - const total = resp.total || 0; - const totalPages = Math.ceil(total / UI._auditPerPage); - - if (entries.length === 0) { - el.innerHTML = '
No audit entries found
'; - } 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 ` - - - - - - - `; - }).join('')} -
ActionActorResourceDetailsTimeIP
${esc(e.action)}${esc(actor)}${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}${meta || '—'}${timeStr}${e.ip_address ? esc(e.ip_address) : '—'}
`; - } - - // Pagination - const pgEl = document.getElementById('auditPagination'); - if (totalPages > 1) { - pgEl.style.display = 'flex'; - document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`; - document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1; - document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages; - } else { - pgEl.style.display = 'none'; - } - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - _formatAuditMeta(metaStr) { - try { - const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr; - if (!m || Object.keys(m).length === 0) return ''; - return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · '); - } catch { return ''; } - }, - - async loadAdminProviders(quiet) { - const el = document.getElementById('adminProviderList'); - const formEl = document.getElementById('adminAddProviderForm'); - - // Initialize admin provider form primitive (once) — now renders inside modal body - if (!UI._adminProvForm && formEl) { - UI._adminProvForm = renderProviderForm(formEl, { - prefix: 'adminProv', - showPrivate: true, - showDefaultModel: true, - onSubmit: async (vals, isEdit) => { - 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; - updates.model_default = vals.model_default; - updates.is_private = vals.is_private || false; - await API.adminUpdateGlobalConfig(vals._editId, updates); - UI.toast('Provider updated', 'success'); - } else { - if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; } - await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false); - UI.toast('Provider added', 'success'); - } - closeModal('providerFormModal'); - UI._adminProvForm.setCreateMode(); - UI.loadAdminProviders(true); - } catch (e) { UI.toast(e.message, 'error'); } - }, - onCancel: () => { - closeModal('providerFormModal'); - UI._adminProvForm.setCreateMode(); - }, - }); - } - - if (!quiet) el.innerHTML = '
Loading...
'; - - try { - // Fetch providers, health, and models in parallel - const [configsRaw, healthRaw, modelsRaw] = await Promise.all([ - API.adminListGlobalConfigs(), - API.adminGetAllProviderHealth().catch(() => ({ providers: [] })), - API.adminListModels().catch(() => ({ models: [] })), - ]); - - const configs = Array.isArray(configsRaw) ? configsRaw : (configsRaw.configs || configsRaw.providers || configsRaw.data || []); - const healthList = healthRaw.providers || healthRaw.data || healthRaw || []; - const models = modelsRaw.models || modelsRaw.data || modelsRaw || []; - - // Build health lookup by provider_config_id - const healthMap = {}; - (Array.isArray(healthList) ? healthList : []).forEach(h => { healthMap[h.provider_config_id] = h; }); - - // Count models per provider - const modelCounts = {}; - (Array.isArray(models) ? models : []).forEach(m => { - const pid = m.provider_config_id; - if (pid) modelCounts[pid] = (modelCounts[pid] || 0) + 1; - }); - - // Cache for edit - UI._providerCache = {}; - configs.forEach(c => { UI._providerCache[c.id] = c; }); - - if (!configs.length) { - el.innerHTML = '
No providers configured \u2014 click + Add above
'; - return; - } - - el.innerHTML = configs.map(c => { - const health = healthMap[c.id]; - const status = health ? health.status : 'unknown'; - const statusCls = status === 'healthy' ? 'badge-healthy' - : status === 'degraded' ? 'badge-degraded' - : status === 'unhealthy' ? 'badge-unhealthy' - : 'badge-unknown'; - const count = modelCounts[c.id] || 0; - const scope = c.is_private ? 'Private' : (c.team_id ? 'Team' : ''); - - return `
-
-
${esc(c.name)}${scope ? ' (' + scope + ')' : ''}
- ${status} -
-
- ${esc(c.provider || '')} - ${count ? `${count} model${count !== 1 ? 's' : ''}` : ''} -
-
`; - }).join(''); - - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - - // Delegate card clicks → open edit modal - if (!el._delegated) { - el._delegated = true; - el.addEventListener('click', (e) => { - const card = e.target.closest('.provider-card'); - if (!card) return; - const id = card.dataset.id; - const prov = UI._providerCache?.[id]; - if (!prov || !UI._adminProvForm) return; - UI._adminProvForm.setEditMode(prov.id, prov); - document.getElementById('providerFormTitle').textContent = 'Edit Provider'; - openModal('providerFormModal'); - }); - } - }, - - async loadAdminModels(quiet) { - const el = document.getElementById('adminModelList'); - if (!quiet) el.innerHTML = '
Loading...
'; - try { - const data = await API.adminListModels(); - const list = data.models || data.data || data || []; - const arr = Array.isArray(list) ? list : []; - if (!arr.length) { el.innerHTML = '
No models — add a provider first, then click Fetch Models
'; return; } - - el.innerHTML = ` - - - ${arr.map(m => { - const caps = m.capabilities || {}; - const badges = renderCapBadges(caps, { compact: true }); - const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled'); - const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled'; - const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : ''; - return ` - - - - - `; - }).join('')} -
ModelProviderCapabilitiesVisibility
${esc(m.model_id || m.id)}${esc(m.provider_name || '')}${badges}
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async loadAdminPersonas(quiet) { - const el = document.getElementById('adminPersonaList'); - if (!quiet) el.innerHTML = '
Loading...
'; - try { - const data = await API.adminListPersonas(); - const list = data.data || []; - - // Ensure form is initialized for dropdown population - ensureAdminPersonaForm(); - - // Populate model dropdown from admin model list (all synced models) - const modelSel = _adminPersonaForm?.getModelSelect(); - if (modelSel) { - modelSel.innerHTML = ''; - try { - const modelData = await API.adminListModels(); - const allModels = modelData.models || modelData.data || []; - const arr = Array.isArray(allModels) ? allModels : []; - arr.forEach(m => { - const opt = document.createElement('option'); - const mid = m.model_id || m.id; - opt.value = mid; - opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : ''); - modelSel.appendChild(opt); - }); - } catch (e) { console.warn('Failed to load models for persona form:', e.message); } - } - - // Populate config dropdown - const cfgSel = _adminPersonaForm?.getConfigSelect(); - if (cfgSel && cfgSel.options.length <= 1) { - try { - const cfgData = await API.adminListGlobalConfigs(); - const cfgs = cfgData.configs || cfgData.data || []; - (Array.isArray(cfgs) ? cfgs : []).forEach(c => { - const opt = document.createElement('option'); - opt.value = c.id; - opt.textContent = c.name + ' (' + c.provider + ')'; - cfgSel.appendChild(opt); - }); - } catch (e) { /* optional */ } - } - - UI._personaCache = {}; - if (!list.length) { el.innerHTML = '
No personas — create one to give users preconfigured model experiences
'; return; } - - el.innerHTML = ` - - - ${list.map(p => { - UI._personaCache[p.id] = p; - const avatarEl = p.avatar - ? `` - : ''; - let scopeBadge = ''; - if (p.scope === 'global') scopeBadge = 'global'; - else if (p.scope === 'team' && p.team_name) scopeBadge = `${esc(p.team_name)}`; - else if (p.scope === 'personal') scopeBadge = 'personal'; - const creator = p.creator_name ? `
by ${esc(p.creator_name)}
` : ''; - return ` - - - - - - `; - }).join('')} -
PersonaScopeModelStatus
-
- ${avatarEl} -
-
${esc(p.name)}
- ${p.description ? `
${esc(p.description)}
` : creator} -
-
-
${scopeBadge}${esc(p.base_model_id)}${p.is_active ? 'active' : 'inactive'} - - - - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - // ── Teams Admin ───────────────────────── - _teamEditId: null, - - async loadAdminTeams(quiet) { - const el = document.getElementById('adminTeamList'); - const detail = document.getElementById('adminTeamDetail'); - if (!quiet) { - el.innerHTML = '
Loading...
'; - detail.style.display = 'none'; - el.style.display = ''; - closeModal('createTeamModal'); - } - try { - const resp = await API.adminListTeams(); - const teams = resp.data || []; - if (!teams.length) { el.innerHTML = '
No teams yet — create one to organize users and set policies
'; return; } - - el.innerHTML = ` - - - ${teams.map(t => ` - - - - - `).join('')} -
TeamStatusMembers
-
${esc(t.name)}
-
${esc(t.description || 'No description')}
-
- ${t.is_active ? 'active' : 'inactive'} - ${t.member_count} member${t.member_count !== 1 ? 's' : ''} - - - - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async openTeamDetail(teamId, teamName) { - this._teamEditId = teamId; - document.getElementById('adminTeamList').style.display = 'none'; - closeModal('createTeamModal'); - const detail = document.getElementById('adminTeamDetail'); - detail.style.display = ''; - document.getElementById('adminTeamDetailName').textContent = teamName; - document.getElementById('adminAddMemberForm').style.display = 'none'; - - // Load team settings for policy checkboxes - try { - const team = await API.adminGetTeam(teamId); - const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {}); - document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers; - // allow_team_providers defaults to true if not explicitly set - document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false; - } catch (e) { /* proceed with defaults */ } - - await this.loadTeamMembers(teamId); - }, - - async loadTeamMembers(teamId) { - const el = document.getElementById('adminMemberList'); - el.innerHTML = '
Loading...
'; - try { - const resp = await API.adminListMembers(teamId); - const members = resp.data || []; - if (!members.length) { el.innerHTML = '
No members — add users to this team
'; return; } - - el.innerHTML = ` - - - ${members.map(m => ` - - - - `).join('')} -
MemberRole
-
${esc(m.display_name || m.email)}
-
${esc(m.email)}
-
- - ${m.user_role === 'admin' ? 'sys-admin' : ''} - - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async loadMemberUserDropdown(teamId) { - const sel = document.getElementById('adminMemberUser'); - sel.innerHTML = ''; - try { - const resp = await API.adminListUsers(1, 200); - const users = resp.users || resp.data || []; - // Also get existing members to exclude - const memberResp = await API.adminListMembers(teamId); - const existingIds = new Set((memberResp.data || []).map(m => m.user_id)); - const available = users.filter(u => u.is_active && !existingIds.has(u.id)); - sel.innerHTML = '' + - available.map(u => ``).join(''); - } catch (e) { sel.innerHTML = ``; } - }, - - // ── Groups Admin ──────────────────────── - _groupEditId: null, - - async loadAdminGroups(quiet) { - const el = document.getElementById('adminGroupList'); - const detail = document.getElementById('adminGroupDetail'); - if (!quiet) { - el.innerHTML = '
Loading...
'; - detail.style.display = 'none'; - el.style.display = ''; - closeModal('createGroupModal'); - } - try { - const resp = await API.adminListGroups(); - const groups = resp.data || []; - if (!groups.length) { el.innerHTML = '
No groups yet — create one to control access to personas and knowledge bases
'; return; } - - el.innerHTML = ` - - - ${groups.map(g => { - const scopeBadge = g.scope === 'global' - ? 'global' - : 'team'; - return ` - - - - - `; - }).join('')} -
GroupScopeMembers
-
${esc(g.name)}
-
${esc(g.description || 'No description')}
-
${scopeBadge}${g.member_count} member${g.member_count !== 1 ? 's' : ''} - - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async openGroupDetail(groupId, groupName) { - this._groupEditId = groupId; - document.getElementById('adminGroupList').style.display = 'none'; - closeModal('createGroupModal'); - const detail = document.getElementById('adminGroupDetail'); - detail.style.display = ''; - document.getElementById('adminGroupDetailName').textContent = groupName; - document.getElementById('adminAddGroupMemberForm').style.display = 'none'; - - // Load group + available permissions + enabled models in parallel - try { - const [g, permResp, modelsResp] = await Promise.all([ - API.adminGetGroup(groupId), - API.adminListPermissions(), - API.listEnabledModels(), - ]); - const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group'; - document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`; - - // ── Permission checkboxes ── - const allPerms = permResp.permissions || []; - const groupPerms = new Set(g.permissions || []); - const permDesc = { - 'model.use': 'Use models for completions', - 'model.select_any': 'Use any enabled model', - 'kb.read': 'Search knowledge bases', - 'kb.write': 'Upload / delete KB documents', - 'kb.create': 'Create knowledge bases', - 'channel.create': 'Create channels', - 'channel.invite': 'Invite users to channels', - 'persona.create': 'Create personas', - 'persona.manage': 'Edit / delete team personas', - 'workflow.create': 'Create workflows', - 'task.create': 'Create scheduled tasks', - 'task.admin': 'Manage all tasks + config', - 'admin.view': 'Read-only admin access', - 'token.unlimited': 'Bypass token budgets', - }; - const permEl = document.getElementById('adminGroupPermissions'); - permEl.innerHTML = allPerms.map(p => ``).join(''); - - // ── Token budget fields ── - document.getElementById('adminGroupBudgetDaily').value = g.token_budget_daily != null ? g.token_budget_daily : ''; - document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : ''; - - // ── Model allowlist checkboxes ── - const models = (modelsResp.data || modelsResp.models || []).filter(m => !m.is_persona); - const allowedSet = g.allowed_models ? new Set(g.allowed_models) : null; - const modelEl = document.getElementById('adminGroupModels'); - const seen = new Set(); - const unique = models.filter(m => { if (seen.has(m.model_id)) return false; seen.add(m.model_id); return true; }); - unique.sort((a, b) => a.model_id.localeCompare(b.model_id)); - modelEl.innerHTML = unique.map(m => ``).join('') || 'No models available'; - } catch (e) { - console.error('openGroupDetail:', e); - } - - // Wire save button - document.getElementById('adminGroupSavePermsBtn').onclick = () => this._saveGroupPerms(groupId); - - await this.loadGroupMembers(groupId); - }, - - async _saveGroupPerms(groupId) { - const status = document.getElementById('adminGroupSaveStatus'); - status.textContent = 'Saving\u2026'; - - const perms = [...document.querySelectorAll('.group-perm-cb:checked')].map(cb => cb.value); - const dailyRaw = document.getElementById('adminGroupBudgetDaily').value.trim(); - const monthlyRaw = document.getElementById('adminGroupBudgetMonthly').value.trim(); - const allModelCbs = document.querySelectorAll('.group-model-cb'); - const checkedModelCbs = document.querySelectorAll('.group-model-cb:checked'); - const allChecked = allModelCbs.length > 0 && checkedModelCbs.length === allModelCbs.length; - - const update = { permissions: perms }; - - if (dailyRaw === '') { update.clear_budget_daily = true; } - else { update.token_budget_daily = parseInt(dailyRaw, 10); } - if (monthlyRaw === '') { update.clear_budget_monthly = true; } - else { update.token_budget_monthly = parseInt(monthlyRaw, 10); } - - if (allChecked || allModelCbs.length === 0) { update.clear_allowed_models = true; } - else { update.allowed_models = [...checkedModelCbs].map(cb => cb.value); } - - try { - await API.adminUpdateGroup(groupId, update); - status.textContent = 'Saved \u2713'; - setTimeout(() => { status.textContent = ''; }, 2000); - } catch (e) { - status.textContent = 'Error: ' + e.message; - } - }, - - async loadGroupMembers(groupId) { - const el = document.getElementById('adminGroupMemberList'); - el.innerHTML = '
Loading...
'; - try { - const resp = await API.adminListGroupMembers(groupId); - const members = resp.data || []; - if (!members.length) { el.innerHTML = '
No members — add users to this group
'; return; } - el.innerHTML = ` - - - ${members.map(m => ` - - - - `).join('')} -
MemberAdded
-
${esc(m.display_name || m.username || m.email)}
-
${esc(m.email)}
-
${new Date(m.added_at).toLocaleDateString()} - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async loadGroupMemberDropdown(groupId) { - const sel = document.getElementById('adminGroupMemberUser'); - sel.innerHTML = ''; - try { - const resp = await API.adminListUsers(1, 200); - const users = resp.users || resp.data || []; - // Exclude existing members - const memberResp = await API.adminListGroupMembers(groupId); - const existingIds = new Set((memberResp.data || []).map(m => m.user_id)); - const available = users.filter(u => u.is_active && !existingIds.has(u.id)); - sel.innerHTML = '' + - available.map(u => ``).join(''); - } catch (e) { sel.innerHTML = ``; } - }, - - // ── Grant Picker (shared for Personas + KBs) ── - - async openGrantPicker(resourceType, resourceId, resourceName, anchorSelector) { - // Fetch current grant + all groups for the picker - const [grantResp, groupsResp] = await Promise.all([ - API.adminGetGrant(resourceType, resourceId), - API.adminListGroups(), - ]); - const grant = grantResp; - const groups = (groupsResp.data || []); - const currentScope = grant.grant_scope || 'team_only'; - const currentGroups = new Set(grant.granted_groups || []); - - const html = `
-
Access: ${esc(resourceName)}
-
- - -
-
- ${groups.length ? groups.map(g => ``).join('') : 'No groups exist yet — create one in People → Groups'} -
-
- - -
-
`; - - // Remove any existing picker, then insert after the anchor row - document.querySelectorAll('.grant-picker-active').forEach(el => el.remove()); - const row = document.querySelector(anchorSelector); - if (row) { - const wrapper = document.createElement('div'); - wrapper.className = 'grant-picker-active'; - wrapper.innerHTML = html; - row.after(wrapper); - - // Wire scope toggle - const scopeSel = document.getElementById(`grantScope_${resourceId}`); - const groupsDiv = document.getElementById(`grantGroups_${resourceId}`); - scopeSel?.addEventListener('change', () => { - groupsDiv.style.display = scopeSel.value === 'groups' ? '' : 'none'; - }); - } - }, - - // ── Provider Health Dashboard (v0.22.3) ────── - async loadAdminHealth() { - const cardsEl = document.getElementById('adminHealthCards'); - const el = document.getElementById('adminHealthContent'); - if (cardsEl) cardsEl.innerHTML = ''; - el.innerHTML = '
Loading health data...
'; - - try { - const data = await API.adminGetAllProviderHealth(); - const providers = data.data || []; - - if (providers.length === 0) { - el.innerHTML = '
No health data yet — health metrics are collected after the first completion request.
'; - return; - } - - // Stat cards - const total = providers.length; - const healthy = providers.filter(p => p.status === 'healthy').length; - const degraded = providers.filter(p => p.status === 'degraded').length; - const down = providers.filter(p => p.status === 'down' || p.status === 'unhealthy').length; - const avgLat = providers.reduce((s, p) => s + (p.avg_latency_ms || 0), 0); - const avgLatStr = total > 0 ? Math.round(avgLat / total) + 'ms' : '—'; - - if (cardsEl) { - cardsEl.innerHTML = ` -
Providers
${total}
All configured
-
Healthy
${healthy}
${total ? Math.round(healthy / total * 100) : 0}%
-
Degraded
${degraded + down}
${total ? Math.round((degraded + down) / total * 100) : 0}%
-
Avg Latency
${avgLatStr}
Last hour
`; - } - - // Table - const rows = providers.map(p => { - const name = esc(p.provider_name || p.provider_config_id?.slice(0, 8) || '—'); - const statusCls = p.status === 'healthy' ? 'badge-healthy' : p.status === 'degraded' ? 'badge-degraded' : p.status === 'down' || p.status === 'unhealthy' ? 'badge-unhealthy' : 'badge-unknown'; - const latency = p.avg_latency_ms != null ? p.avg_latency_ms + 'ms' : '—'; - const errorRate = p.error_rate != null ? (100 - p.error_rate * 100).toFixed(1) + '%' : '—'; - const reqs = p.request_count ?? 0; - const errCount = (p.error_count || 0) + (p.timeout_count || 0); - const lastErr = p.last_error ? `
${esc(p.last_error)}
` : '—'; - - return ` - ${name} - ${esc(p.status || 'unknown')} - ${latency} - ${errorRate} - ${reqs} - ${errCount} - ${lastErr} - `; - }).join(''); - - el.innerHTML = ` - - - ${rows} -
ProviderStatusLatencyUptimeRequestsErrorsLast Error
`; - } catch (e) { - el.innerHTML = `
Failed to load health data: ${esc(e.message)}
`; - } - }, - - // ── Routing Policies (v0.22.3) ─────────────── - async loadAdminRouting() { - const listEl = document.getElementById('adminRoutingPolicyList'); - const formEl = document.getElementById('adminRoutingForm'); - listEl.innerHTML = '
Loading policies...
'; - - // Wire add button - const addBtn = document.getElementById('adminAddRoutingPolicyBtn'); - addBtn?.addEventListener('click', () => UI._showRoutingForm(null), { once: true }); - - // Wire test button - const testBtn = document.getElementById('routingTestBtn'); - testBtn?.addEventListener('click', () => UI._runRoutingTest(), { once: true }); - - try { - const data = await API.adminListRoutingPolicies(); - const policies = data.data || []; - - if (policies.length === 0) { - listEl.innerHTML = '
No routing policies — requests go to the user\'s default provider config.
'; - return; - } - - const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' }; - - listEl.innerHTML = ` - - - ${policies.map(p => ` - - - - - - - `).join('')} -
PolicyTypeScopePriorityStatus
${esc(p.name)}${esc(typeLabels[p.policy_type] || p.policy_type)}${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}${p.priority}${p.is_active ? 'active' : 'inactive'} - - - -
- `; - } catch (e) { - listEl.innerHTML = `
Failed to load routing policies: ${esc(e.message)}
`; - } - }, - - async _showRoutingForm(policyId) { - const formEl = document.getElementById('adminRoutingForm'); - const isEdit = !!policyId; - let policy = { name: '', scope: 'global', priority: 100, policy_type: 'provider_prefer', is_active: true, config: {} }; - - if (isEdit) { - try { policy = await API.adminGetRoutingPolicy(policyId); } catch (e) { UI.toast(e.message, 'error'); return; } - } - - const cfg = policy.config || {}; - formEl.style.display = ''; - formEl.innerHTML = ` -

${isEdit ? 'Edit' : 'New'} Routing Policy

-
-
-
-
-
-
- -
-
- -
-
-
- -
-
- -
- -
- - -
`; - - document.getElementById('rpScope').addEventListener('change', (e) => { - document.getElementById('rpTeamGroup').style.display = e.target.value === 'team' ? '' : 'none'; - }); - document.getElementById('rpCancelBtn').addEventListener('click', () => { formEl.style.display = 'none'; }); - document.getElementById('rpSaveBtn').addEventListener('click', async () => { - let config; - try { config = JSON.parse(document.getElementById('rpConfig').value || '{}'); } - catch (e) { UI.toast('Invalid JSON in config field', 'error'); return; } - - const payload = { - name: document.getElementById('rpName').value, - priority: parseInt(document.getElementById('rpPriority').value) || 100, - policy_type: document.getElementById('rpType').value, - scope: document.getElementById('rpScope').value, - team_id: document.getElementById('rpScope').value === 'team' ? document.getElementById('rpTeamId').value : null, - is_active: document.getElementById('rpActive').checked, - config, - }; - try { - if (isEdit) { await API.adminUpdateRoutingPolicy(policyId, payload); } - else { await API.adminCreateRoutingPolicy(payload); } - UI.toast(isEdit ? 'Policy updated' : 'Policy created', 'success'); - formEl.style.display = 'none'; - UI.loadAdminRouting(); - } catch (e) { UI.toast(e.message, 'error'); } - }); - }, - - async _toggleRoutingPolicy(id, active) { - try { - const policy = await API.adminGetRoutingPolicy(id); - policy.is_active = active; - await API.adminUpdateRoutingPolicy(id, policy); - UI.toast(active ? 'Policy enabled' : 'Policy disabled', 'success'); - UI.loadAdminRouting(); - } catch (e) { UI.toast(e.message, 'error'); } - }, - - async _deleteRoutingPolicy(id, name) { - if (!await showConfirm(`Delete routing policy "${name}"?`)) return; - try { - await API.adminDeleteRoutingPolicy(id); - UI.toast('Policy deleted', 'success'); - UI.loadAdminRouting(); - } catch (e) { UI.toast(e.message, 'error'); } - }, - - async _runRoutingTest() { - const model = document.getElementById('routingTestModel').value; - const userId = document.getElementById('routingTestUser').value || API.user?.id; - const resultEl = document.getElementById('routingTestResult'); - if (!model) { UI.toast('Model is required', 'warning'); return; } - - resultEl.style.display = ''; - resultEl.innerHTML = '
Evaluating...
'; - try { - const data = await API.adminTestRouting(model, userId); - const dec = data.decision; - const candidates = data.candidates || []; - - resultEl.innerHTML = ` -
- Decision: ${dec ? `${esc(dec.policy_name || 'no policy')} → ${esc(dec.selected?.slice(0, 8) || '—')}` : 'No routing applied'} - (${candidates.length} candidates, ${data.policies || 0} policies evaluated) -
-
- ${candidates.map((c, i) => ` -
- ${i + 1}. ${esc(c.provider_id)} / ${esc(c.config_id?.slice(0, 8) || '—')} - ${esc(c.status || 'unknown')} - ${c.reason ? `${esc(c.reason)}` : ''} -
- `).join('')} -
`; - } catch (e) { - resultEl.innerHTML = `
Test failed: ${esc(e.message)}
`; - } - }, - - // ── Capability Overrides (v0.22.3) ─────────── - async loadAdminCapabilities() { - const el = document.getElementById('adminCapabilityList'); - el.innerHTML = '
Loading overrides...
'; - try { - const data = await API.adminListCapabilityOverrides(); - const overrides = data.data || []; - - if (overrides.length === 0) { - el.innerHTML = '
No capability overrides — all models use auto-detected capabilities from catalog sync or heuristic inference.
'; - return; - } - - el.innerHTML = ` - - ${overrides.map(o => ` - - - - - - `).join('')} -
ModelProvider ConfigFieldValue
${esc(o.model_id)}${esc(o.provider_config_id?.slice(0, 8) || 'any')}${esc(o.field)}${esc(String(o.value))}
`; - } catch (e) { - el.innerHTML = `
Failed to load overrides: ${esc(e.message)}
`; - } - }, - - async _deleteCapOverride(modelId, overrideId) { - try { - await API.adminDeleteModelCapability(modelId, overrideId); - UI.toast('Override deleted', 'success'); - UI.loadAdminCapabilities(); - } catch (e) { UI.toast(e.message, 'error'); } - }, - - async loadAdminSettings() { - try { - const data = await API.adminGetSettings(); - // v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } } - const settings = data.settings || {}; - const policies = data.policies || {}; - - // Helper to read from settings map (JSONB values) - const getSetting = (key, fallback) => { - const v = settings[key]; - if (v === undefined || v === null) return fallback; - // Unwrap {value: X} wrapper if present - return (v && typeof v === 'object' && 'value' in v) ? v.value : v; - }; - - // Registration (policy: allow_registration) - document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true'; - - // Admin system prompt (global_settings) - const sysPrompt = getSetting('system_prompt', {}) || {}; - document.getElementById('adminSystemPrompt').value = sysPrompt.content || ''; - - // Registration default state (policy: default_user_active → 'true' means auto-active) - const defaultActive = policies.default_user_active === 'true'; - document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending'; - - // User providers / BYOK (policy: allow_user_byok) - document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true'; - - // User personas (policy: allow_user_personas) - document.getElementById('adminUserPersonasToggle').checked = policies.allow_user_personas === 'true'; - - // KB direct access (policy: kb_direct_access — v0.17.0) - const kbDirectEl = document.getElementById('adminKBDirectAccessToggle'); - if (kbDirectEl) kbDirectEl.checked = policies.kb_direct_access === 'true'; - - // Default model (policy: default_model) — populate from current model list - const defModelSel = document.getElementById('adminDefaultModel'); - if (defModelSel) { - defModelSel.innerHTML = ''; - let modelList = typeof App !== 'undefined' && App.models ? App.models : []; - // On admin surface, App isn't loaded — fetch from admin API - if (modelList.length === 0 && typeof API !== 'undefined' && API.adminListModels) { - try { - const mr = await API.adminListModels(); - modelList = (mr.models || mr.data || []).map(m => ({ - id: m.model_id || m.id, - baseModelId: m.model_id || m.id, - name: m.display_name || m.model_id || m.id, - provider: m.provider_name || '', - hidden: !!m.hidden, - })); - } catch (e) { /* non-critical */ } - } - modelList.filter(m => !m.hidden).forEach(m => { - const opt = document.createElement('option'); - opt.value = m.baseModelId || m.id; - opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); - defModelSel.appendChild(opt); - }); - defModelSel.value = policies.default_model || ''; - } - - // Banner (global_settings) - const banner = getSetting('banner', {}) || {}; - document.getElementById('adminBannerEnabled').checked = !!banner.enabled; - document.getElementById('adminBannerText').value = banner.text || ''; - document.getElementById('adminBannerBg').value = banner.bg || '#007a33'; - document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33'; - document.getElementById('adminBannerFg').value = banner.fg || '#ffffff'; - document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff'; - document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none'; - UI.updateBannerPreview(); - - // Vault / Encryption status - const vaultEl = document.getElementById('adminVaultStatus'); - - // Web Search config (global_settings) - const searchCfg = getSetting('search_config', {}) || {}; - document.getElementById('adminSearchProvider').value = searchCfg.provider || 'duckduckgo'; - document.getElementById('adminSearchEndpoint').value = searchCfg.endpoint || ''; - document.getElementById('adminSearchApiKey').value = searchCfg.api_key || ''; - document.getElementById('adminSearchMaxResults').value = String(searchCfg.max_results || 5); - document.getElementById('searxngConfigFields').style.display = - (searchCfg.provider === 'searxng') ? '' : 'none'; - - // Auto-Compaction (global_settings) - const compactionCfg = getSetting('auto_compaction', {}) || {}; - const compactionEnabled = document.getElementById('adminCompactionEnabled'); - if (compactionEnabled) { - compactionEnabled.checked = !!compactionCfg.enabled; - document.getElementById('compactionConfigFields').style.display = - compactionCfg.enabled ? '' : 'none'; - } - const compThreshold = document.getElementById('adminCompactionThreshold'); - if (compThreshold) compThreshold.value = String(compactionCfg.threshold || 70); - const compCooldown = document.getElementById('adminCompactionCooldown'); - if (compCooldown) compCooldown.value = String(compactionCfg.cooldown || 30); - - // Memory extraction config (v0.18.0) - const memExtractionCfg = getSetting('memory_extraction', {}) || {}; - const memEnabled = document.getElementById('adminMemoryExtractionEnabled'); - if (memEnabled) { - memEnabled.checked = !!memExtractionCfg.enabled; - const memFields = document.getElementById('memoryExtractionConfigFields'); - if (memFields) memFields.style.display = memExtractionCfg.enabled ? '' : 'none'; - memEnabled.addEventListener('change', () => { - if (memFields) memFields.style.display = memEnabled.checked ? '' : 'none'; - }); - } - const memAutoApprove = document.getElementById('adminMemoryAutoApprove'); - if (memAutoApprove) memAutoApprove.checked = !!memExtractionCfg.auto_approve; - - // Email / SMTP config (v0.20.0 Phase 3) - const notifCfg = getSetting('notifications', {}) || {}; - if (typeof NotifPrefs !== 'undefined') { - NotifPrefs._loadAdminSmtp(notifCfg); - NotifPrefs._initAdminSmtp(); - } - - if (vaultEl) { - try { - const vault = await API.adminGetVaultStatus(); - const keyBadge = vault.encryption_key_set - ? 'Active' - : 'Not Set'; - vaultEl.innerHTML = ` -
-
- Encryption - ${keyBadge} -
-
- Encrypted Keys - ${vault.encrypted_keys} -
-
- Vault Users - ${vault.vault_users} -
-
`; - } catch (e) { - vaultEl.innerHTML = 'Could not load vault status'; - } - } - } catch (e) { console.debug('Failed to load admin settings:', e); } - }, - - updateBannerPreview() { - const prev = document.getElementById('bannerPreview'); - if (!prev) return; - const text = document.getElementById('adminBannerText').value || 'PREVIEW'; - const bg = document.getElementById('adminBannerBg').value; - const fg = document.getElementById('adminBannerFg').value; - prev.textContent = text; - prev.style.background = bg; - prev.style.color = fg; - }, - - // ── Admin Channels (v0.23.2) ────────────── - - async loadAdminChannels() { - const list = document.getElementById('adminArchivedList'); - const countEl = document.getElementById('adminArchivedCount'); - const modeEl = document.getElementById('adminRetentionMode'); - if (!list) return; - - try { - const resp = await API._get('/api/v1/admin/channels/archived'); - const channels = resp.channels || []; - if (countEl) countEl.textContent = channels.length; - if (modeEl) modeEl.textContent = resp.retention_mode || 'flexible'; - - if (channels.length === 0) { - list.innerHTML = '
No archived channels
'; - return; - } - - let html = '' + - '' + - ''; - for (const ch of channels) { - const age = _relativeTime(ch.updated_at); - html += ` - - - - - - - `; - } - html += '
TitleTypeOwnerMessagesArchived
${esc(ch.title || 'Untitled')}${esc(ch.type)}${esc(ch.owner_name)}${ch.message_count}${age}
'; - list.innerHTML = html; - } catch (e) { - list.innerHTML = `
Failed to load: ${esc(e.message)}
`; - } - }, - - async _purgeChannel(channelId) { - if (!await showConfirm('Permanently delete this archived channel and all its messages? This cannot be undone.')) return; - try { - await API._del(`/api/v1/admin/channels/${channelId}/purge`); - UI.toast('Channel purged', 'success'); - UI.loadAdminChannels(); // refresh list - } catch (e) { - UI.toast('Purge failed: ' + e.message, 'error'); - } - }, - -}); - -// ── Exports ───────────────────────────────── -sb.ns('ADMIN_SECTIONS', ADMIN_SECTIONS); -sb.ns('ADMIN_LABELS', ADMIN_LABELS); -sb.ns('ADMIN_LOADERS', ADMIN_LOADERS); diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js deleted file mode 100644 index 7b4d077..0000000 --- a/src/js/ui-settings.js +++ /dev/null @@ -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 = '
You are not a member of any teams.
'; - return; - } - el.innerHTML = teams.map(t => ` -
-
-

${esc(t.name)}

- ${t.my_role} -
- ${t.description ? `

${esc(t.description)}

` : ''} -
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
-
- `).join(''); - } catch (e) { - el.innerHTML = '
Failed to load teams.
'; - } - }, - - 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 => ` -
-
- ${esc(t.name)} - ${t.my_role} - ${t.my_role === 'admin' ? 'Manage →' : ''} -
- ${t.description ? `
${esc(t.description)}
` : ''} -
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
-
- `).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 = ` ${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 = ''; } - - // 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 = '
Loading...
'; - try { - const resp = await API.teamListMembers(teamId); - const members = resp.data || []; - if (!members.length) { el.innerHTML = '
No members
'; return; } - el.innerHTML = ` - - - ${members.map(m => ` - - - - `).join('')} -
MemberRole
-
${esc(m.display_name || m.email)}
- ${m.user_role === 'admin' ? 'sys-admin' : ''} -
- - - -
- `; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - 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 = '
Loading...
'; - try { - const resp = await API.teamListPersonas(teamId); - const personas = resp.data || []; - el.innerHTML = personas.map(p => { - const icon = p.icon || ''; - return `
-
- ${icon ? icon + ' ' : ''}${esc(p.name)} -
${esc(p.base_model_id)}
-
- -
`; - }).join('') || '
No team personas — create one to give your team preconfigured models
'; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - async loadTeamManageGroups(teamId) { - const el = document.getElementById('settingsTeamGroups'); - if (!el) return; - el.innerHTML = '
Loading...
'; - try { - const resp = await API.teamListGroups(teamId); - const groups = resp.data || []; - if (!groups.length) { el.innerHTML = '
No groups assigned to this team yet
'; return; } - el.innerHTML = ` - - - ${groups.map(g => ` - - - `).join('')} -
GroupMembers
${esc(g.name)}
${esc(g.description || 'No description')}
${g.member_count} member${g.member_count !== 1 ? 's' : ''}
`; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - 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 = '

BYOK (Bring Your Own Key) must be enabled by your admin before you can configure model role overrides.

'; - 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 = '

Add a personal provider in My Providers first to configure model role overrides.

'; - notice.style.display = ''; - } - return; - } - el.style.display = ''; - if (notice) notice.style.display = 'none'; - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - 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 = '
Loading...
'; - - // 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 = '
No activity recorded for this team
'; - } 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 `
-
- ${esc(e.action)} - ${esc(actor)} - ${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''} -
-
- ${meta ? `${meta}` : ''} - ${timeStr} -
-
`; - }).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 = `
${esc(e.message)}
`; } - }, - - async loadTeamPersonaModelDropdown(teamId) { - const sel = document.getElementById('teamPersona_model'); - if (!sel) return; - sel.innerHTML = ''; - try { - const resp = await API.teamListAvailableModels(teamId); - const models = resp.models || []; - sel.innerHTML = ''; - - // 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 = ''; - 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 = '
Loading...
'; - }, - - 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 = '
Loading models...
'; - 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 = '
No models available
'; - 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 = `
- ${visibleCount} visible, ${hiddenCount} hidden of ${models.length} total -
- - -
`; - - 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' ? 'personal' - : m.source === 'team' ? `👥 ${esc(m.team_name || 'team')}` : ''; - const hidden = App.hiddenModels.has(compositeKey); - const toggleCls = hidden ? '' : 'enabled'; - const toggleLabel = hidden ? 'Hidden' : '✓ Visible'; - return `
- ${esc(mid)} - ${badges} - ${esc(m.provider_name || m.provider || '')} - ${src} - -
`; - }).join(''); - el.innerHTML = html; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - - 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 = '
No personal personas yet
'; - return; - } - el.innerHTML = personas.map(p => { - const avatarEl = p.avatar ? `` : ''; - return `
-
- ${avatarEl}${esc(p.name)} -
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
-
-
- -
-
`; - }).join(''); - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } - }, - -}); - diff --git a/src/js/workflow-admin.js b/src/js/workflow-admin.js deleted file mode 100644 index 3565e4f..0000000 --- a/src/js/workflow-admin.js +++ /dev/null @@ -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 = - '
' + - ''; - - // ── 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 = ''; - _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 = ''; - _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 = - '
No workflows yet
' + - ''; - return; - } - - let html = '
' + - '' + wfs.length + ' workflow(s)' + - '
' + - '' + - '' + - '
'; - html += '' + - '' + - ''; - for (const wf of wfs) { - html += '' + - '' + - '' + - '' + - '' + - '' + - ''; - } - html += '
NameSlugEntryActiveVersion
' + esc(wf.name) + '' + esc(wf.slug) + '' + esc(wf.entry_mode || 'authenticated') + '' + (wf.is_active ? '✓' : '—') + 'v' + (wf.version || 1) + '
'; - list.innerHTML = html; - } catch (e) { - list.innerHTML = '
Failed to load: ' + esc(e.message) + '
'; - } - }); - - // ── 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 = '
No stages — add one below
'; - 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 ? '' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '' : - 'persona'; - } - var modeLabel = ''; - if (s.stage_mode === 'review') { - modeLabel = 'review'; - } else if (s.stage_mode && s.stage_mode !== 'chat_only') { - modeLabel = '' + esc(s.stage_mode.replace('_', ' ')) + ''; - } - var surfaceLabel = ''; - if (s.surface_pkg_id) { - surfaceLabel = 'pkg: ' + esc(s.surface_pkg_id) + ''; - } - html += '
' + - '' + - '' + i + '' + - '' + esc(s.name) + '' + - personaLabel + modeLabel + surfaceLabel + - '' + (s.history_mode || 'full') + '' + - '' + - '' + - '
'; - }); - el.innerHTML = html; - - // v0.27.0: Wire DnD reorder on stage rows - _wfWireStageDnD(el, wfId); - } catch (e) { - el.innerHTML = '
Failed: ' + esc(e.message) + '
'; - } - } - - // ── 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 = - '' + - '' + - '' + - '' + - '' + - ''; - - // 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 = ''; - 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 = ''; - 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'; - } - }; - } - } - diff --git a/src/js/workflow-team-admin.js b/src/js/workflow-team-admin.js deleted file mode 100644 index 30b4adc..0000000 --- a/src/js/workflow-team-admin.js +++ /dev/null @@ -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 = - '
' + - ''; - - // ── 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 = ''; - _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 = - '
No workflows for this team
' + - ''; - return; - } - - var html = '
' + - '' + wfs.length + ' workflow(s)' + - '' + - '
'; - html += '' + - '' + - ''; - for (var i = 0; i < wfs.length; i++) { - var wf = wfs[i]; - html += '' + - '' + - '' + - '' + - '' + - '' + - ''; - } - html += '
NameSlugEntryActiveVersion
' + esc(wf.name) + '' + esc(wf.slug) + '' + esc(wf.entry_mode || 'team_only') + '' + (wf.is_active ? '✓' : '—') + 'v' + (wf.version || 1) + '
'; - list.innerHTML = html; - } catch (e) { - list.innerHTML = '
Failed to load: ' + esc(e.message) + '
'; - } - }; - - // ── 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 = '
No stages — add one below
'; - 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 ? '' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '' : - 'persona'; - } - var modeLabel = ''; - if (s.stage_mode === 'review') { - modeLabel = 'review'; - } else if (s.stage_mode && s.stage_mode !== 'chat_only') { - modeLabel = '' + esc(s.stage_mode.replace('_', ' ')) + ''; - } - html += '
' + - '' + - '' + i + '' + - '' + esc(s.name) + '' + - personaLabel + modeLabel + - '' + (s.history_mode || 'full') + '' + - '' + - '' + - '
'; - }); - el.innerHTML = html; - _twfWireStageDnD(el, wfId); - } catch (e) { - el.innerHTML = '
Failed: ' + esc(e.message) + '
'; - } - } - - // ── 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 = - '' + - '' + - '' + - '' + - ''; - - 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 = ''; - 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 = ''; - 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'; - } - }; - } - } - -})();