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 */}}
-
-
-
-
-
Username
-
Email
-
Password
-
Role User Admin
-
-
-
-
-
-{{/* Reset Password modal */}}
-
-
-
-
-
-
- Warning: This will destroy the user's personal vault. All personal API keys (BYOK) will be permanently deleted.
-
-
New Password
-
-
-
-
-
-{{/* Approve User modal */}}
-
-
-{{/* Create Team modal */}}
-
-
-{{/* Create Group modal */}}
-
-
-
-
-
Name
-
Description
-
-
Scope Global Team
-
Team
-
-
-
-
-
-
-{{/* 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 picker (multi-team admins) */}}
-
-
- {{/* Tabbed team management */}}
-
- {{/* Tab bar */}}
-
- Members
- Providers
- Personas
- Knowledge
- Groups
- Settings
- Usage
- Activity
-
-
- {{/* Tab content panels */}}
-
- {{/* Members */}}
-
-
- {{/* Providers */}}
-
-
- {{/* Personas */}}
-
-
-
-
-
-
+ New Persona
-
-
- {{/* Knowledge */}}
-
-
- {{/* Groups */}}
-
-
- {{/* Settings */}}
-
-
- {{/* Usage */}}
-
-
- {{/* Activity (Audit) */}}
-
-
-
-
-
-
+{{/* 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 = 'Assign to teams: ' +
- teams.map(t => ` ${esc(t.name)} `).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 = 'Select team... ' +
- teams.map(t => `${esc(t.name)} `).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 = `
-
- `;
-
- // 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.' +
- '
' +
- '
' +
- 'All ' +
- 'Surfaces ' +
- 'Extensions ' +
- 'Full ' +
- ' ' +
- 'Browse Registry ' +
- '
' +
- '
' +
- '' +
- '
' +
- '
' +
- '';
-
- 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 = '' +
- 'Package ' +
- 'Type ' +
- 'Version ' +
- 'Scope ' +
- 'Source ' +
- 'Status ' +
- 'Actions ' +
- ' ';
-
- 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 += 'Disable ';
- } else {
- actions += 'Enable ';
- }
- }
- // Settings (if manifest has settings)
- if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) {
- actions += 'Settings ';
- }
- // Export (non-core only)
- if (pkg.source !== 'core') {
- actions += 'Export ';
- }
- // Delete (non-core only)
- if (pkg.source !== 'core') {
- actions += 'Delete ';
- }
-
- var desc = pkg.description ? '' + pkg.description + '
' : '';
- var system = pkg.is_system ? ' system ' : '';
- var schemaVer = pkg.schema_version > 0 ? ' v' + pkg.schema_version + ' ' : '';
-
- html += '' +
- '' + pkg.title + ' ' + system + schemaVer + '' + pkg.id + '
' + desc + ' ' +
- '' + typeBadge + ' ' +
- '' + (pkg.version || '\u2014') + ' ' +
- '' + scopeBadge + ' ' +
- '' + sourceBadge + ' ' +
- '' + statusBadge + ' ' +
- '' + actions + ' ' +
- ' ';
- });
-
- html += '
';
- 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.
' +
- 'Close ';
- 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 += '' +
- '' + (s.label || key) + ' ';
-
- if (s.type === 'select' && s.options) {
- html += '';
- s.options.forEach(function(opt) {
- html += '' + opt + ' ';
- });
- html += ' ';
- } else if (s.type === 'boolean') {
- html += ' ';
- } else if (s.type === 'number') {
- html += ' ';
- } else {
- html += ' ';
- }
- html += '
';
- });
-
- html += '' +
- 'Save ' +
- 'Close ' +
- '
';
-
- 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 ' +
- ' ' +
- 'Close ' +
- '';
-
- 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 += '' +
- 'Package Version Author Actions ' +
- ' ';
-
- packages.forEach(function(pkg) {
- var installBtn = pkg.installed
- ? 'Installed '
- : 'Install ';
-
- html += '' +
- '' + pkg.title + ' ' +
- (pkg.description || '') + '
' +
- '' + (pkg.version || '') + ' ' +
- '' + (pkg.author || '') + ' ' +
- '' + installBtn + ' ' +
- ' ';
- });
- html += '
';
- }
-
- 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 =
- '
' +
- '' +
- '
' +
- '← Back ' +
- '
' +
- '' +
- '
' +
- 'Require private providers (BYOK) ' +
- 'Allow team providers ' +
- '
' +
- '
' +
- '
' +
- '
+ Add Member ' +
- '
';
-
- SCAFFOLDING.groups =
- '
' +
- '' +
- '
' +
- '← Back ' +
- '
' +
- ' ' +
- '' +
-
- /* ── Permissions (v0.24.2) ── */
- '
' +
- '
Permissions ' +
- '
' +
- '
' +
-
- /* ── Token Budgets ── */
- '
' +
- '
Token Budgets ' +
- '
' +
- '
' +
-
- /* ── Model Allowlist ── */
- '
' +
- '
Allowed Models ' +
- '
Leave empty for unrestricted access to all enabled models.
' +
- '
' +
- '
' +
-
- /* ── Save button ── */
- '
' +
- 'Save Permissions & Budgets ' +
- ' ' +
- '
' +
-
- '
' +
-
- /* ── Members (existing) ── */
- '
Members ' +
- '
' +
- '
' +
- '
+ Add Member ' +
- '
';
-
- // -- AI ---------------------------------------------------------------
-
- SCAFFOLDING.providers =
- '
';
-
- SCAFFOLDING.models =
- '' +
- 'Fetch Models ' +
- ' ' +
- '
' +
- '
';
-
- SCAFFOLDING.personas =
- '
';
-
- SCAFFOLDING.roles = '
';
- SCAFFOLDING.knowledgeBases = '
';
- SCAFFOLDING.memory = '
';
-
- // -- System -----------------------------------------------------------
-
- SCAFFOLDING.settings =
- '';
-
- SCAFFOLDING.storage = '
';
-
- SCAFFOLDING.packages =
- '
';
-
- // -- Routing ----------------------------------------------------------
-
- SCAFFOLDING.health =
- '
' +
- '
';
-
- SCAFFOLDING.routing =
- '+ New Policy
' +
- '
' +
- '
' +
- 'Test Routing ' +
- '
' +
- '
' +
- '
';
-
- SCAFFOLDING.capabilities =
- '';
-
- // -- Monitoring -------------------------------------------------------
-
- SCAFFOLDING.dashboard =
- '
' +
- '';
-
- SCAFFOLDING.usage =
- '
' +
- '
' +
- '
' +
- '
' +
- '
';
-
- SCAFFOLDING.audit =
- '' +
- '' +
- '';
-
- SCAFFOLDING.stats =
- '';
-
- SCAFFOLDING.channels =
- '' +
- '';
-
-
- // === 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).' +
- '
' +
- '
' +
- 'Title ' +
- ' ' +
- '
' +
- '
' +
- 'Message ' +
- '' +
- '
' +
- '
' +
- 'Level ' +
- '' +
- 'Info ' +
- 'Warning ' +
- 'Critical ' +
- ' ' +
- '
' +
- '
' +
- ' ' +
- ' Send Broadcast' +
- ' ' +
- '
' +
- '
';
-
- 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.
-
-
- Enter your password to confirm
-
-
-
- Delete My Account
- Cancel
-
-
`;
- document.getElementById('dpDeleteConfirmBtn')
- .addEventListener('click', _executeDelete);
- document.getElementById('dpDeleteCancelBtn')
- .addEventListener('click', () => { _renderDeleteButton(el); });
- }
-
- function _renderDeleteButton(el) {
- el.innerHTML = `
-
- Delete My Account
- `;
- 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.
-
-
Download My Data
-
-
-
-
Import Data
-
- Restore from a .switchboard archive exported from this or another
- instance. Existing items with the same ID are skipped (no duplicates).
-
-
-
- Import
-
-
-
-
-
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.
-
-
-
- Import
-
-
-
-
-
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 ' +
- '' +
- ' ' +
- ' Generate Key' +
- ' ' +
- '' +
- '
' +
- '
' +
- 'No SSH keys yet. Generate one to get started.' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
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 =
- '' +
- '
' +
- 'Key Name ' +
- ' ' +
- '
' +
- '
' +
- '
';
-
- 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 ? '
Copy Public Key ' : '') +
- '
Delete ' +
- '
';
- }).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
-
-
-
-
-
-
- `;
-
- // 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 `
- `;
- },
-
- _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 ? `
- ✓ Approve All
- ` : ''}
- ↻ Refresh
-
-
- ${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 `
- `;
- },
-
- // ═════════════════════════════════════════
- // 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 = `
-
-
-
-
- Enable memory for this persona
-
- When enabled, this persona can save and recall memories from conversations.
-
- Custom Extraction Prompt (optional)
-
-
- `;
-
- // 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 = `
-
- Notification Type
- In-App
- Email
-
- ${html}
-
`;
-
- // 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 = `
-
-
Knowledge Bases
-
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 `
-
-
- ${esc(kb.name)}
- ${kb.document_count} docs · ${kb.chunk_count} chunks
-
-
- Auto-inject
-
- `;
- }).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 = 'Loading... ';
- try {
- const resp = await API.teamListMembers(UI._managingTeamId);
- const existingIds = new Set((resp.data || []).map(m => m.user_id));
- sel.innerHTML = 'Enter user ID or ask sys-admin ';
- } catch (e) { sel.innerHTML = 'Error '; }
- });
- 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 = 'Select base model... ';
- 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`
-
- `;
-}
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.
+
+
+ ${exporting ? 'Preparing\u2026' : 'Export My Data'}
+
+
+
+
+
Danger Zone
+
+ Permanently delete your account and all associated data. This action cannot be undone.
+ We recommend exporting your data before proceeding.
+
+
+ ${deleting ? 'Deleting\u2026' : 'Delete My Account'}
+
+
+
+ `;
+}
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' : ''}
+
+
setShowAdd(!showAdd)}>
+ ${showAdd ? 'Cancel' : '+ Add Key'}
+
+
+
+ ${showAdd && html`
+
+ `}
+
+
+ ${keys.length === 0 && html`
No SSH credentials configured.
`}
+ ${keys.map(k => html`
+
+
+ ${k.name}
+ ${k.fingerprint && html`
+
+ ${k.fingerprint.substring(0, 24)}\u2026
+
+ `}
+
+
${fmtDate(k.created_at)}
+
deleteKey(k.id)}>Delete
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/settings/index.js b/src/js/sw/surfaces/settings/index.js
index 5e5466f..10496c8 100644
--- a/src/js/sw/surfaces/settings/index.js
+++ b/src/js/sw/surfaces/settings/index.js
@@ -7,39 +7,34 @@
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
*
* Layout: topbar + left nav + content area (same CSS classes as before).
- * Preact sections are loaded lazily. Bridge sections delegate to old JS.
+ * All sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
-import { BridgeSection } from './bridge-section.js';
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Lazy section imports ────────────────────
// Each returns a Promise<{ default: Component }> or { SectionName: Component }
const sectionModules = {
- general: () => import('./general.js'),
- appearance: () => import('./appearance.js'),
- profile: () => import('./profile.js'),
- teams: () => import('./teams.js'),
- models: () => import('./models.js'),
- providers: () => import('./providers.js'),
- personas: () => import('./personas.js'),
- roles: () => import('./roles.js'),
- usage: () => import('./usage.js'),
-};
-
-// ── Bridge section config (deferred to old JS) ──
-const bridgeSections = {
- workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() },
- tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() },
- gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() },
- data: { id: 'dpMount', loader: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy() },
- knowledge: { id: 'settingsDynamic', loader: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.() },
- memory: { id: 'settingsDynamic', loader: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.() },
- notifications: { id: 'settingsDynamic', loader: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.() },
+ general: () => import('./general.js'),
+ appearance: () => import('./appearance.js'),
+ profile: () => import('./profile.js'),
+ teams: () => import('./teams.js'),
+ models: () => import('./models.js'),
+ providers: () => import('./providers.js'),
+ personas: () => import('./personas.js'),
+ roles: () => import('./roles.js'),
+ usage: () => import('./usage.js'),
+ workflows: () => import('./workflows.js'),
+ tasks: () => import('./tasks.js'),
+ gitkeys: () => import('./gitkeys.js'),
+ data: () => import('./data.js'),
+ knowledge: () => import('./knowledge.js'),
+ memory: () => import('./memory.js'),
+ notifications: () => import('./notifications.js'),
};
// ── Nav structure ───────────────────────────
@@ -52,8 +47,11 @@ const NAV_ITEMS = [
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
- { key: 'gitkeys', label: 'Git Keys' },
- { key: 'data', label: 'Data & Privacy' },
+ { key: 'gitkeys', label: 'Git Keys' },
+ { key: 'knowledge', label: 'Knowledge Bases' },
+ { key: 'memory', label: 'Memory' },
+ { key: 'notifications', label: 'Notifications' },
+ { key: 'data', label: 'Data & Privacy' },
];
const BYOK_ITEMS = [
@@ -198,12 +196,9 @@ function SettingsSurface() {
${title}
- ${bridgeSections[section]
- ? html`<${BridgeSection} id=${bridgeSections[section].id}
- loader=${bridgeSections[section].loader} />`
- : SectionComponent
- ? html`<${SectionComponent} />`
- : html`
Loading\u2026
`
+ ${SectionComponent
+ ? html`<${SectionComponent} />`
+ : html`
Loading\u2026
`
}
diff --git a/src/js/sw/surfaces/settings/knowledge.js b/src/js/sw/surfaces/settings/knowledge.js
new file mode 100644
index 0000000..f0a0b9b
--- /dev/null
+++ b/src/js/sw/surfaces/settings/knowledge.js
@@ -0,0 +1,182 @@
+/**
+ * Settings > Knowledge — personal knowledge base management
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+}
+
+function formatBytes(b) {
+ if (!b) return '0 B';
+ if (b < 1024) return b + ' B';
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
+ return (b / 1048576).toFixed(1) + ' MB';
+}
+
+export default function KnowledgeSection() {
+ const [kbs, setKbs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null);
+ const [docs, setDocs] = useState([]);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.knowledge.list();
+ setKbs(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function openDetail(kb) {
+ setDetail(kb);
+ try {
+ const d = await sw.api.knowledge.documents(kb.id);
+ setDocs(Array.isArray(d) ? d : d.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function createKb(e) {
+ e.preventDefault();
+ const form = e.target;
+ const name = form.name.value.trim();
+ if (!name) { sw.toast('Name is required', 'error'); return; }
+ try {
+ await sw.api.knowledge.create({
+ name,
+ description: form.description.value.trim(),
+ });
+ sw.toast('Knowledge base created', 'success');
+ form.reset();
+ setShowCreate(false);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteKb(id) {
+ const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
+ if (!ok) return;
+ try {
+ await sw.api.knowledge.del(id);
+ sw.toast('Knowledge base deleted', 'success');
+ setDetail(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function uploadDoc() {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.txt,.md,.csv,.html,.pdf';
+ input.onchange = async () => {
+ const file = input.files[0];
+ if (!file) return;
+ try {
+ await sw.api.knowledge.upload(detail.id, file);
+ sw.toast('Document uploaded', 'success');
+ const d = await sw.api.knowledge.documents(detail.id);
+ setDocs(Array.isArray(d) ? d : d.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ };
+ input.click();
+ }
+
+ async function deleteDoc(docId) {
+ const ok = await sw.confirm('Delete this document?', true);
+ if (!ok) return;
+ try {
+ await sw.api.knowledge.delDoc(detail.id, docId);
+ sw.toast('Document deleted', 'success');
+ setDocs(prev => prev.filter(d => d.id !== docId));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function statusBadge(status) {
+ const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
+ return html`${status} `;
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ // Detail view
+ if (detail) return html`
+
+
+
{ setDetail(null); setDocs([]); }}>\u2190 Back
+
${detail.name}
+ ${statusBadge(detail.status || 'active')}
+
+
Upload Document
+
deleteKb(detail.id)}>Delete KB
+
+
+ ${detail.description && html`
+
${detail.description}
+ `}
+
+ ${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
+
+
+
+ ${docs.length === 0 && html`
No documents yet. Upload one to get started.
`}
+ ${docs.map(d => html`
+
+
+ ${d.filename}
+ ${formatBytes(d.size_bytes)}
+ ${' '}${statusBadge(d.status || 'pending')}
+
+
${fmtDate(d.created_at)}
+
deleteDoc(d.id)}>Delete
+
+ `)}
+
+
+ `;
+
+ // List view
+ return html`
+
+
+
${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}
+
+
setShowCreate(!showCreate)}>
+ ${showCreate ? 'Cancel' : '+ Create'}
+
+
+
+ ${showCreate && html`
+
+
+ Name
+
+
+
+ Description
+
+
+ Create Knowledge Base
+
+ `}
+
+
+ ${kbs.length === 0 && html`
No knowledge bases. Create one to get started.
`}
+ ${kbs.map(k => html`
+
openDetail(k)}>
+
+ ${k.name}
+ ${k.document_count || 0} docs
+
+
${fmtDate(k.created_at)}
+ ${statusBadge(k.status || 'active')}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/settings/memory.js b/src/js/sw/surfaces/settings/memory.js
new file mode 100644
index 0000000..eb15350
--- /dev/null
+++ b/src/js/sw/surfaces/settings/memory.js
@@ -0,0 +1,117 @@
+/**
+ * Settings > Memory — personal memory management
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+}
+
+function truncate(s, len) {
+ if (!s) return '';
+ return s.length > len ? s.substring(0, len) + '\u2026' : s;
+}
+
+export default function MemorySection() {
+ const [memories, setMemories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [count, setCount] = useState(0);
+ const [editingId, setEditingId] = useState(null);
+ const [editValue, setEditValue] = useState('');
+
+ const load = useCallback(async () => {
+ try {
+ const [data, c] = await Promise.all([
+ sw.api.memory.list(),
+ sw.api.memory.count().catch(() => null),
+ ]);
+ setMemories(Array.isArray(data) ? data : data.data || []);
+ if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ function startEdit(m) {
+ setEditingId(m.id);
+ setEditValue(m.value || m.content || '');
+ }
+
+ function cancelEdit() {
+ setEditingId(null);
+ setEditValue('');
+ }
+
+ async function saveEdit(id) {
+ try {
+ await sw.api.memory.update(id, { value: editValue, content: editValue });
+ sw.toast('Memory updated', 'success');
+ setEditingId(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteMemory(id) {
+ const ok = await sw.confirm('Delete this memory?', true);
+ if (!ok) return;
+ try {
+ await sw.api.memory.del(id);
+ sw.toast('Memory deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function statusBadge(status) {
+ if (!status) return null;
+ const cls = status === 'approved' ? 'badge-active'
+ : status === 'rejected' ? 'badge-inactive'
+ : 'badge';
+ return html`${status} `;
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ return html`
+
+
+
${count || memories.length} memor${count === 1 ? 'y' : 'ies'}
+
+
+
+
+ ${memories.length === 0 && html`
No memories stored.
`}
+ ${memories.map(m => html`
+
+ ${editingId === m.id ? html`
+
+
setEditValue(e.target.value)} />
+
+ saveEdit(m.id)}>Save
+ Cancel
+
+
+ ` : html`
+
+
+ ${m.key && html`${m.key} `}
+ ${truncate(m.value || m.content || '', 120)}
+
+ ${statusBadge(m.status)}
+
${fmtDate(m.created_at)}
+
+
+ startEdit(m)}>Edit
+ deleteMemory(m.id)}>Delete
+
+ `}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/settings/notifications.js b/src/js/sw/surfaces/settings/notifications.js
new file mode 100644
index 0000000..477bda8
--- /dev/null
+++ b/src/js/sw/surfaces/settings/notifications.js
@@ -0,0 +1,96 @@
+/**
+ * Settings > Notifications — notification preferences
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function NotificationsSection() {
+ const [prefs, setPrefs] = useState(null);
+ const [saving, setSaving] = useState({});
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.notifications.prefs();
+ // Normalize: could be an array of { type, enabled, ... } or an object keyed by type
+ if (Array.isArray(data)) {
+ const obj = {};
+ data.forEach(p => { obj[p.type] = p; });
+ setPrefs(obj);
+ } else {
+ setPrefs(data || {});
+ }
+ } catch (e) { sw.toast(e.message, 'error'); setPrefs({}); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function togglePref(type, currentEnabled) {
+ setSaving(prev => ({ ...prev, [type]: true }));
+ try {
+ await sw.api.notifications.setPref(type, { enabled: !currentEnabled });
+ setPrefs(prev => ({
+ ...prev,
+ [type]: { ...prev[type], enabled: !currentEnabled },
+ }));
+ sw.toast('Preference updated', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setSaving(prev => ({ ...prev, [type]: false })); }
+ }
+
+ async function resetPref(type) {
+ setSaving(prev => ({ ...prev, [type]: true }));
+ try {
+ await sw.api.notifications.delPref(type);
+ sw.toast('Reset to default', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setSaving(prev => ({ ...prev, [type]: false })); }
+ }
+
+ if (prefs === null) return html`Loading\u2026
`;
+
+ const types = Object.keys(prefs);
+
+ function friendlyName(type) {
+ return type
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, c => c.toUpperCase());
+ }
+
+ return html`
+
+ ${types.length === 0 && html`
No notification preferences available.
`}
+
+
+ ${types.map(type => {
+ const pref = prefs[type];
+ const enabled = pref.enabled !== false;
+ const isSaving = saving[type];
+ return html`
+
+
+
${friendlyName(type)}
+ ${pref.description && html`
+
${pref.description}
+ `}
+
+
+ togglePref(type, enabled)} />
+
+
+
resetPref(type)}
+ title="Reset to default">
+ Reset
+
+
+ `;
+ })}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/settings/tasks.js b/src/js/sw/surfaces/settings/tasks.js
new file mode 100644
index 0000000..b372a08
--- /dev/null
+++ b/src/js/sw/surfaces/settings/tasks.js
@@ -0,0 +1,71 @@
+/**
+ * Settings > Tasks — user's tasks with run/stop controls
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+}
+
+export default function TasksSection() {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.tasks.list();
+ setTasks(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function runTask(id) {
+ try {
+ await sw.api.tasks.start(id);
+ sw.toast('Task started', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function stopTask(id) {
+ try {
+ await sw.api.tasks.stop(id);
+ sw.toast('Task stopped', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ return 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 ? 'running' : 'stopped'}
+
+
+ runTask(t.id)}>Run
+ stopTask(t.id)}>Stop
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/settings/workflows.js b/src/js/sw/surfaces/settings/workflows.js
new file mode 100644
index 0000000..6bcba4b
--- /dev/null
+++ b/src/js/sw/surfaces/settings/workflows.js
@@ -0,0 +1,88 @@
+/**
+ * Settings > Workflows — read-only view of user's visible workflows
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function WorkflowsSection() {
+ const [workflows, setWorkflows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null);
+ const [stages, setStages] = useState([]);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.workflows.list();
+ setWorkflows(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function openDetail(wf) {
+ setDetail(wf);
+ try {
+ const d = await sw.api.workflows.get(wf.id);
+ setStages(d.stages || []);
+ } catch (e) { sw.toast(e.message, 'error'); setStages([]); }
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ if (detail) return html`
+
+
+ { setDetail(null); setStages([]); }}>\u2190 Back
+
${detail.name}
+
+ ${detail.is_active ? 'active' : 'inactive'}
+
+
+
+ ${detail.description && html`
+
${detail.description}
+ `}
+
+
+ Slug: /${detail.slug}
+ Entry: ${detail.entry_mode}
+ Version: ${detail.version || 0}
+
+
+
Stages (${stages.length})
+
+ ${stages.length === 0 && html`
No stages defined
`}
+ ${stages.map((s, i) => html`
+
+ #${i + 1}
+ ${s.name || `Stage ${i + 1}`}
+ ${s.stage_mode}
+ ${s.persona_id && html`persona `}
+
+ `)}
+
+
+ `;
+
+ return html`
+
+
+ ${workflows.length === 0 && html`
No workflows available.
`}
+ ${workflows.map(w => html`
+
openDetail(w)}>
+
+ ${w.name}
+ /${w.slug}
+
+
${w.entry_mode}
+
+ ${w.is_active ? 'active' : 'inactive'}
+
+
v${w.version || 0}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/team-admin/activity.js b/src/js/sw/surfaces/team-admin/activity.js
new file mode 100644
index 0000000..32f735a
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/activity.js
@@ -0,0 +1,88 @@
+/**
+ * Team Admin > Activity (Audit Log)
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function ActivitySection({ teamId }) {
+ const [entries, setEntries] = useState([]);
+ const [actions, setActions] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [filterAction, setFilterAction] = useState('');
+ const [filterResource, setFilterResource] = useState('');
+ const [loading, setLoading] = useState(true);
+ const perPage = 50;
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const opts = { page, per_page: perPage };
+ if (filterAction) opts.action = filterAction;
+ if (filterResource) opts.resource_type = filterResource;
+ const data = await sw.api.teams.audit(teamId, opts);
+ setEntries(data.data || []);
+ setTotal(data.total || 0);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [teamId, page, filterAction, filterResource]);
+
+ useEffect(() => { load(); }, [load]);
+
+ useEffect(() => {
+ sw.api.teams.auditActions(teamId).then(d => setActions(d.actions || [])).catch(() => {});
+ }, [teamId]);
+
+ const totalPages = Math.ceil(total / perPage) || 1;
+
+ function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+ }
+
+ // Extract unique resource types from actions (action format: resource.verb)
+ const resourceTypes = [...new Set(actions.map(a => a.split('.')[0]).filter(Boolean))];
+
+ return html`
+
+
+
+ ${loading
+ ? html`
Loading\u2026
`
+ : html`
+
+ ${entries.length === 0 && html`
No activity entries
`}
+ ${entries.map(e => html`
+
+ ${fmtDate(e.created_at)}
+ ${e.actor_name || '\u2014'}
+ ${e.action}
+ ${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}
+ ${e.ip_address || ''}
+
+ `)}
+
+
+
+ setPage(p => p - 1)}>\u2190 Prev
+ Page ${page} of ${totalPages} (${total} total)
+ = totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192
+
+ `
+ }
+
+ `;
+}
diff --git a/src/js/sw/surfaces/team-admin/groups.js b/src/js/sw/surfaces/team-admin/groups.js
new file mode 100644
index 0000000..558bab4
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/groups.js
@@ -0,0 +1,37 @@
+/**
+ * Team Admin > Groups
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function GroupsSection({ teamId }) {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.teams.groups(teamId);
+ setGroups(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [teamId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ if (loading) return html`Loading\u2026
`;
+
+ return html`
+
+
+ ${groups.length === 0 && html`
No groups
`}
+ ${groups.map(g => html`
+
+ ${g.name}
+ ${g.member_count || 0} members
+ ${(g.permissions || []).length} permissions
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/team-admin/index.js b/src/js/sw/surfaces/team-admin/index.js
new file mode 100644
index 0000000..db40287
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/index.js
@@ -0,0 +1,229 @@
+/**
+ * TeamAdminSurface — root team admin surface
+ *
+ * Reads globals:
+ * __SECTION__ — active section name (string)
+ * __BASE__ — base path
+ *
+ * Layout: topbar (back + team name) + sidebar nav + content area.
+ * All 10 sections are native Preact components loaded lazily.
+ */
+const { html } = window;
+const { useState, useEffect, useCallback, useMemo } = hooks;
+const { render } = preact;
+
+import { ToastContainer } from '../../primitives/toast.js';
+import { DialogStack } from '../../shell/dialog-stack.js';
+
+// ── Section config ──────────────────────────
+const SECTIONS = [
+ { key: 'members', label: 'Members' },
+ { key: 'providers', label: 'Providers' },
+ { key: 'personas', label: 'Personas' },
+ { key: 'knowledge', label: 'Knowledge' },
+ { key: 'groups', label: 'Groups' },
+ { key: 'workflows', label: 'Workflows' },
+ { key: 'tasks', label: 'Tasks' },
+ { key: 'settings', label: 'Settings' },
+ { key: 'usage', label: 'Usage' },
+ { key: 'activity', label: 'Activity' },
+];
+
+// ── Lazy section imports ────────────────────
+const sectionModules = {
+ members: () => import('./members.js'),
+ providers: () => import('./providers.js'),
+ personas: () => import('./personas.js'),
+ knowledge: () => import('./knowledge.js'),
+ groups: () => import('./groups.js'),
+ workflows: () => import('./workflows.js'),
+ tasks: () => import('./tasks.js'),
+ settings: () => import('./settings.js'),
+ usage: () => import('./usage.js'),
+ activity: () => import('./activity.js'),
+};
+
+function TeamAdminSurface() {
+ const BASE = window.__BASE__ || '';
+ const section = window.__SECTION__ || 'members';
+
+ const [SectionComponent, setSectionComponent] = useState(null);
+ const [selectedTeam, setSelectedTeam] = useState(null);
+
+ // Determine teams this user can admin
+ const adminTeams = useMemo(() => {
+ const teams = sw.auth?.teams || [];
+ if (sw.isAdmin) return teams;
+ return teams.filter(t => t.my_role === 'admin');
+ }, []);
+
+ // Auto-select if only one team
+ useEffect(() => {
+ if (adminTeams.length === 1 && !selectedTeam) {
+ setSelectedTeam(adminTeams[0]);
+ }
+ }, [adminTeams]);
+
+ // Load section component
+ useEffect(() => {
+ setSectionComponent(null);
+ const loader = sectionModules[section];
+ if (loader) {
+ loader().then(mod => {
+ const Comp = mod.default || Object.values(mod)[0];
+ setSectionComponent(() => Comp);
+ }).catch(e => console.error('[team-admin] Failed to load section:', section, e));
+ }
+ }, [section]);
+
+ // Back button with return URL stash
+ useEffect(() => {
+ const RETURN_KEY = 'sb_team_admin_return';
+ if (!sessionStorage.getItem(RETURN_KEY)) {
+ const ref = document.referrer;
+ if (ref && ref.startsWith(location.origin) && !ref.includes('/team-admin')) {
+ sessionStorage.setItem(RETURN_KEY, ref);
+ } else {
+ sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
+ }
+ }
+ }, []);
+
+ const backClick = useCallback((e) => {
+ e.preventDefault();
+ const RETURN_KEY = 'sb_team_admin_return';
+ const returnURL = sessionStorage.getItem(RETURN_KEY);
+ sessionStorage.removeItem(RETURN_KEY);
+ location.href = returnURL || BASE + '/';
+ }, []);
+
+ const navClick = useCallback((e) => {
+ e.preventDefault();
+ const href = e.currentTarget.getAttribute('href');
+ if (href) location.replace(href);
+ }, []);
+
+ const teamId = selectedTeam?.id || selectedTeam?.team_id || null;
+ const teamName = selectedTeam?.name || selectedTeam?.team_name || 'Team';
+ const sectionLabel = SECTIONS.find(s => s.key === section)?.label || 'Members';
+
+ // Team picker if multiple teams
+ if (!selectedTeam && adminTeams.length > 1) {
+ return html`
+
+
+
+
Select a Team
+
+ ${adminTeams.map(t => html`
+
setSelectedTeam(t)}>
+ ${t.name || t.team_name}
+ ${t.my_role || 'admin'}
+
+ `)}
+
+
+
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+ }
+
+ // No teams
+ if (!selectedTeam && adminTeams.length === 0) {
+ return html`
+
+
+
+
You are not an admin of any team.
+
+
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+ }
+
+ return html`
+
+ ${/* Top Bar */``}
+
+
+
+
+
+
+ Back
+
+
+
+ ${teamName}
+ ${adminTeams.length > 1 && html`
+ setSelectedTeam(null)}>Switch Team
+ `}
+
+
+
+
+ ${/* Left Nav */``}
+
+
+ ${/* Content */``}
+
+
+
+ ${SectionComponent && teamId
+ ? html`<${SectionComponent} teamId=${teamId} />`
+ : html`
Loading\u2026
`
+ }
+
+
+
+
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+}
+
+// ── Mount ────────────────────────────────────
+const mount = document.getElementById('team-admin-mount');
+if (mount) {
+ render(html`<${TeamAdminSurface} />`, mount);
+ console.log('[team-admin] Surface mounted');
+}
+
+export { TeamAdminSurface };
diff --git a/src/js/sw/surfaces/team-admin/knowledge.js b/src/js/sw/surfaces/team-admin/knowledge.js
new file mode 100644
index 0000000..0291604
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/knowledge.js
@@ -0,0 +1,44 @@
+/**
+ * Team Admin > Knowledge
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function KnowledgeSection({ teamId }) {
+ const [kbs, setKbs] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.teams.knowledgeBases(teamId);
+ setKbs(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [teamId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ return html`
+
+
+ ${kbs.length === 0 && html`
No knowledge bases
`}
+ ${kbs.map(k => html`
+
+
+ ${k.name}
+ ${k.document_count || 0} docs
+
+
${fmtDate(k.created_at)}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/team-admin/members.js b/src/js/sw/surfaces/team-admin/members.js
new file mode 100644
index 0000000..092e896
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/members.js
@@ -0,0 +1,114 @@
+/**
+ * Team Admin > Members
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function MembersSection({ teamId }) {
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showAdd, setShowAdd] = useState(false);
+ const [users, setUsers] = useState([]);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.teams.members(teamId);
+ setMembers(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [teamId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ async function openAdd() {
+ setShowAdd(true);
+ try {
+ const data = await sw.api.admin.users.list();
+ setUsers(Array.isArray(data) ? data : data.users || data.data || []);
+ } catch {
+ // non-admin may not have access — leave empty
+ setUsers([]);
+ }
+ }
+
+ async function addMember(e) {
+ e.preventDefault();
+ const form = e.target;
+ const userId = form.userId.value;
+ const role = form.role.value;
+ if (!userId) return;
+ try {
+ await sw.api.teams.addMember(teamId, userId, role);
+ sw.toast('Member added', 'success');
+ setShowAdd(false);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function updateRole(memberId, newRole) {
+ try {
+ await sw.api.teams.updateMember(teamId, memberId, newRole);
+ sw.toast('Role updated', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function removeMember(memberId) {
+ const ok = await sw.confirm('Remove this member from the team?', true);
+ if (!ok) return;
+ try {
+ await sw.api.teams.removeMember(teamId, memberId);
+ sw.toast('Member removed', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ return html`
+
+
+
+ ${showAdd && html`
+
+
+
+ Add
+ setShowAdd(false)}>Cancel
+
+
+ `}
+
+
+ ${members.length === 0 && html`
No members
`}
+ ${members.map(m => html`
+
+ ${m.username || m.user_id}
+ updateRole(m.id || m.user_id, e.target.value)}>
+ Member
+ Admin
+
+ removeMember(m.id || m.user_id)}>Remove
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/team-admin/personas.js b/src/js/sw/surfaces/team-admin/personas.js
new file mode 100644
index 0000000..db87dd1
--- /dev/null
+++ b/src/js/sw/surfaces/team-admin/personas.js
@@ -0,0 +1,106 @@
+/**
+ * Team Admin > Personas
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function PersonasSection({ teamId }) {
+ const [personas, setPersonas] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // null | 'new' | persona object
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.teams.personas(teamId);
+ setPersonas(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [teamId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ async function savePersona(e) {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.pname.value.trim(),
+ description: form.description.value.trim(),
+ system_prompt: form.system_prompt.value,
+ };
+ try {
+ if (editing === 'new') {
+ await sw.api.teams.createPersona(teamId, data);
+ sw.toast('Persona created', 'success');
+ } else {
+ await sw.api.teams.updatePersona(teamId, editing.id, data);
+ sw.toast('Persona updated', 'success');
+ }
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deletePersona(pid) {
+ const ok = await sw.confirm('Delete this persona?', true);
+ if (!ok) return;
+ try {
+ await sw.api.teams.deletePersona(teamId, pid);
+ sw.toast('Persona deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`Loading\u2026
`;
+
+ if (editing) {
+ const isNew = editing === 'new';
+ const p = isNew ? {} : editing;
+ return html`
+
+
+ setEditing(null)}>\u2190 Back
+
${isNew ? 'Create Persona' : `Edit: ${p.name}`}
+
+ Name
+
+
+ Description
+
+
+ System Prompt
+ ${p.system_prompt || ''}
+
+
+ ${isNew ? 'Create' : 'Save'}
+ setEditing(null)}>Cancel
+
+
+ `;
+ }
+
+ return html`
+
+
+
+
setEditing('new')}>+ Add
+
+
+
+ ${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
+
+ setEditing(p)}>Edit
+ deletePersona(p.id)}>Delete
+
+
+ `)}
+
+
+ `;
+}
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`
+
+
+
+
setEditing('new')}>+ Add
+
+
+ ${editing && html`
+
+ ${editing === 'new' ? 'Add Provider' : 'Edit Provider'}
+
+
+
+ ${editing === 'new' ? 'Create' : 'Save'}
+ setEditing(null)}>Cancel
+
+
+ `}
+
+
+ ${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)'}
+
+ setEditing(c)}>Edit
+ syncModels(c.id)}>Sync Models
+ deleteProvider(c.id)}>Delete
+
+
+ `)}
+
+
+ `;
+}
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`
+
+ `;
+}
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`
+
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+
+ Description
+
+ Create
+ setShowCreate(false)}>Cancel
+
+
+ `}
+
+
+ ${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'}
+
+ runTask(t.id)}>Run
+ killTask(t.id)}>Kill
+ deleteTask(t.id)}>Delete
+
+
+ `)}
+
+
+ `;
+}
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`
+
+
+
setEditing(null)}>\u2190 Back
+
Edit: ${editing.name}
+
+
publishWorkflow(editing.id)}>Publish
+
deleteWorkflow(editing.id)}>Delete
+
+
+ Description
+
+
+ Active
+
+
+ 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
`}
+
+
+
+ Save
+
+
+ `;
+
+ return html`
+
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+
+ Description
+
+ Create
+ setShowCreate(false)}>Cancel
+
+
+ `}
+
+
+ ${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 =
- '' +
- 'Tasks ' +
- 'Configuration ' +
- '+ System Task ' +
- '
' +
- '' +
- '
' +
- '
Scheduled Tasks ' +
- '0 ' +
- '' +
- '
' +
- '
' +
- '' +
- '
Task Configuration ' +
- '
' +
- '
' +
- '' +
- '
' +
- '← Back ' +
- '
' +
- 'Kill Active Run ' +
- '' +
- '
' +
- '
' +
- '' +
- '
Create System Task ' +
- '
' +
- 'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
- 'pure platform operations (cleanup, sweeps, compaction). Admin-only.
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
Create System Task ' +
- '
' +
- '
';
-
- 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 = '\ud83d\udd17 ';
- }
-
- return '' +
- '
' +
- '
' + escapeHtml(t.name) + '
' +
- '
' +
- escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
- '
' +
- '
' +
- '
' + type + funcBadge + scope + active + '
' +
- '
next: ' + nextRun + '
' +
- '
' +
- triggerBtn +
- '\u25b6 ' +
- '\ud83d\udccb ' +
- '\u2715 ' +
- '
' +
- '
';
- }).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 = '— select function — ';
- _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.
' +
- '
+ New Task ' +
- '
';
-
- 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 = '\ud83d\udd17 ';
- }
-
- return '' +
- '
' +
- '
' +
- '
' + esc(t.name) + '
' +
- '
' + esc(sched) + ' (' + esc(t.timezone) + ')' + lastStatus + '
' +
- '
next: ' + nextRun + '
' +
- '
' +
- '
' +
- teamBadge + status +
- triggerBtn +
- '\u25b6 ' +
- '' + (t.is_active ? '\u23f8' : '\u25b6') + ' ' +
- '\u2715 ' +
- '
' +
- '
' +
- '
';
- }).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 '' + esc(p.label) + ' ';
- }).join('');
-
- mount.innerHTML =
- '\u2190 Back
' +
- '' +
- '
' +
- '
Prompt ' +
- '' + esc(d.prompt || '') + '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- ' Webhook-triggered task
' +
- '
This task runs when an external service sends a POST request to its trigger URL. ' +
- 'The trigger URL will be shown after creation — copy it into your CI, cron, or another task\\u2019s webhook URL for task-to-task chaining.
' +
- '
' +
- '
' +
- '
' +
- 'Notify on complete ' +
- 'Notify on failure ' +
- '
' +
- '
Create Task ' +
- '
';
-
- 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.
' +
- '' +
- ' ' +
- 'Copy ' +
- '
' +
- '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 =>
- `${ADMIN_LABELS[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 `
-
- ${u.role}
- ${statusLabel}
- ${teams}
- ${lastSeen}
-
- ${isPending
- ? `✓ `
- : `⏸ `
- }
- ⇅
- 🔑
- 🗑
-
- `;
- }).join('');
-
- el.innerHTML = `
-
- User Role Status Teams Last Seen
- ${rows}
-
- `;
-
- // 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 `
`;
- }).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 = `
-
-
- Model
- Input $/M
- Output $/M
- Source
-
- ${entries.map(p => `
- ${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}
- `).join('')}
-
`;
- }
- } 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 = `
-
- Extension Status Tier Version
- ${exts.map(ext => {
- const statusBadge = ext.is_enabled ? 'enabled ' : 'disabled ';
- const systemBadge = ext.is_system ? ' system ' : '';
- return `
-
- ${esc(ext.name)}${systemBadge}
- ${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}
-
- ${statusBadge}
- ${esc(ext.tier)}
- v${esc(ext.version)}
-
- ✎
- ${ext.is_enabled ? '⏸' : '▶'}
- 🗑
-
- `;
- }).join('')}
-
- `;
- } 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 = `
-
- Action Actor Resource Details Time IP
- ${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 || '—'}
- ${timeStr}
- ${e.ip_address ? esc(e.ip_address) : '—'}
- `;
- }).join('')}
-
`;
- }
-
- // 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.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 = `
-
- Model Provider Capabilities Visibility
- ${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 `
- ${esc(m.model_id || m.id)}
- ${esc(m.provider_name || '')}
- ${badges}
- ${visLabel}
- `;
- }).join('')}
-
- `;
- } 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 = 'Select base model... ';
- 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 = `
-
- Persona Scope Model Status
- ${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 `
-
-
- ${avatarEl}
-
-
${esc(p.name)}
- ${p.description ? `
${esc(p.description)}
` : creator}
-
-
-
- ${scopeBadge}
- ${esc(p.base_model_id)}
- ${p.is_active ? 'active ' : 'inactive '}
-
- 🔒
- ✎
- ${p.is_active ? '⏸' : '▶'}
- 🗑
-
- `;
- }).join('')}
-
- `;
- } 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 = `
-
- Team Status Members
- ${teams.map(t => `
-
- ${esc(t.name)}
- ${esc(t.description || 'No description')}
-
-
- ${t.is_active ? 'active ' : 'inactive '}
-
- ${t.member_count} member${t.member_count !== 1 ? 's' : ''}
-
- 👥
- 📦
- ${t.is_active ? '⏸' : '▶'}
- 🗑
-
- `).join('')}
-
- `;
- } 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 = `
-
- Member Role
- ${members.map(m => `
-
- ${esc(m.display_name || m.email)}
- ${esc(m.email)}
-
-
-
- Member
- Team Admin
-
- ${m.user_role === 'admin' ? 'sys-admin ' : ''}
-
-
- 🗑
-
- `).join('')}
-
- `;
- } catch (e) { el.innerHTML = `${esc(e.message)}
`; }
- },
-
- async loadMemberUserDropdown(teamId) {
- const sel = document.getElementById('adminMemberUser');
- sel.innerHTML = 'Loading... ';
- 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 = 'Select user... ' +
- available.map(u => `${esc(u.username || u.email)} `).join('');
- } catch (e) { sel.innerHTML = `Error loading users `; }
- },
-
- // ── 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 = `
-
- Group Scope Members
- ${groups.map(g => {
- const scopeBadge = g.scope === 'global'
- ? 'global '
- : 'team ';
- return `
-
- ${esc(g.name)}
- ${esc(g.description || 'No description')}
-
- ${scopeBadge}
- ${g.member_count} member${g.member_count !== 1 ? 's' : ''}
-
- 👥
- 🗑
-
- `;
- }).join('')}
-
- `;
- } 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 => `
-
- ${esc(p)} \u2014 ${esc(permDesc[p] || 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 => `
-
- ${esc(m.display_name || m.model_id)} ${esc(m.provider_name || '')}
- `).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 = `
-
- Member Added
- ${members.map(m => `
-
- ${esc(m.display_name || m.username || m.email)}
- ${esc(m.email)}
-
- ${new Date(m.added_at).toLocaleDateString()}
-
- 🗑
-
- `).join('')}
-
- `;
- } catch (e) { el.innerHTML = `${esc(e.message)}
`; }
- },
-
- async loadGroupMemberDropdown(groupId) {
- const sel = document.getElementById('adminGroupMemberUser');
- sel.innerHTML = 'Loading... ';
- 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 = 'Select user... ' +
- available.map(u => `${esc(u.username || u.email)} `).join('');
- } catch (e) { sel.innerHTML = `Error loading users `; }
- },
-
- // ── 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 = ``;
-
- // 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 = `
-
- Provider Status Latency Uptime Requests Errors Last Error
- ${rows}
-
`;
- } 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 = `
-
- Policy Type Scope Priority Status
- ${policies.map(p => `
- ${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 '}
-
- ✎
- ${p.is_active ? '⏸' : '▶'}
- 🗑
-
- `).join('')}
-
- `;
- } 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
-
-
-
- Team ID
-
- Config (JSON)
- ${esc(JSON.stringify(cfg, null, 2))}
-
- Active
-
- ${isEdit ? 'Update' : 'Create'}
- Cancel
-
`;
-
- 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 = `
- Model Provider Config Field Value
- ${overrides.map(o => `
- ${esc(o.model_id)}
- ${esc(o.provider_config_id?.slice(0, 8) || 'any')}
- ${esc(o.field)}
- ${esc(String(o.value))}
- ×
- `).join('')}
-
`;
- } 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 = '— None (first visible) — ';
- 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 = '' +
- 'Title Type Owner Messages Archived ' +
- ' ';
- for (const ch of channels) {
- const age = _relativeTime(ch.updated_at);
- html += `
- ${esc(ch.title || 'Untitled')}
- ${esc(ch.type)}
- ${esc(ch.owner_name)}
- ${ch.message_count}
- ${age}
- Purge
- `;
- }
- html += '
';
- 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 = 'All actions '; }
-
- // 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 = `
-
- Member Role
- ${members.map(m => `
-
- ${esc(m.display_name || m.email)}
- ${m.user_role === 'admin' ? 'sys-admin ' : ''}
-
-
-
- Member
- Admin
-
-
-
- 🗑
-
- `).join('')}
-
- `;
- } 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 = `
-
- Group Members
- ${groups.map(g => `
- ${esc(g.name)}
${esc(g.description || 'No description')}
- ${g.member_count} member${g.member_count !== 1 ? 's' : ''}
- `).join('')}
-
`;
- } 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 = 'Loading models... ';
- try {
- const resp = await API.teamListAvailableModels(teamId);
- const models = resp.models || [];
- sel.innerHTML = 'Select base model... ';
-
- // 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 = 'Select base model... ';
- 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
-
-
Show All
-
Hide All
-
`;
-
- 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}
- ${toggleLabel}
-
`;
- }).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 =
- '
' +
- '' +
- '
' +
- '← Back ' +
- '
' +
- ' ' +
- ' ' +
- '' +
-
- '
' +
- '
' +
- '
' +
- '
' +
- 'Active ' +
- 'Save Changes ' +
- 'Publish ' +
- 'Export .pkg ' +
- 'Delete ' +
- '
' +
- '
' +
-
- '
Stages ' +
- '
' +
- '
+ Add Stage ' +
-
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
-
- '
' +
-
- '
' +
- '
' +
- 'Save Stage ' +
- 'Cancel ' +
- '
' +
- '
' +
-
- '
Public URL ' +
- '
' +
- '
';
-
- // ── 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 = 'None (no AI for this stage) ';
- _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 = '— Built-in (use stage mode) — ';
- _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
' +
- '+ Create Workflow ';
- return;
- }
-
- let html = '' +
- '
' + wfs.length + ' workflow(s) ' +
- '
' +
- 'Import .pkg ' +
- '+ New ' +
- '
';
- html += '' +
- 'Name Slug Entry Active Version ' +
- ' ';
- for (const wf of wfs) {
- html += '' +
- '' + esc(wf.name) + ' ' +
- '' + esc(wf.slug) + ' ' +
- '' + esc(wf.entry_mode || 'authenticated') + ' ' +
- '' + (wf.is_active ? '✓' : '—') + ' ' +
- 'v' + (wf.version || 1) + ' ' +
- ' ';
- }
- html += '
';
- 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') + ' ' +
- 'Edit ' +
- '× ' +
- '
';
- });
- 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 =
- '⠿ ' +
- ' ' +
- '' +
- FIELD_TYPES.map(function(t) {
- return '' + t + ' ';
- }).join('') +
- ' ' +
- ' ' +
- 'Req ' +
- '× ';
-
- // 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 =
- '
' +
- '' +
- '
' +
- '← Back ' +
- '
' +
- ' ' +
- ' ' +
- '' +
-
- '
' +
- '
' +
- '
' +
- '
' +
- 'Active ' +
- 'Save Changes ' +
- 'Publish ' +
- 'Delete ' +
- '
' +
- '
' +
-
- '
Stages ' +
- '
' +
- '
+ Add Stage ' +
-
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- 'Save Stage ' +
- 'Cancel ' +
- '
' +
- '
' +
-
- '
Public URL ' +
- '
' +
- '
';
-
- // ── 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 = 'None (no AI for this stage) ';
- _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
' +
- '+ Create Workflow ';
- return;
- }
-
- var html = '' +
- '' + wfs.length + ' workflow(s) ' +
- '+ New ' +
- '
';
- html += '' +
- 'Name Slug Entry Active Version ' +
- ' ';
- for (var i = 0; i < wfs.length; i++) {
- var wf = wfs[i];
- html += '' +
- '' + esc(wf.name) + ' ' +
- '' + esc(wf.slug) + ' ' +
- '' + esc(wf.entry_mode || 'team_only') + ' ' +
- '' + (wf.is_active ? '✓' : '—') + ' ' +
- 'v' + (wf.version || 1) + ' ' +
- ' ';
- }
- html += '
';
- 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') + ' ' +
- 'Edit ' +
- '× ' +
- '
';
- });
- 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 =
- ' ' +
- '' +
- FIELD_TYPES.map(function(t) {
- return '' + t + ' ';
- }).join('') +
- ' ' +
- ' ' +
- 'Req ' +
- '× ';
-
- 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';
- }
- };
- }
- }
-
-})();