Changeset 0.37.7 (#219)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 18:37:39 +00:00
committed by xcaliber
parent 5f1c733002
commit b6152fbf5e
50 changed files with 2105 additions and 8989 deletions

View File

@@ -1,5 +1,111 @@
# Changelog # 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 ## [0.37.5] — 2026-03-21
### Summary ### Summary

View File

@@ -1 +1 @@
0.37.6 0.37.7

View File

@@ -78,6 +78,7 @@ server {
} }
location /admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } 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 /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 /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; } location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

View File

@@ -119,11 +119,17 @@ type SettingsPageData struct {
// ── Loader registration ────────────────────── // ── Loader registration ──────────────────────
// TeamAdminPageData is what the team-admin surface receives.
type TeamAdminPageData struct {
Section string `json:"section"`
}
func (e *Engine) registerLoaders() { func (e *Engine) registerLoaders() {
e.RegisterLoader("admin", e.adminLoader) e.RegisterLoader("admin", e.adminLoader)
e.RegisterLoader("chat", e.chatLoader) e.RegisterLoader("chat", e.chatLoader)
e.RegisterLoader("notes", e.notesLoader) e.RegisterLoader("notes", e.notesLoader)
e.RegisterLoader("settings", e.settingsLoader) e.RegisterLoader("settings", e.settingsLoader)
e.RegisterLoader("team-admin", e.teamAdminLoader)
} }
// ListDataProviders returns the keys of all registered data providers. // 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 // v0.22.7: Reads feature gates from GlobalConfig to control
// which nav links/tabs are visible (BYOK, User Personas). // 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) { func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
section := c.Param("section") section := c.Param("section")
if section == "" { if section == "" {

View File

@@ -217,6 +217,12 @@ func (e *Engine) registerCoreSurfaces() {
DataRequires: []string{"settings"}, DataRequires: []string{"settings"},
Layout: "single", Source: "core", 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", ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session", Title: "Workflow", Template: "workflow", Auth: "session",
@@ -337,6 +343,9 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
if section == "" && surfaceID == "settings" { if section == "" && surfaceID == "settings" {
section = "general" section = "general"
} }
if section == "" && surfaceID == "team-admin" {
section = "members"
}
e.Render(c, "base.html", PageData{ e.Render(c, "base.html", PageData{
Surface: surfaceID, Surface: surfaceID,

View File

@@ -64,6 +64,7 @@
<div class="surface-inner" id="surfaceInner"> <div class="surface-inner" id="surfaceInner">
{{if eq .Surface "chat"}}{{template "surface-chat" .}} {{if eq .Surface "chat"}}{{template "surface-chat" .}}
{{else if eq .Surface "admin"}}{{template "surface-admin" .}} {{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 "notes"}}{{template "surface-notes" .}}
{{else if eq .Surface "settings"}}{{template "surface-settings" .}} {{else if eq .Surface "settings"}}{{template "surface-settings" .}}
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}} {{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 "chat"}}{{template "scripts-chat" .}}{{end}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{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 "notes"}}{{template "scripts-notes" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}} {{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}} {{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}

View File

@@ -10,135 +10,7 @@
<div class="settings-placeholder" style="padding:40px;text-align:center;">Loading admin&hellip;</div> <div class="settings-placeholder" style="padding:40px;text-align:center;">Loading admin&hellip;</div>
</div> </div>
{{/* Modals — kept outside mount for legacy compat during transition. */}} {{/* Legacy modals removed in v0.37.7 — all handled by Dialog primitives in Preact components */}}
{{/* These will be removed once all modal usage is confirmed replaced by Dialog primitives. */}}
{{/* Create User modal */}}
<div id="createUserModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create User</h2>
<button class="modal-close" onclick="sb.call('closeModal','createUserModal')">&#10005;</button>
</div>
<div class="modal-body">
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username" autocomplete="off"></div>
<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com" autocomplete="off"></div>
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars" autocomplete="new-password"></div>
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','createUserModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateUserBtn">Create User</button>
</div>
</div>
</div>
{{/* Reset Password modal */}}
<div id="resetPwModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Reset Password</h2>
<button class="modal-close" onclick="sb.call('closeModal','resetPwModal')">&#10005;</button>
</div>
<div class="modal-body">
<p id="resetPwUser" style="font-weight:600;margin-bottom:8px;"></p>
<div class="admin-warning" style="background:var(--danger-dim);border:1px solid var(--danger);border-radius:var(--radius);padding:10px 12px;font-size:12px;margin-bottom:12px;color:var(--danger);">
<strong>Warning:</strong> This will destroy the user's personal vault. All personal API keys (BYOK) will be permanently deleted.
</div>
<div class="form-group"><label>New Password</label><input type="password" id="resetPwInput" placeholder="min 8 chars" autocomplete="new-password"></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','resetPwModal')">Cancel</button>
<button class="btn-md btn-danger" id="resetPwConfirmBtn">Reset Password & Destroy Vault</button>
</div>
</div>
</div>
{{/* Approve User modal */}}
<div id="approveUserModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2 id="approveUserTitle">Approve User</h2>
<button class="modal-close" onclick="sb.call('closeModal','approveUserModal')">&#10005;</button>
</div>
<div class="modal-body">
<input type="hidden" id="approveUserId">
<div id="approveTeamsList"></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','approveUserModal')">Cancel</button>
<button class="btn-md btn-primary" id="approveUserSubmitBtn">Activate User</button>
</div>
</div>
</div>
{{/* Create Team modal */}}
<div id="createTeamModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create Team</h2>
<button class="modal-close" onclick="sb.call('closeModal','createTeamModal')">&#10005;</button>
</div>
<div class="modal-body">
<div class="form-group"><label>Name</label><input type="text" id="adminTeamName" placeholder="Team name"></div>
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Optional description"></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','createTeamModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateTeamBtn">Create Team</button>
</div>
</div>
</div>
{{/* Create Group modal */}}
<div id="createGroupModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create Group</h2>
<button class="modal-close" onclick="sb.call('closeModal','createGroupModal')">&#10005;</button>
</div>
<div class="modal-body">
<div class="form-group"><label>Name</label><input type="text" id="adminGroupName" placeholder="Group name"></div>
<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Optional description"></div>
<div style="display:flex;gap:12px;">
<div class="form-group" style="flex:1"><label>Scope</label><select id="adminGroupScope"><option value="global">Global</option><option value="team">Team</option></select></div>
<div class="form-group" id="adminGroupTeamRow" style="flex:1;display:none"><label>Team</label><select id="adminGroupTeam"></select></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','createGroupModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateGroupBtn">Create Group</button>
</div>
</div>
</div>
{{/* Provider form modal */}}
<div id="providerFormModal" class="modal-overlay">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 id="providerFormTitle">Add Provider</h2>
<button class="modal-close" onclick="sb.call('closeModal','providerFormModal')">&#10005;</button>
</div>
<div class="modal-body" id="adminAddProviderForm"></div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','providerFormModal')">Cancel</button>
</div>
</div>
</div>
{{/* Persona form modal */}}
<div id="personaFormModal" class="modal-overlay">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 id="personaFormTitle">Create Persona</h2>
<button class="modal-close" onclick="sb.call('closeModal','personaFormModal')">&#10005;</button>
</div>
<div class="modal-body" id="adminAddPersonaForm"></div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="sb.call('closeModal','personaFormModal')">Cancel</button>
</div>
</div>
</div>
{{end}} {{end}}
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}} {{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}

View File

@@ -354,112 +354,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* ── Hidden File Input ─────── */}} {{/* ── Hidden File Input ─────── */}}
<input type="file" id="fileInput" multiple style="display:none;"> <input type="file" id="fileInput" multiple style="display:none;">
{{/* ── Team Admin Modal ────────────────────── */}} {{/* Team Admin Modal — removed in v0.37.7. Team admin is now at /team-admin. */}}
<div id="teamAdminModal" class="modal-overlay">
<div class="modal" style="max-width:900px;height:80vh;display:flex;flex-direction:column;">
<div class="modal-header" style="flex-shrink:0;">
<h2 id="teamAdminTitle">Team Admin</h2>
<button id="teamAdminCloseBtn" class="modal-close"></button>
</div>
<div class="modal-body" style="display:flex;flex:1;min-height:0;padding:0;">
{{/* Team picker (multi-team admins) */}}
<div id="teamAdminPicker" style="padding:20px;flex:1;overflow-y:auto;">
<div id="teamAdminPickerList"></div>
</div>
{{/* Tabbed team management */}}
<div id="teamAdminContent" class="team-admin-content" style="display:none;">
{{/* Tab bar */}}
<div id="teamAdminTabs" style="display:flex;gap:2px;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto;">
<button class="admin-tab active" data-ttab="members" onclick="sb.call('UI.switchTeamTab','members')">Members</button>
<button class="admin-tab" data-ttab="providers" onclick="sb.call('UI.switchTeamTab','providers')">Providers</button>
<button class="admin-tab" data-ttab="personas" onclick="sb.call('UI.switchTeamTab','personas')">Personas</button>
<button class="admin-tab" data-ttab="knowledge" onclick="sb.call('UI.switchTeamTab','knowledge')">Knowledge</button>
<button class="admin-tab" data-ttab="groups" onclick="sb.call('UI.switchTeamTab','groups')">Groups</button>
<button class="admin-tab" data-ttab="settings" onclick="sb.call('UI.switchTeamTab','settings')">Settings</button>
<button class="admin-tab" data-ttab="usage" onclick="sb.call('UI.switchTeamTab','usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="sb.call('UI.switchTeamTab','activity')">Activity</button>
</div>
{{/* Tab content panels */}}
<div style="flex:1;overflow-y:auto;padding:16px;">
{{/* Members */}}
<div id="teamTabMembers" class="team-tab-content">
<div id="settingsTeamMembers"></div>
<div id="settingsTeamAddMember" class="admin-inline-form" style="display:none;margin-top:12px;">
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="form-group" style="flex:1"><label>User</label><select id="settingsTeamMemberUser"><option value="">Select user...</option></select></div>
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>
<button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button>
<button class="btn-small" onclick="document.getElementById('settingsTeamAddMember').style.display='none'">Cancel</button>
</div>
</div>
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:8px">+ Add Member</button>
</div>
{{/* Providers */}}
<div id="teamTabProviders" class="team-tab-content" style="display:none;">
<div id="settingsTeamProviders"></div>
<div id="settingsTeamProviderForm" style="display:none;margin-top:12px;"></div>
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:8px">+ Add Provider</button>
</div>
{{/* Personas */}}
<div id="teamTabPersonas" class="team-tab-content" style="display:none;">
<div id="teamPersonaList"></div>
<div id="settingsTeamAddPersona" style="display:none;margin-top:12px;"></div>
<div id="settingsTeamPersonas" style="display:none;"></div>
<div style="display:none;"><select id="teamPersona_model"></select></div>
<button class="btn-small" id="settingsTeamAddPersonaBtn" style="margin-top:8px">+ New Persona</button>
</div>
{{/* Knowledge */}}
<div id="teamTabKnowledge" class="team-tab-content" style="display:none;">
<div id="teamKBContent"></div>
</div>
{{/* Groups */}}
<div id="teamTabGroups" class="team-tab-content" style="display:none;">
<div id="settingsTeamGroups"></div>
</div>
{{/* Settings */}}
<div id="teamTabSettings" class="team-tab-content" style="display:none;">
<div style="max-width:500px;">
<div class="form-group" style="margin-bottom:12px;">
<label class="toggle-label"><input type="checkbox" id="teamSettingPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>
<div class="toggle-hint">Team members must use their own API keys</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label class="toggle-label"><input type="checkbox" id="teamSettingAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>
<div class="toggle-hint">Team admins can add shared provider configs</div>
</div>
<button class="btn-small btn-primary" id="teamSettingSaveBtn">Save Settings</button>
</div>
</div>
{{/* Usage */}}
<div id="teamTabUsage" class="team-tab-content" style="display:none;">
<div id="settingsTeamUsageTotals"></div>
</div>
{{/* Activity (Audit) */}}
<div id="teamTabActivity" class="team-tab-content" style="display:none;">
<div style="margin-bottom:8px;">
<select id="teamAuditFilterAction" style="font-size:12px;padding:4px 8px;"><option value="">All actions</option></select>
</div>
<div id="settingsTeamAudit"></div>
<div id="teamAuditPagination" style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<button id="teamAuditPrevBtn" class="btn-small" disabled>← Prev</button>
<span id="teamAuditPageInfo" class="text-muted" style="font-size:12px;"></span>
<button id="teamAuditNextBtn" class="btn-small">Next →</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{/* ── Debug Modal moved to base.html (available on all surfaces) ── */}} {{/* ── 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) */}} {{/* App JS — order matches index.html (minus api/events/ui-primitives/ui-format already in base) */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script> {{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/memory-ui.js?v={{$.Version}}"></script> {{/* memory-ui.js and persona-kb.js removed in v0.37.7 — no chat surface callers */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/persona-kb.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notification-prefs.js?v={{$.Version}}"></script> {{/* notification-prefs.js removed in v0.37.7 — now native Preact in settings */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script> {{/* settings-handlers.js, admin-handlers.js, workflow-admin.js removed in v0.37.7 */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/task-sidebar.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/task-sidebar.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script> <script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>

View File

@@ -1,7 +1,6 @@
{{/* {{/*
Settings surface — v0.37.5: Preact component tree. Settings surface — v0.37.7: Full Preact component tree.
Replaces server-rendered DOM with a mount point. All sections are native Preact components. No bridges.
Bridge sections (tasks, gitkeys, workflows, data) still load old JS.
*/}} */}}
{{define "surface-settings"}} {{define "surface-settings"}}
@@ -21,16 +20,7 @@
window.__SECTION__ = '{{.Section}}'; window.__SECTION__ = '{{.Section}}';
</script> </script>
{{/* Bridge section JS — deferred sections still use old imperative code */}} {{/* Bridge section scripts removed in v0.37.7 — all sections are native Preact */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/data-portability.js?v={{.Version}}"></script>
{{/* Vendor: Preact + htm → globals, then SDK boot, then settings surface */}} {{/* Vendor: Preact + htm → globals, then SDK boot, then settings surface */}}
<script type="module" nonce="{{.CSPNonce}}"> <script type="module" nonce="{{.CSPNonce}}">

View File

@@ -0,0 +1,34 @@
{{/*
Team Admin surface — v0.37.7: Native Preact component tree.
10 sections: members, providers, personas, knowledge, groups,
workflows, tasks, settings, usage, activity.
*/}}
{{define "surface-team-admin"}}
<div id="team-admin-mount" class="surface-team-admin" style="flex-direction:column;">
<div class="settings-placeholder" style="padding:40px;text-align:center;">Loading team admin&hellip;</div>
</div>
{{end}}
{{define "css-team-admin"}}{{end}}
{{define "scripts-team-admin"}}
<script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
</script>
<script type="module" nonce="{{.CSPNonce}}">
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
await boot();
await import('{{.BasePath}}/js/sw/surfaces/team-admin/index.js?v={{.Version}}');
</script>
{{end}}

View File

@@ -1,3 +1,3 @@
package main package main
const Version = "0.37.6" const Version = "0.37.7"

View File

@@ -1,917 +0,0 @@
// ==========================================
// Chat Switchboard Admin Handlers
// ==========================================
// Admin actions: user management, roles, model visibility,
// personas, team management, global providers.
// ── Admin Actions ────────────────────────────
function _adminScroll() { return document.getElementById('adminMain'); }
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
async function toggleUserActive(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function showApproveForm(userId, username) {
document.getElementById('approveUserId').value = userId;
document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
const teamsEl = document.getElementById('approveTeamsList');
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
openModal('approveUserModal');
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
if (teams.length === 0) {
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
} else {
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">Assign to teams:</label>' +
teams.map(t => `<label class="toggle-label" style="margin-bottom:6px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> <span>${esc(t.name)}</span></label>`).join('');
}
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
}
function hideApproveForm(userId) {
closeModal('approveUserModal');
}
async function submitApproval(userId) {
if (!userId) userId = document.getElementById('approveUserId').value;
const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(userId, true, teamIds, 'member');
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
UI.toast(msg, 'success');
closeModal('approveUserModal');
await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleUserRole(id, role) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUser(id, username) {
if (!await showConfirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function createAdminUser() {
try {
const u = document.getElementById('adminNewUsername').value.trim();
const e = document.getElementById('adminNewEmail').value.trim();
const p = document.getElementById('adminNewPassword').value;
const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r);
closeModal('createUserModal');
// Clear form fields
document.getElementById('adminNewUsername').value = '';
document.getElementById('adminNewEmail').value = '';
document.getElementById('adminNewPassword').value = '';
document.getElementById('adminNewRole').value = 'user';
UI.toast('User created', 'success');
await UI.loadAdminUsers();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function adminResetUserPassword(id, username) {
// Show the reset password modal
document.getElementById('resetPwUser').textContent = username;
document.getElementById('resetPwInput').value = '';
openModal('resetPwModal');
document.getElementById('resetPwInput').focus();
// Wire up the confirm button (remove old listener, add new)
const btn = document.getElementById('resetPwConfirmBtn');
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async () => {
const pw = document.getElementById('resetPwInput').value;
if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
closeModal('resetPwModal');
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
hint.textContent = 'Fetching...';
try {
const resp = await API.adminFetchModels();
const errs = resp.errors || [];
if (errs.length > 0) {
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
} else {
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
}
hint.textContent = '';
await UI.loadAdminModels();
}
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
async function cycleModelVisibility(id, current) {
// Cycle: disabled → enabled → team → disabled
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
// Send both for backward compat with pre-0.8.3 backends
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
await UI.loadAdminModels();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetVisibility(visibility) {
const hint = document.getElementById('adminModelsHint');
const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
try {
// Send both for backward compat with pre-0.8.3 backends
const resp = await API.adminBulkUpdateModels(visibility);
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
setTimeout(() => { hint.textContent = ''; }, 3000);
await UI.loadAdminModels();
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles — now managed by renderRoleConfig primitive in ui-admin.js ──
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId;
try {
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
else App.hiddenModels.add(compositeKey);
await UI.loadUserModels();
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetUserModelVisibility(show) {
const models = App.models.filter(m => !m.isPersona);
if (!models.length) return;
const shouldHide = !show;
// Only update models not already in the desired state
const toUpdate = models
.filter(m => {
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
return App.hiddenModels.has(compositeKey) !== shouldHide;
})
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(e => {
const key = (e.provider_config_id || '') + ':' + e.model_id;
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
});
await UI.loadUserModels();
await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUserPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.deleteUserPersona(id);
UI.toast('Persona deleted');
await UI.loadUserPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Personas ────────────────────────────
var _adminPersonaForm = null;
function ensureAdminPersonaForm() {
if (_adminPersonaForm) return _adminPersonaForm;
const container = document.getElementById('adminAddPersonaForm');
if (!container) return null;
_adminPersonaForm = renderPersonaForm(container, {
prefix: 'adminPersona',
showAvatar: true,
showProviderConfig: true,
showKBPicker: true,
kbScope: 'admin',
onSubmit: (vals) => createAdminPersona(vals),
onCancel: () => {
closeModal('personaFormModal');
_editingPersonaId = null;
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
});
// Override avatar handlers for edit-mode (upload immediately for existing personas)
const fileInput = document.getElementById('adminPersona_avatarFileInput');
if (fileInput) {
// Remove default handler set by renderPersonaForm, add edit-aware one
const newInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newInput, fileInput);
newInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
if (_editingPersonaId) {
try {
const data = await API.adminUploadPersonaAvatar(_editingPersonaId, reader.result);
if (data.avatar) {
_adminPersonaForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Persona avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
} else {
_adminPersonaForm.updateAvatarPreview(reader.result);
}
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('adminPersona_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
}
const removeBtn = document.getElementById('adminPersona_avatarRemoveBtn');
if (removeBtn) {
const newBtn = removeBtn.cloneNode(true);
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
newBtn.addEventListener('click', async () => {
if (_editingPersonaId) {
try {
await API.adminDeletePersonaAvatar(_editingPersonaId);
_adminPersonaForm.updateAvatarPreview(null);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Persona avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
} else {
_adminPersonaForm.updateAvatarPreview(null);
}
});
}
return _adminPersonaForm;
}
var _editingPersonaId = null;
function editAdminPersona(id) {
const p = UI._personaCache?.[id];
if (!p) return;
_editingPersonaId = id;
ensureAdminPersonaForm();
const form = _adminPersonaForm;
if (!form) return;
document.getElementById('personaFormTitle').textContent = 'Edit Persona';
openModal('personaFormModal');
form.setValues(p);
form.setSubmitLabel('Update');
// Load KB bindings (v0.17.0)
if (form.kbPicker) {
loadPersonaKBs(id, form.kbPicker, '/api/v1/admin');
}
}
async function createAdminPersona(vals) {
if (!vals) {
if (_adminPersonaForm) vals = _adminPersonaForm.getValues();
else return;
}
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
const persona = {
name: vals.name,
base_model_id: vals.base_model_id,
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
if (vals.provider_config_id) persona.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) persona.temperature = vals.temperature;
if (vals.max_tokens != null) persona.max_tokens = vals.max_tokens;
try {
let personaId = _editingPersonaId;
if (_editingPersonaId) {
await API.adminUpdatePersona(_editingPersonaId, persona);
UI.toast('Persona updated', 'success');
} else {
const result = await API.adminCreatePersona(persona);
personaId = result?.id || result?.persona?.id;
UI.toast('Persona created', 'success');
}
// Upload pending avatar for new personas
if (!_editingPersonaId && personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('Persona avatar upload failed:', e.message); }
}
// Save KB bindings (v0.17.0)
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
try { await API.adminSetPersonaKBs(personaId, vals._kbValues); }
catch (e) { console.warn('Persona KB binding failed:', e.message); }
}
_editingPersonaId = null;
closeModal('personaFormModal');
if (_adminPersonaForm) {
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleAdminPersona(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdatePersona(id, { is_active: active });
await UI.loadAdminPersonas(true);
_restoreScroll(el, pos);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.adminDeletePersona(id);
UI.toast('Persona deleted', 'success');
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Admin Functions ────────────────────
async function toggleTeamActive(id, active) {
try {
await API.adminUpdateTeam(id, { is_active: active });
UI.toast(active ? 'Team activated' : 'Team deactivated');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteTeam(id, name) {
if (!await showConfirm(`Delete team "${name}"? All members will be removed.`)) return;
try {
await API.adminDeleteTeam(id);
UI.toast('Team deleted');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function updateTeamMember(teamId, memberId, role) {
try {
await API.adminUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamMembers(teamId);
}
}
async function removeTeamMember(teamId, memberId, email) {
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.adminRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
// Settings-side team management (uses team admin API, not sys-admin)
async function settingsUpdateTeamMember(teamId, memberId, role) {
try {
await API.teamUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamManageMembers(teamId);
}
}
async function settingsRemoveTeamMember(teamId, memberId, email) {
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.teamRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamManageMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamPersona(teamId, personaId, name) {
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
try {
await API.teamDeletePersona(teamId, personaId);
UI.toast('Persona deleted');
await UI.loadTeamManagePersonas(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
// ── Group Actions ───────────────────────────
async function deleteGroup(id, name) {
if (!await showConfirm(`Delete group "${name}"? Members will lose access to any resources granted via this group.`)) return;
try { await API.adminDeleteGroup(id); UI.toast('Group deleted'); await UI.loadAdminGroups(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function removeGroupMember(groupId, userId, label) {
if (!await showConfirm(`Remove ${label} from group?`)) return;
try {
await API.adminRemoveGroupMember(groupId, userId);
UI.toast('Member removed');
await UI.loadGroupMembers(groupId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function saveGrant(resourceType, resourceId) {
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
if (!scopeSel) return;
const scope = scopeSel.value;
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
const selectedGroups = [...(groupsDiv?.querySelectorAll('.grant-group-cb:checked') || [])].map(cb => cb.value);
if (scope === 'groups' && selectedGroups.length === 0) {
return UI.toast('Select at least one group', 'error');
}
try {
await API.adminSetGrant(resourceType, resourceId, scope, selectedGroups);
// Remove the picker
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
UI.toast(scope === 'team_only' ? 'Access reverted to team only' : `Access set to ${scope}`);
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Listeners (extracted from initListeners) ──
function _initAdminListeners() {
// Back button and category tab navigation are handled by the inline
// script in admin.html (sessionStorage return URL + location.replace).
// Only wire up section-specific action handlers here.
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
openModal('createUserModal');
});
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — approve user modal
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
// Admin — providers (form managed by primitives in loadAdminProviders)
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
});
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — personas (shared form)
document.getElementById('adminAddPersonaBtn')?.addEventListener('click', () => {
const form = ensureAdminPersonaForm();
if (!form) return;
_editingPersonaId = null;
form.setSubmitLabel('Create');
form.clearForm();
document.getElementById('personaFormTitle').textContent = 'Create Persona';
openModal('personaFormModal');
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
openModal('createTeamModal');
document.getElementById('adminTeamName').focus();
});
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
closeModal('createTeamModal');
});
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim();
const desc = document.getElementById('adminTeamDesc').value.trim();
if (!name) return UI.toast('Team name required', 'error');
try {
await API.adminCreateTeam(name, desc);
closeModal('createTeamModal');
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
document.getElementById('adminTeamDetail').style.display = 'none';
document.getElementById('adminTeamList').style.display = '';
UI.loadAdminTeams(true);
});
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddMemberForm').style.display = '';
await UI.loadMemberUserDropdown(UI._teamEditId);
});
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ require_private_providers: this.checked })
});
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ allow_team_providers: this.checked })
});
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminMemberUser').value;
const role = document.getElementById('adminMemberRole').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddMember(UI._teamEditId, userId, role);
document.getElementById('adminAddMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadTeamMembers(UI._teamEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Group management ────────────────────────
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
openModal('createGroupModal');
document.getElementById('adminGroupName').focus();
// Populate team dropdown
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
const sel = document.getElementById('adminGroupTeam');
sel.innerHTML = '<option value="">Select team...</option>' +
teams.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
} catch (e) { /* proceed without teams */ }
});
document.getElementById('adminGroupScope')?.addEventListener('change', function() {
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
});
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
closeModal('createGroupModal');
});
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminGroupName').value.trim();
const desc = document.getElementById('adminGroupDesc').value.trim();
const scope = document.getElementById('adminGroupScope').value;
const teamId = scope === 'team' ? document.getElementById('adminGroupTeam').value : null;
if (!name) return UI.toast('Group name required', 'error');
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
try {
await API.adminCreateGroup(name, desc, scope, teamId);
closeModal('createGroupModal');
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
UI.toast('Group created');
await UI.loadAdminGroups(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminGroupBackBtn')?.addEventListener('click', () => {
document.getElementById('adminGroupDetail').style.display = 'none';
document.getElementById('adminGroupList').style.display = '';
UI.loadAdminGroups(true);
});
document.getElementById('adminAddGroupMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupMemberForm').style.display = '';
await UI.loadGroupMemberDropdown(UI._groupEditId);
});
document.getElementById('adminCancelGroupMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
});
document.getElementById('adminAddGroupMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminGroupMemberUser').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddGroupMember(UI._groupEditId, userId);
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadGroupMembers(UI._groupEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Extension management ────────────────────
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
document.getElementById('adminInstallExtForm').style.display = '';
});
document.getElementById('adminInstallExtSubmit')?.addEventListener('click', async () => {
const extId = document.getElementById('extInstallId').value.trim();
const name = document.getElementById('extInstallName').value.trim();
if (!extId || !name) return UI.toast('ID and Name are required', 'error');
// Build manifest: merge user-provided JSON with _script
let manifest = {};
const manifestRaw = document.getElementById('extInstallManifest').value.trim();
if (manifestRaw) {
try { manifest = JSON.parse(manifestRaw); }
catch { return UI.toast('Invalid manifest JSON', 'error'); }
}
const script = document.getElementById('extInstallScript').value;
if (script) manifest._script = script;
try {
await API._post('/api/v1/admin/extensions', {
ext_id: extId,
name: name,
version: document.getElementById('extInstallVersion').value.trim() || '1.0.0',
author: document.getElementById('extInstallAuthor').value.trim(),
description: document.getElementById('extInstallDesc').value.trim(),
manifest: manifest,
is_system: document.getElementById('extInstallSystem').checked,
is_enabled: document.getElementById('extInstallEnabled').checked,
});
document.getElementById('adminInstallExtForm').style.display = 'none';
// Clear form
['extInstallId','extInstallName','extInstallVersion','extInstallAuthor','extInstallDesc','extInstallManifest','extInstallScript'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = el.type === 'checkbox' ? '' : (el.tagName === 'TEXTAREA' ? '' : '');
});
document.getElementById('extInstallVersion').value = '1.0.0';
UI.toast('Extension installed');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
if (typeof sw !== 'undefined') {
sw.on('role.fallback', (payload) => {
const banner = document.getElementById('roleFallbackBanner');
if (!banner) return;
const msg = payload?.message || 'A model role is using its fallback provider.';
banner.querySelector('.fallback-msg').textContent = msg;
banner.style.display = '';
// Auto-dismiss after 60s
clearTimeout(banner._dismissTimer);
banner._dismissTimer = setTimeout(() => { banner.style.display = 'none'; }, 60000);
});
}
}
// ── Extension admin actions (global scope for onclick) ──
async function toggleAdminExtension(id, enabled) {
try {
await API._put(`/api/v1/admin/extensions/${id}`, { is_enabled: enabled });
UI.toast(enabled ? 'Extension enabled' : 'Extension disabled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminExtension(id, name) {
if (!await showConfirm(`Uninstall extension "${name}"? This cannot be undone.`, { danger: true })) return;
try {
await API._del(`/api/v1/admin/extensions/${id}`);
UI.toast('Extension uninstalled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
// Track CM6 editor instances per extension edit form
var _extEditors = {};
// Listen for theme changes to update CM6 editors
if (typeof sw !== 'undefined') {
sw.on('theme.changed', (data) => {
const isDark = data.resolved === 'dark';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setDarkMode(isDark);
editors.script?.setDarkMode(isDark);
}
});
sw.on('keymap.changed', (data) => {
const mode = data.mode || 'standard';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setKeymap(mode);
editors.script?.setKeymap(mode);
}
});
}
function editAdminExtension(id) {
// Close any existing edit form (and destroy CM6 instances)
document.querySelectorAll('.ext-edit-form').forEach(el => {
const eid = el.dataset.extId;
if (_extEditors[eid]) {
_extEditors[eid].manifest?.destroy();
_extEditors[eid].script?.destroy();
delete _extEditors[eid];
}
el.remove();
});
const ext = (UI._adminExtensions || []).find(e => e.id === id);
if (!ext) return UI.toast('Extension not found', 'error');
// Extract manifest and _script separately
let manifest = {};
try { manifest = typeof ext.manifest === 'string' ? JSON.parse(ext.manifest) : (ext.manifest || {}); }
catch { manifest = {}; }
const script = manifest._script || '';
// Show manifest without _script for cleaner editing
const manifestClean = { ...manifest };
delete manifestClean._script;
const manifestJSON = JSON.stringify(manifestClean, null, 2);
const systemWarning = ext.is_system
? `<div style="background:var(--bg-3);border:1px solid var(--warning,#f39c12);border-radius:6px;padding:8px 12px;font-size:12px;margin-bottom:8px;color:var(--warning,#f39c12)">
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
</div>`
: '';
// Use container divs instead of textareas when CM6 is available
const useCM6 = !!window.CM?.codeEditor;
const manifestEditor = useCM6
? `<div id="extEdit-manifest-${id}" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>`;
const scriptEditor = useCM6
? `<div id="extEdit-script-${id}" style="width:100%;min-height:320px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>`;
const formHTML = `
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
${systemWarning}
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Name</label>
<input type="text" id="extEdit-name-${id}" value="${esc(ext.name)}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Description</label>
<input type="text" id="extEdit-desc-${id}" value="${esc(ext.description || '')}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
${manifestEditor}
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
${scriptEditor}
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" data-action="_closeExtEditForm" data-args='${JSON.stringify([id])}'">Cancel</button>
<button class="btn-small btn-primary" data-action="saveAdminExtension" data-args='${JSON.stringify([id])}'">Save Changes</button>
</div>
</div>
`;
// Find the extension row and insert the form after it
const rows = document.querySelectorAll('#adminExtensionsList tbody tr');
for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);
if (useCM6) {
// Get user keybinding preference
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const keymapMode = prefs.editorKeymap || 'standard';
// Initialize CM6 editors
_extEditors[id] = {
manifest: CM.codeEditor(document.getElementById(`extEdit-manifest-${id}`), {
language: 'json',
value: manifestJSON,
lineNumbers: true,
keymap: keymapMode,
}),
script: CM.codeEditor(document.getElementById(`extEdit-script-${id}`), {
language: 'javascript',
value: script,
lineNumbers: true,
keymap: keymapMode,
}),
};
} else {
// Fallback: Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
}
}
break;
}
}
}
function _closeExtEditForm(id) {
if (_extEditors[id]) {
_extEditors[id].manifest?.destroy();
_extEditors[id].script?.destroy();
delete _extEditors[id];
}
document.querySelector(`.ext-edit-form[data-ext-id="${id}"]`)?.remove();
}
async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`);
// Read from CM6 if available, otherwise from textarea
const editors = _extEditors[id];
const manifestText = editors?.manifest
? editors.manifest.getValue()
: document.getElementById(`extEdit-manifest-${id}`)?.value;
const scriptText = editors?.script
? editors.script.getValue()
: document.getElementById(`extEdit-script-${id}`)?.value;
if (!nameEl || manifestText == null || scriptText == null) return;
// Parse manifest and merge _script back in
let manifest;
try {
manifest = JSON.parse((manifestText || '').trim() || '{}');
} catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
}
if (scriptText.trim()) {
manifest._script = scriptText;
}
try {
await API._put(`/api/v1/admin/extensions/${id}`, {
name: nameEl.value.trim(),
description: descEl.value.trim(),
manifest: manifest,
});
UI.toast('Extension updated — reload page to apply changes');
_closeExtEditForm(id);
await UI.loadAdminExtensions();
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ── Exports ─────────────────────────────────
sb.register('_adminPersonaForm', _adminPersonaForm);
sb.register('_initAdminListeners', _initAdminListeners);
sb.register('adminResetUserPassword', adminResetUserPassword);
sb.register('bulkSetUserModelVisibility', bulkSetUserModelVisibility);
sb.register('cycleModelVisibility', cycleModelVisibility);
sb.register('deleteAdminExtension', deleteAdminExtension);
sb.register('deleteAdminPersona', deleteAdminPersona);
sb.register('deleteGroup', deleteGroup);
sb.register('deleteTeam', deleteTeam);
sb.register('deleteUser', deleteUser);
sb.register('deleteUserPersona', deleteUserPersona);
sb.register('editAdminExtension', editAdminExtension);
sb.register('editAdminPersona', editAdminPersona);
sb.register('ensureAdminPersonaForm', ensureAdminPersonaForm);
sb.register('removeGroupMember', removeGroupMember);
sb.register('removeTeamMember', removeTeamMember);
sb.register('saveGrant', saveGrant);
sb.register('settingsDeleteTeamPersona', settingsDeleteTeamPersona);
sb.register('settingsRemoveTeamMember', settingsRemoveTeamMember);
sb.register('settingsUpdateTeamMember', settingsUpdateTeamMember);
sb.register('showApproveForm', showApproveForm);
sb.register('toggleAdminExtension', toggleAdminExtension);
sb.register('toggleAdminPersona', toggleAdminPersona);
sb.register('toggleTeamActive', toggleTeamActive);
sb.register('toggleUserActive', toggleUserActive);
sb.register('toggleUserModelVisibility', toggleUserModelVisibility);
sb.register('toggleUserRole', toggleUserRole);
sb.register('updateTeamMember', updateTeamMember);
sb.register('_closeExtEditForm', _closeExtEditForm);
sb.register('saveAdminExtension', saveAdminExtension);

View File

@@ -1,380 +0,0 @@
// ==========================================
// Chat Switchboard — Admin Packages UI
// ==========================================
// Renders in the "Packages" admin section via ADMIN_LOADERS.
// Replaces admin-surfaces.js (v0.28.7).
// v0.30.0: scope column, settings, export, registry browse.
//
// Exports: window._loadAdminPackages
async function _loadAdminPackages() {
const container = document.getElementById('adminPackagesContent');
if (!container) return;
container.innerHTML =
'<div style="margin-bottom:16px">' +
'<p class="text-muted" style="font-size:12px;margin-bottom:12px">' +
'Surfaces, extensions, and full packages. Install .pkg or .surface archives.' +
'</p>' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px">' +
'<button class="btn-small" id="pkgFilterAll">All</button>' +
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
'<span style="flex:1"></span>' +
'<button class="btn-small" id="pkgBrowseRegistry">Browse Registry</button>' +
'</div>' +
'</div>' +
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
'<h4 style="margin:0 0 8px 0;font-size:13px">Install Package</h4>' +
'<input type="file" id="pkgInstallFile" accept=".pkg,.surface,.zip" style="font-size:12px">' +
'<div style="display:flex;gap:8px;margin-top:8px">' +
'<button class="btn-small btn-primary" id="pkgInstallSubmit">Upload & Install</button>' +
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminPkgSettingsPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
'<div id="adminPkgRegistryPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
var base = document.body.dataset.basePath || '';
var currentFilter = '';
// Filter buttons
['All', 'Surface', 'Extension', 'Full'].forEach(function(label) {
var btn = document.getElementById('pkgFilter' + label);
if (btn) {
btn.addEventListener('click', function() {
currentFilter = label === 'All' ? '' : label.toLowerCase();
document.getElementById('adminPkgRegistryPanel').style.display = 'none';
loadList();
});
}
});
// Browse Registry button
var browseBtn = document.getElementById('pkgBrowseRegistry');
if (browseBtn) {
browseBtn.addEventListener('click', loadRegistry);
}
// Install form
var installSubmit = document.getElementById('pkgInstallSubmit');
var installCancel = document.getElementById('pkgInstallCancel');
if (installSubmit) {
installSubmit.addEventListener('click', async function() {
var fileInput = document.getElementById('pkgInstallFile');
if (!fileInput || !fileInput.files[0]) {
UI.toast('Select a .pkg, .surface, or .zip file', 'warning');
return;
}
var fd = new FormData();
fd.append('file', fileInput.files[0]);
try {
var resp = await fetch(base + '/api/v1/admin/packages/install', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + API.accessToken },
body: fd,
});
if (!resp.ok) {
var err = await resp.json();
UI.toast(err.error || 'Install failed', 'error');
return;
}
var data = await resp.json();
UI.toast('Installed: ' + (data.title || data.id), 'success');
fileInput.value = '';
document.getElementById('adminPkgInstallForm').style.display = 'none';
loadList();
} catch (e) {
UI.toast('Install failed: ' + e.message, 'error');
}
});
}
if (installCancel) {
installCancel.addEventListener('click', function() {
document.getElementById('adminPkgInstallForm').style.display = 'none';
});
}
async function loadList() {
var listEl = document.getElementById('adminPkgList');
if (!listEl) return;
listEl.innerHTML = '<div class="loading">Loading...</div>';
try {
var url = '/api/v1/admin/packages';
if (currentFilter) url += '?type=' + currentFilter;
var resp = await API._get(url);
var packages = resp.data || [];
if (packages.length === 0) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No packages found</div>';
return;
}
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
'<th style="width:25%">Package</th>' +
'<th>Type</th>' +
'<th>Version</th>' +
'<th>Scope</th>' +
'<th>Source</th>' +
'<th>Status</th>' +
'<th style="width:140px">Actions</th>' +
'</tr></thead><tbody>';
packages.forEach(function(pkg) {
var typeBadge = {
surface: '<span class="badge badge-info">surface</span>',
extension: '<span class="badge badge-accent">extension</span>',
full: '<span class="badge badge-warning">full</span>',
}[pkg.type] || '<span class="badge">' + pkg.type + '</span>';
var sourceBadge = {
core: '<span class="text-muted">core</span>',
builtin: '<span class="text-muted">builtin</span>',
extension: '<span class="text-muted">installed</span>',
registry: '<span class="text-muted">registry</span>',
}[pkg.source] || pkg.source;
var scopeBadge = {
global: '<span class="text-muted">global</span>',
team: '<span class="badge badge-info">team</span>',
personal: '<span class="badge badge-accent">personal</span>',
}[pkg.scope] || pkg.scope;
var statusBadge = pkg.enabled
? '<span class="badge badge-success">enabled</span>'
: '<span class="badge badge-muted">disabled</span>';
var actions = '';
// Toggle enable/disable (not for chat/admin)
if (pkg.id !== 'chat' && pkg.id !== 'admin') {
if (pkg.enabled) {
actions += '<button class="btn-small btn-ghost pkg-disable" data-id="' + pkg.id + '" title="Disable">Disable</button> ';
} else {
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
}
}
// Settings (if manifest has settings)
if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) {
actions += '<button class="btn-small btn-ghost pkg-settings" data-id="' + pkg.id + '" title="Settings">Settings</button> ';
}
// Export (non-core only)
if (pkg.source !== 'core') {
actions += '<button class="btn-small btn-ghost pkg-export" data-id="' + pkg.id + '" title="Export">Export</button> ';
}
// Delete (non-core only)
if (pkg.source !== 'core') {
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
}
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
var schemaVer = pkg.schema_version > 0 ? ' <span class="text-muted" style="font-size:10px">v' + pkg.schema_version + '</span>' : '';
html += '<tr>' +
'<td><strong>' + pkg.title + '</strong>' + system + schemaVer + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
'<td>' + typeBadge + '</td>' +
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '\u2014') + '</td>' +
'<td>' + scopeBadge + '</td>' +
'<td>' + sourceBadge + '</td>' +
'<td>' + statusBadge + '</td>' +
'<td>' + actions + '</td>' +
'</tr>';
});
html += '</tbody></table>';
listEl.innerHTML = html;
// Wire action buttons
listEl.querySelectorAll('.pkg-enable').forEach(function(btn) {
btn.addEventListener('click', async function() {
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/enable');
UI.toast('Enabled', 'success');
loadList();
});
});
listEl.querySelectorAll('.pkg-disable').forEach(function(btn) {
btn.addEventListener('click', async function() {
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/disable');
UI.toast('Disabled', 'success');
loadList();
});
});
listEl.querySelectorAll('.pkg-delete').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Delete package "' + btn.dataset.id + '"? This cannot be undone.')) return;
await API._del('/api/v1/admin/packages/' + btn.dataset.id);
UI.toast('Deleted', 'success');
loadList();
});
});
listEl.querySelectorAll('.pkg-export').forEach(function(btn) {
btn.addEventListener('click', function() {
window.location = base + '/api/v1/admin/packages/' + btn.dataset.id + '/export';
});
});
listEl.querySelectorAll('.pkg-settings').forEach(function(btn) {
btn.addEventListener('click', function() {
loadSettings(btn.dataset.id);
});
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
}
}
// ── Settings panel ──────────────────────────
async function loadSettings(pkgId) {
var panel = document.getElementById('adminPkgSettingsPanel');
panel.style.display = 'block';
panel.innerHTML = '<div class="loading">Loading settings...</div>';
try {
var resp = await API._get('/api/v1/admin/packages/' + pkgId + '/settings');
var schema = resp.schema || [];
var values = resp.values || {};
if (typeof values === 'string') {
try { values = JSON.parse(values); } catch(e) { values = {}; }
}
if (schema.length === 0) {
panel.innerHTML = '<p class="text-muted" style="font-size:12px">No settings declared by this package.</p>' +
'<button class="btn-small" id="pkgSettingsClose">Close</button>';
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
return;
}
var html = '<h4 style="margin:0 0 12px 0;font-size:13px">Settings: ' + pkgId + '</h4>';
schema.forEach(function(s) {
var key = s.key;
var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : '');
html += '<div style="margin-bottom:10px">' +
'<label style="font-size:12px;display:block;margin-bottom:4px">' + (s.label || key) + '</label>';
if (s.type === 'select' && s.options) {
html += '<select class="pkg-setting-input" data-key="' + key + '" style="font-size:12px;padding:4px 8px">';
s.options.forEach(function(opt) {
html += '<option value="' + opt + '"' + (opt === val ? ' selected' : '') + '>' + opt + '</option>';
});
html += '</select>';
} else if (s.type === 'boolean') {
html += '<input type="checkbox" class="pkg-setting-input" data-key="' + key + '" data-type="boolean"' + (val ? ' checked' : '') + '>';
} else if (s.type === 'number') {
html += '<input type="number" class="pkg-setting-input" data-key="' + key + '" data-type="number" value="' + val + '" style="font-size:12px;padding:4px 8px;width:100px">';
} else {
html += '<input type="text" class="pkg-setting-input" data-key="' + key + '" value="' + val + '" style="font-size:12px;padding:4px 8px;width:200px">';
}
html += '</div>';
});
html += '<div style="display:flex;gap:8px;margin-top:12px">' +
'<button class="btn-small btn-primary" id="pkgSettingsSave">Save</button>' +
'<button class="btn-small" id="pkgSettingsClose">Close</button>' +
'</div>';
panel.innerHTML = html;
document.getElementById('pkgSettingsSave').addEventListener('click', async function() {
var data = {};
panel.querySelectorAll('.pkg-setting-input').forEach(function(el) {
var key = el.dataset.key;
if (el.dataset.type === 'boolean') {
data[key] = el.checked;
} else if (el.dataset.type === 'number') {
data[key] = parseFloat(el.value) || 0;
} else {
data[key] = el.value;
}
});
try {
await API._put('/api/v1/admin/packages/' + pkgId + '/settings', data);
UI.toast('Settings saved', 'success');
panel.style.display = 'none';
} catch (e) {
UI.toast('Failed to save: ' + e.message, 'error');
}
});
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
} catch (e) {
panel.innerHTML = '<div style="color:var(--danger)">Failed to load settings: ' + e.message + '</div>';
}
}
// ── Registry browse ─────────────────────────
async function loadRegistry() {
var panel = document.getElementById('adminPkgRegistryPanel');
panel.style.display = 'block';
panel.innerHTML = '<div class="loading">Loading registry...</div>';
try {
var resp = await API._get('/api/v1/admin/packages/registry');
var packages = resp.packages || [];
var registryUrl = resp.registry_url || '';
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<h4 style="margin:0;font-size:13px">Package Registry</h4>' +
'<span style="flex:1"></span>' +
'<button class="btn-small" id="pkgRegistryClose">Close</button>' +
'</div>';
if (!registryUrl) {
html += '<p class="text-muted" style="font-size:12px">No registry URL configured. Set <code>package_registry</code> in Admin Settings with a <code>{"url": "https://..."}</code> value.</p>';
} else if (packages.length === 0) {
html += '<p class="text-muted" style="font-size:12px">Registry is empty or returned no packages.</p>';
} else {
html += '<table class="admin-table" style="width:100%"><thead><tr>' +
'<th style="width:30%">Package</th><th>Version</th><th>Author</th><th>Actions</th>' +
'</tr></thead><tbody>';
packages.forEach(function(pkg) {
var installBtn = pkg.installed
? '<span class="badge badge-success">Installed</span>'
: '<button class="btn-small btn-primary registry-install" data-url="' + pkg.download_url + '">Install</button>';
html += '<tr>' +
'<td><strong>' + pkg.title + '</strong><div class="text-muted" style="font-size:11px">' +
(pkg.description || '') + '</div></td>' +
'<td style="font-family:monospace;font-size:12px">' + (pkg.version || '') + '</td>' +
'<td style="font-size:12px">' + (pkg.author || '') + '</td>' +
'<td>' + installBtn + '</td>' +
'</tr>';
});
html += '</tbody></table>';
}
panel.innerHTML = html;
document.getElementById('pkgRegistryClose').addEventListener('click', function() { panel.style.display = 'none'; });
panel.querySelectorAll('.registry-install').forEach(function(btn) {
btn.addEventListener('click', async function() {
btn.disabled = true;
btn.textContent = 'Installing...';
try {
await API._post('/api/v1/admin/packages/registry/install', {
download_url: btn.dataset.url,
});
UI.toast('Installed from registry', 'success');
loadList();
loadRegistry(); // refresh to show "Installed" badge
} catch (e) {
UI.toast('Install failed: ' + e.message, 'error');
btn.disabled = false;
btn.textContent = 'Install';
}
});
});
} catch (e) {
panel.innerHTML = '<div style="color:var(--danger)">Failed to load registry: ' + e.message + '</div>';
}
}
loadList();
}
sb.register('_loadAdminPackages', _loadAdminPackages);

View File

@@ -1,367 +0,0 @@
// ==========================================
// Chat Switchboard - Admin Surface Scaffold
// ==========================================
// Injects section-specific DOM into adminDynamic, wires listeners,
// and calls the appropriate ADMIN_LOADERS[section]() from ui-admin.js.
// Loaded only on the admin surface (/admin/:section).
// Each admin section loader (ui-admin.js) expects specific container
// elements. SCAFFOLDING maps section name -> HTML to inject before
// the loader runs.
var SCAFFOLDING = {};
// -- People -----------------------------------------------------------
SCAFFOLDING.users =
'<div id="adminUserList" class="admin-list"></div>';
SCAFFOLDING.teams =
'<div id="adminTeamList" class="admin-list"></div>' +
'<div id="adminTeamDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminTeamBackBtn">&larr; Back</button>' +
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
'</div>' +
'<div style="margin-bottom:12px">' +
'<label class="toggle-label"><input type="checkbox" id="adminTeamPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminTeamAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>' +
'</div>' +
'<div id="adminMemberList" class="admin-list"></div>' +
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' +
'<div class="form-group"><label>User</label><select id="adminMemberUser"><option value="">Select user...</option></select></div>' +
'<div class="form-group"><label>Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminAddMemberSubmit">Add Member</button>' +
'<button class="btn-small" id="adminCancelMemberBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<button class="btn-small" id="adminAddMemberBtn" style="margin-top:8px">+ Add Member</button>' +
'</div>';
SCAFFOLDING.groups =
'<div id="adminGroupList" class="admin-list"></div>' +
'<div id="adminGroupDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminGroupBackBtn">&larr; Back</button>' +
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
'<span id="adminGroupDetailMeta" class="text-muted"></span>' +
'</div>' +
/* ── Permissions (v0.24.2) ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Permissions</h5>' +
'<div id="adminGroupPermissions" class="admin-permissions-grid"></div>' +
'</div>' +
/* ── Token Budgets ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Token Budgets</h5>' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:1"><label>Daily limit</label>' +
'<input type="number" id="adminGroupBudgetDaily" placeholder="Unlimited" min="0" style="width:100%">' +
'</div>' +
'<div class="form-group" style="flex:1"><label>Monthly limit</label>' +
'<input type="number" id="adminGroupBudgetMonthly" placeholder="Unlimited" min="0" style="width:100%">' +
'</div>' +
'</div>' +
'</div>' +
/* ── Model Allowlist ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Allowed Models</h5>' +
'<p class="text-muted" style="font-size:12px;margin:0 0 8px">Leave empty for unrestricted access to all enabled models.</p>' +
'<div id="adminGroupModels" class="admin-models-grid"></div>' +
'</div>' +
/* ── Save button ── */
'<div style="margin-bottom:16px">' +
'<button class="btn-small btn-primary" id="adminGroupSavePermsBtn">Save Permissions &amp; Budgets</button>' +
'<span id="adminGroupSaveStatus" class="text-muted" style="margin-left:8px;font-size:12px"></span>' +
'</div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
/* ── Members (existing) ── */
'<h5 style="margin:0 0 8px">Members</h5>' +
'<div id="adminGroupMemberList" class="admin-list"></div>' +
'<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' +
'<div class="form-group"><label>User</label><select id="adminGroupMemberUser"><option value="">Select user...</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminAddGroupMemberSubmit">Add Member</button>' +
'<button class="btn-small" id="adminCancelGroupMemberBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<button class="btn-small" id="adminAddGroupMemberBtn" style="margin-top:8px">+ Add Member</button>' +
'</div>';
// -- AI ---------------------------------------------------------------
SCAFFOLDING.providers =
'<div id="adminProviderList" class="provider-cards"></div>';
SCAFFOLDING.models =
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small btn-primary" id="adminFetchModelsBtn">Fetch Models</button>' +
'<span id="adminModelsHint" class="text-muted" style="font-size:12px"></span>' +
'</div>' +
'<div id="adminModelList" class="admin-list"></div>';
SCAFFOLDING.personas =
'<div id="adminPersonaList" class="admin-list"></div>';
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
SCAFFOLDING.memory = '<div id="adminMemoryContent"></div>';
// -- System -----------------------------------------------------------
SCAFFOLDING.settings =
'<div class="admin-settings-form">' +
'<div class="settings-section"><h3>Registration</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminRegToggle"><span class="toggle-track"></span><span>Allow new user registration</span></label>' +
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h3>System Prompt</h3>' +
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
'</div>' +
'<div class="settings-section"><h3>Default Model</h3>' +
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
'</div>' +
'<div class="settings-section"><h3>Policies</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminUserProvidersToggle"><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminUserPersonasToggle"><span class="toggle-track"></span><span>Allow user personas</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminKBDirectAccessToggle"><span class="toggle-track"></span><span>Allow direct KB access (non-persona)</span></label>' +
'</div>' +
'<div class="settings-section"><h3>Environment Banner</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminBannerEnabled"><span class="toggle-track"></span><span>Show environment banner</span></label>' +
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Background</label><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;margin-left:4px"></div>' +
'<div class="form-group"><label>Foreground</label><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;margin-left:4px"></div>' +
'</div>' +
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h3>Web Search</h3>' +
'<div class="form-group"><label>Provider</label>' +
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
'</div>' +
'<div id="searxngConfigFields" style="display:none">' +
'<div class="form-group"><label>Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://searxng.example.com"></div>' +
'<div class="form-group"><label>API Key</label><input type="text" id="adminSearchApiKey" placeholder="Optional"></div>' +
'</div>' +
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
'</div>' +
'<div class="settings-section"><h3>Auto-Compaction</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminCompactionEnabled"><span class="toggle-track"></span><span>Enable auto-compaction</span></label>' +
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-row">' +
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
'<div class="form-group"><label>Cooldown (min)</label><input type="number" id="adminCompactionCooldown" value="30" min="5" max="1440"></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h3>Memory Extraction</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminMemoryExtractionEnabled"><span class="toggle-track"></span><span>Enable memory extraction</span></label>' +
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
'<label class="toggle-label"><input type="checkbox" id="adminMemoryAutoApprove"><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h3>Encryption Vault</h3>' +
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
'</div>' +
'<div id="roleFallbackBanner"></div>' +
'<div style="display:flex;gap:8px;align-items:center;margin-top:16px">' +
'<button class="btn-md btn-primary" id="adminSaveSettings">Save Settings</button>' +
'<div style="flex:1"></div>' +
'<button class="btn-small" id="adminExportSettings" title="Export admin settings as JSON">Export</button>' +
'<button class="btn-small" id="adminImportSettings" title="Import admin settings from JSON">Import</button>' +
'<input type="file" id="adminImportFile" accept=".json,application/json" style="display:none">' +
'</div>' +
'</div>';
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
SCAFFOLDING.packages =
'<div id="adminPackagesContent"></div>';
// -- Routing ----------------------------------------------------------
SCAFFOLDING.health =
'<div id="adminHealthCards" class="stat-cards-grid"></div>' +
'<div id="adminHealthContent"></div>';
SCAFFOLDING.routing =
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
'<div id="adminRoutingPolicyList" class="admin-list"></div>' +
'<div id="adminRoutingForm" class="admin-inline-form" style="display:none"></div>' +
'<div class="settings-section" style="margin-top:24px"><h4>Test Routing</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>Model</label><input type="text" id="routingTestModel" placeholder="gpt-4o"></div>' +
'<div class="form-group"><label>User ID</label><input type="text" id="routingTestUser" placeholder="optional"></div>' +
'<button class="btn-small btn-primary" id="routingTestBtn" style="align-self:flex-end">Test</button>' +
'</div>' +
'<pre id="routingTestResult" class="code-block" style="display:none;margin-top:8px;font-size:12px;max-height:200px;overflow:auto"></pre>' +
'</div>';
SCAFFOLDING.capabilities =
'<div id="adminCapabilityList" class="admin-list"><div class="loading">Loading...</div></div>';
// -- Monitoring -------------------------------------------------------
SCAFFOLDING.dashboard =
'<div id="adminDashboardCards" class="stat-cards-grid"></div>' +
'<div id="adminDashboardContent"><div class="loading">Loading...</div></div>';
SCAFFOLDING.usage =
'<div id="adminUsageCards" class="stat-cards-grid"></div>' +
'<div id="adminUsageChart" style="margin-bottom:24px"></div>' +
'<div id="adminUsageTotals"></div>' +
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
'<div id="adminPricingTable" style="margin-top:24px"></div>';
SCAFFOLDING.audit =
'<div class="form-row" style="margin-bottom:12px">' +
'<div class="form-group"><label>Action</label><select id="auditFilterAction"><option value="">All</option></select></div>' +
'<div class="form-group"><label>Resource</label><select id="auditFilterResource"><option value="">All</option></select></div>' +
'</div>' +
'<div id="adminAuditList" class="admin-list"><div class="loading">Loading...</div></div>' +
'<div id="auditPagination" style="margin-top:12px;display:flex;align-items:center;gap:8px">' +
'<button class="btn-small" id="auditPrevBtn">&larr; Prev</button>' +
'<span id="auditPageInfo" class="text-muted" style="font-size:12px"></span>' +
'<button class="btn-small" id="auditNextBtn">Next &rarr;</button>' +
'</div>';
SCAFFOLDING.stats =
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
SCAFFOLDING.channels =
'<div class="stat-cards-grid" style="margin-bottom:16px">' +
'<div class="stat-card"><div class="stat-value" id="adminArchivedCount">—</div><div class="stat-label">Archived</div></div>' +
'<div class="stat-card"><div class="stat-value" id="adminRetentionMode">—</div><div class="stat-label">Retention Mode</div></div>' +
'</div>' +
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
// === Add button actions ==============================================
var ADD_ACTIONS = {
users: function() {
openModal('createUserModal');
var n = document.getElementById('adminNewUsername');
if (n) n.focus();
},
teams: function() {
openModal('createTeamModal');
var n = document.getElementById('adminTeamName');
if (n) n.focus();
},
providers: function() {
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
},
personas: function() {
if (typeof ensureAdminPersonaForm === 'function') {
var form = ensureAdminPersonaForm();
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
}
document.getElementById('personaFormTitle').textContent = 'Create Persona';
openModal('personaFormModal');
},
groups: function() {
openModal('createGroupModal');
var n = document.getElementById('adminGroupName');
if (n) n.focus();
},
packages: function() {
var f = document.getElementById('adminPkgInstallForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
};
// === Init on DOMContentLoaded ========================================
document.addEventListener('DOMContentLoaded', function() {
// When the Preact admin surface is active, skip scaffold init entirely.
if (document.getElementById('admin-mount')) return;
var el = document.getElementById('adminContentBody');
if (!el) return;
var section = el.dataset.section;
if (!section) return;
// -- Category / nav resolution -----------
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
// admin-scaffold.js previously had its own duplicate catMap/secMap.
var cat = 'people';
if (typeof ADMIN_SECTIONS !== 'undefined') {
for (var c in ADMIN_SECTIONS) {
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
}
}
var secMap;
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
secMap = {};
for (var c in ADMIN_SECTIONS) {
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
return { id: id, l: ADMIN_LABELS[id] || id };
});
}
}
// Rebuild left nav for current category
var navEl = document.getElementById('adminNav');
var base = window.__BASE__ || '';
if (navEl && secMap[cat]) {
navEl.innerHTML = secMap[cat].map(function(s) {
return '<a href="' + base + '/admin/' + s.id + '" class="admin-nav-link' + (s.id === section ? ' active' : '') + '">' + s.l + '</a>';
}).join('');
}
// Update section title
var titleEl = document.getElementById('adminSectionTitle');
var curSec = secMap[cat] && secMap[cat].find(function(s) { return s.id === section; });
if (titleEl && curSec) titleEl.textContent = curSec.l;
// -- Inject scaffolding ------------------
var target = document.getElementById('adminDynamic');
if (target && SCAFFOLDING[section]) {
target.innerHTML = SCAFFOLDING[section];
} else if (target) {
target.innerHTML = '<div class="empty-hint">Section: ' + section + '</div>';
}
// -- Show/wire Add button for sections that have one --
var addBtn = document.getElementById('adminAddBtn');
if (addBtn && ADD_ACTIONS[section]) {
addBtn.style.display = '';
addBtn.addEventListener('click', ADD_ACTIONS[section]);
}
// -- Wire install extension cancel -------
var extCancel = document.getElementById('adminInstallExtCancelBtn');
if (extCancel) {
extCancel.addEventListener('click', function() {
document.getElementById('adminInstallExtForm').style.display = 'none';
});
}
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
if (typeof _initAdminListeners === 'function') _initAdminListeners();
// -- Wire settings toggle show/hide listeners (banner, compaction, etc.) --
if (typeof _initAdminSettingsToggles === 'function') _initAdminSettingsToggles();
// -- Call the section data loader --------
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {
ADMIN_LOADERS[section]();
}
});

View File

@@ -321,8 +321,7 @@ function initListeners() {
_listenersInit = true; _listenersInit = true;
_initChatListeners(); // from chat.js _initChatListeners(); // from chat.js
_initSettingsListeners(); // from settings-handlers.js // _initSettingsListeners and _initAdminListeners removed in v0.37.7
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js _initNotesListeners(); // from notes.js
_initFileListeners(); // from files.js _initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry _registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry

View File

@@ -1,96 +0,0 @@
// ==========================================
// Chat Switchboard — Broadcast Admin Panel
// ==========================================
// Admin section for composing and sending system announcements.
// Registered as ADMIN_LOADERS.broadcast in ui-admin.js.
var SCAFFOLD_HTML =
'<div style="max-width:600px">' +
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:20px">' +
'Send a system announcement to all active users. Notifications appear in their ' +
'bell menu and optionally via email (based on user preferences).' +
'</p>' +
'<div class="form-group">' +
'<label>Title</label>' +
'<input type="text" id="broadcastTitle" class="input" placeholder="Scheduled maintenance tonight" autocomplete="off">' +
'</div>' +
'<div class="form-group">' +
'<label>Message</label>' +
'<textarea id="broadcastMessage" class="input" rows="4" placeholder="Details about the announcement…" style="resize:vertical;font-family:inherit"></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label>Level</label>' +
'<select id="broadcastLevel" class="input">' +
'<option value="info">Info</option>' +
'<option value="warning">Warning</option>' +
'<option value="critical">Critical</option>' +
'</select>' +
'</div>' +
'<button id="broadcastSendBtn" class="btn-md btn-primary" style="margin-top:8px">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
' Send Broadcast' +
'</button>' +
'<div id="broadcastResult" style="margin-top:12px;display:none"></div>' +
'</div>';
sb.register('_loadAdminBroadcast', async function () {
var body = document.getElementById('adminDynamic') || document.getElementById('adminContentBody');
if (!body) return;
body.innerHTML = SCAFFOLD_HTML;
var sendBtn = document.getElementById('broadcastSendBtn');
var resultEl = document.getElementById('broadcastResult');
sendBtn.addEventListener('click', async function () {
var title = document.getElementById('broadcastTitle').value.trim();
var message = document.getElementById('broadcastMessage').value.trim();
var level = document.getElementById('broadcastLevel').value;
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!message) { UI.toast('Message is required', 'warning'); return; }
// Confirmation
var ok = await UI.confirm(
'Send broadcast to all active users?\n\n' +
'Level: ' + level + '\n' +
'Title: ' + title
);
if (!ok) return;
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
resultEl.style.display = 'none';
try {
var resp = await API.post('/admin/notifications/broadcast', {
title: title,
message: message,
level: level,
});
var count = resp.count || 0;
resultEl.style.display = 'block';
resultEl.className = 'admin-success';
resultEl.innerHTML =
'<span style="color:var(--success);font-weight:600">✓</span> ' +
'Broadcast sent to ' + count + ' user' + (count !== 1 ? 's' : '') + '.';
// Clear form
document.getElementById('broadcastTitle').value = '';
document.getElementById('broadcastMessage').value = '';
document.getElementById('broadcastLevel').value = 'info';
UI.toast('Broadcast sent to ' + count + ' users', 'success');
} catch (err) {
resultEl.style.display = 'block';
resultEl.className = 'admin-error';
resultEl.innerHTML =
'<span style="color:var(--danger);font-weight:600">✗</span> ' +
'Failed: ' + (err.message || err);
UI.toast('Broadcast failed', 'error');
} finally {
sendBtn.disabled = false;
sendBtn.textContent = ' Send Broadcast';
}
});
});

View File

@@ -1075,11 +1075,11 @@ function _initChatListeners() {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu(); if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
}); });
// Flyout items // Flyout items — v0.37.7: admin + team admin navigate to dedicated surfaces
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); }); const _base = window.__BASE__ || '';
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); }); document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/settings/general'; });
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); }); document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/admin/users'; });
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin()); document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); location.href = _base + '/team-admin/members'; });
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
@@ -1096,7 +1096,7 @@ function _initChatListeners() {
// Model selector (custom dropdown) // Model selector (custom dropdown)
UI.initModelDropdown(); UI.initModelDropdown();
UI.initAppearance(); // UI.initAppearance removed in v0.37.7 — appearance handled by SDK theme
// Mobile hamburger // Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar); document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);

View File

@@ -1,307 +0,0 @@
// data-portability.js — v0.35.1
//
// Settings → Data & Privacy section. Wires the v0.34.0 backend
// endpoints: export/import user data, ChatGPT import, and GDPR
// account deletion. Also used by admin-handlers.js for team export.
(function () {
'use strict';
// ── API helpers ──────────────────────────────
const _base = (window.__BASE__ || '') + '/api/v1';
async function _exportMyData() {
const btn = document.getElementById('dpExportBtn');
if (!btn) return;
btn.disabled = true;
btn.textContent = 'Exporting…';
try {
const resp = await fetch(_base + '/export/me', {
headers: { Authorization: 'Bearer ' + API.accessToken },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Export failed (' + resp.status + ')');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Extract filename from Content-Disposition or use default
const cd = resp.headers.get('Content-Disposition') || '';
const match = cd.match(/filename="?([^"]+)"?/);
a.download = match ? match[1] : 'switchboard-export.switchboard';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
UI.toast('Export downloaded', 'success');
} catch (e) {
UI.toast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Download My Data';
}
}
async function _importMyData() {
const fileInput = document.getElementById('dpImportFile');
if (!fileInput || !fileInput.files[0]) return;
const file = fileInput.files[0];
const btn = document.getElementById('dpImportBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
const form = new FormData();
form.append('file', file);
try {
const resp = await fetch(_base + '/import/me', {
method: 'POST',
headers: { Authorization: 'Bearer ' + API.accessToken },
body: form,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Import failed');
let msg = 'Import complete.';
if (data.imported) {
const counts = Object.entries(data.imported)
.filter(([, v]) => v > 0)
.map(([k, v]) => v + ' ' + k)
.join(', ');
if (counts) msg += ' Imported: ' + counts + '.';
}
if (data.skipped) {
const skips = Object.entries(data.skipped)
.filter(([, v]) => v > 0)
.map(([k, v]) => v + ' ' + k)
.join(', ');
if (skips) msg += ' Skipped (already exist): ' + skips + '.';
}
if (data.errors && data.errors.length) {
msg += ' Errors: ' + data.errors.join('; ');
UI.toast(msg, 'warning');
} else {
UI.toast(msg, 'success');
}
} catch (e) {
UI.toast(e.message, 'error');
} finally {
fileInput.value = '';
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
}
}
async function _importChatGPT() {
const fileInput = document.getElementById('dpChatGPTFile');
if (!fileInput || !fileInput.files[0]) return;
const file = fileInput.files[0];
const btn = document.getElementById('dpChatGPTBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
const form = new FormData();
form.append('file', file);
try {
const resp = await fetch(_base + '/import/chatgpt', {
method: 'POST',
headers: { Authorization: 'Bearer ' + API.accessToken },
body: form,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Import failed');
let msg = 'ChatGPT import complete.';
if (data.imported) {
const counts = Object.entries(data.imported)
.filter(([, v]) => v > 0)
.map(([k, v]) => v + ' ' + k)
.join(', ');
if (counts) msg += ' Imported: ' + counts + '.';
}
if (data.skipped) {
const skips = Object.entries(data.skipped)
.filter(([, v]) => v > 0)
.map(([k, v]) => v + ' ' + k)
.join(', ');
if (skips) msg += ' Skipped: ' + skips + '.';
}
UI.toast(msg, 'success');
} catch (e) {
UI.toast(e.message, 'error');
} finally {
fileInput.value = '';
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
}
}
function _showDeleteConfirm() {
const el = document.getElementById('dpDeleteSection');
if (!el) return;
el.innerHTML = `
<div style="background:var(--danger-dim);border:1px solid var(--danger);border-radius:var(--radius);padding:14px 16px;margin-top:12px;">
<p style="color:var(--danger);font-weight:600;font-size:13px;margin:0 0 8px;">
This will permanently delete your account and all associated data.
This action cannot be undone.
</p>
<div class="form-group" style="margin-bottom:8px;">
<label style="font-size:12px;color:var(--text-2);">Enter your password to confirm</label>
<input type="password" id="dpDeletePassword" autocomplete="current-password"
style="max-width:280px;" placeholder="Your password">
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-md btn-danger" id="dpDeleteConfirmBtn">Delete My Account</button>
<button class="btn-md btn-ghost" id="dpDeleteCancelBtn">Cancel</button>
</div>
</div>`;
document.getElementById('dpDeleteConfirmBtn')
.addEventListener('click', _executeDelete);
document.getElementById('dpDeleteCancelBtn')
.addEventListener('click', () => { _renderDeleteButton(el); });
}
function _renderDeleteButton(el) {
el.innerHTML = `
<button class="btn-md btn-ghost" id="dpDeleteStartBtn"
style="color:var(--danger);border-color:var(--danger);">
Delete My Account
</button>`;
document.getElementById('dpDeleteStartBtn')
.addEventListener('click', _showDeleteConfirm);
}
async function _executeDelete() {
const pw = document.getElementById('dpDeletePassword');
if (!pw || !pw.value) {
UI.toast('Password is required', 'error');
return;
}
const btn = document.getElementById('dpDeleteConfirmBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
try {
const resp = await fetch(_base + '/me', {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + API.accessToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirm: 'DELETE', password: pw.value }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Delete failed');
UI.toast('Account deleted. Redirecting…', 'success');
setTimeout(() => {
API.clearTokens();
window.location.href = '/login';
}, 1500);
} catch (e) {
UI.toast(e.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Delete My Account'; }
}
}
// ── Section renderer ─────────────────────────
function loadDataPrivacy() {
const mount = document.getElementById('dpMount');
if (!mount) return;
mount.innerHTML = `
<div class="settings-section">
<h3>Export My Data</h3>
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
Download all your data as a <code>.switchboard</code> archive: conversations,
notes, memories, projects, files, settings, and usage history.
</p>
<button class="btn-md btn-primary" id="dpExportBtn">Download My Data</button>
</div>
<div class="settings-section" style="margin-top:20px;">
<h3>Import Data</h3>
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
Restore from a <code>.switchboard</code> archive exported from this or another
instance. Existing items with the same ID are skipped (no duplicates).
</p>
<div style="display:flex;gap:8px;align-items:center;">
<input type="file" id="dpImportFile" accept=".switchboard,.zip" style="font-size:13px;">
<button class="btn-md btn-primary" id="dpImportBtn" disabled>Import</button>
</div>
</div>
<div class="settings-section" style="margin-top:20px;">
<h3>Import from ChatGPT</h3>
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
Import conversations from a ChatGPT export. Go to
<a href="https://chatgpt.com/#settings/DataControls" target="_blank"
style="color:var(--accent);">ChatGPT Settings → Data Controls → Export</a>,
download the zip, and upload it here. Conversations become direct chats.
</p>
<div style="display:flex;gap:8px;align-items:center;">
<input type="file" id="dpChatGPTFile" accept=".zip" style="font-size:13px;">
<button class="btn-md btn-primary" id="dpChatGPTBtn" disabled>Import</button>
</div>
</div>
<div class="settings-section" style="margin-top:24px;">
<h3 style="color:var(--danger);">Danger Zone</h3>
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
Permanently delete your account and all associated data. Your messages in
shared channels will be anonymized. This cannot be undone.
</p>
<div id="dpDeleteSection"></div>
</div>`;
// Wire listeners
document.getElementById('dpExportBtn').addEventListener('click', _exportMyData);
const impFile = document.getElementById('dpImportFile');
const impBtn = document.getElementById('dpImportBtn');
impFile.addEventListener('change', () => { impBtn.disabled = !impFile.files.length; });
impBtn.addEventListener('click', _importMyData);
const cgFile = document.getElementById('dpChatGPTFile');
const cgBtn = document.getElementById('dpChatGPTBtn');
cgFile.addEventListener('change', () => { cgBtn.disabled = !cgFile.files.length; });
cgBtn.addEventListener('click', _importChatGPT);
_renderDeleteButton(document.getElementById('dpDeleteSection'));
}
// ── Admin: team export ───────────────────────
async function exportTeamData(teamId, teamName) {
UI.toast('Exporting team data…', 'info');
try {
const resp = await fetch(_base + '/admin/teams/' + teamId + '/export', {
headers: { Authorization: 'Bearer ' + API.accessToken },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Export failed (' + resp.status + ')');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const cd = resp.headers.get('Content-Disposition') || '';
const match = cd.match(/filename="?([^"]+)"?/);
a.download = match ? match[1] : ('switchboard-team-' + (teamName || teamId).replace(/\s+/g, '-') + '.switchboard');
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
UI.toast('Team export downloaded', 'success');
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ── Register ─────────────────────────────────
sb.register('loadDataPrivacy', loadDataPrivacy);
sb.register('exportTeamData', exportTeamData);
})();

View File

@@ -1,180 +0,0 @@
// ==========================================
// Chat Switchboard — Git Credentials Settings
// ==========================================
// Settings section for managing SSH keys used with git workspaces.
// Keys are generated server-side — private keys never leave the server.
// Users copy the public key into their repo host (GitHub, Gitea, etc.).
// Optional persona binding enables per-persona commit attribution.
var SCAFFOLD_HTML =
'<div style="max-width:640px">' +
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:16px">' +
'SSH keys for git operations. Keys are generated on the server — the private key ' +
'is vault-encrypted and never exposed. Copy the public key into your git host.' +
'</p>' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
'<h4 style="margin:0">SSH Keys</h4>' +
'<button id="gitKeyGenerateBtn" class="btn-small btn-primary">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
' Generate Key' +
'</button>' +
'</div>' +
'<div id="gitKeyList" class="admin-list" style="margin-bottom:16px"></div>' +
'<div id="gitKeyEmpty" style="display:none;color:var(--text-3);font-size:13px;text-align:center;padding:24px 0">' +
'No SSH keys yet. Generate one to get started.' +
'</div>' +
'<div style="margin-top:16px;padding:14px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
'<span style="font-size:12px;font-weight:600">Security</span>' +
'</div>' +
'<p style="font-size:12px;color:var(--text-3);line-height:1.6;margin:0">' +
'Private keys are encrypted with your personal vault key. They are used server-side ' +
'for git push/pull operations and cannot be downloaded or viewed.' +
'</p>' +
'</div>' +
'</div>';
// ── Generate key dialog ──
var GENERATE_HTML =
'<div style="max-width:400px">' +
'<div class="form-group">' +
'<label>Key Name</label>' +
'<input type="text" id="gitKeyName" class="input" placeholder="e.g. GitHub - main" autocomplete="off">' +
'</div>' +
'<div class="form-group" id="gitKeyPersonaGroup" style="display:none">' +
'<label>Persona (optional)</label>' +
'<select id="gitKeyPersona" class="input"><option value="">None — use account identity</option></select>' +
'<p style="font-size:11px;color:var(--text-3);margin:4px 0 0">Commits signed with this key will use the persona\'s name as git author.</p>' +
'</div>' +
'</div>';
async function _loadSettingsGitKeys() {
var body = document.querySelector('.settings-section') || document.getElementById('settingsSection');
if (!body) return;
body.innerHTML = SCAFFOLD_HTML;
var listEl = document.getElementById('gitKeyList');
var emptyEl = document.getElementById('gitKeyEmpty');
var genBtn = document.getElementById('gitKeyGenerateBtn');
await refreshList();
genBtn.addEventListener('click', async function () {
// Show generate dialog
var ok = await new Promise(function (resolve) {
UI.modal('Generate SSH Key', GENERATE_HTML, [
{ label: 'Cancel', variant: 'ghost', action: function () { resolve(false); } },
{ label: 'Generate', variant: 'primary', action: function () { resolve(true); } },
]);
// Load personas for the optional selector
API.get('/personas/mine').then(function (d) {
var personas = (d.data || d.personas || []);
if (personas.length > 0) {
var group = document.getElementById('gitKeyPersonaGroup');
var sel = document.getElementById('gitKeyPersona');
if (group) group.style.display = '';
personas.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name + ' (@' + p.handle + ')';
sel.appendChild(opt);
});
}
}).catch(function () {});
});
if (!ok) return;
var name = (document.getElementById('gitKeyName') || {}).value || '';
if (!name.trim()) { UI.toast('Key name is required', 'warning'); return; }
var personaId = (document.getElementById('gitKeyPersona') || {}).value || null;
try {
var payload = { name: name.trim() };
if (personaId) payload.persona_id = personaId;
var cred = await API.post('/git-credentials/generate', payload);
UI.toast('SSH key generated', 'success');
// Show the public key for copying
showPublicKey(cred);
await refreshList();
} catch (err) {
UI.toast('Generation failed: ' + (err.message || err), 'error');
}
});
async function refreshList() {
try {
var d = await API.get('/git-credentials');
var creds = d.data || [];
if (creds.length === 0) {
listEl.innerHTML = '';
listEl.style.display = 'none';
emptyEl.style.display = '';
return;
}
listEl.style.display = '';
emptyEl.style.display = 'none';
listEl.innerHTML = creds.map(function (c) {
var ts = new Date(c.created_at);
var dateStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var fp = c.fingerprint ? '<span style="font-family:var(--mono);font-size:11px;color:var(--text-3)">' + esc(c.fingerprint) + '</span>' : '';
var persona = c.persona_id ? '<span class="badge" style="font-size:10px">persona</span>' : '';
return '<div class="admin-list-item" style="display:flex;align-items:center;gap:10px;padding:10px 14px">' +
'<div style="flex:1">' +
'<div style="font-weight:500;font-size:13px">' + esc(c.name) + ' ' + persona + '</div>' +
'<div style="display:flex;gap:12px;margin-top:2px">' +
'<span class="badge">' + esc(c.auth_type) + '</span>' +
fp +
'<span style="font-size:11px;color:var(--text-3)">' + dateStr + '</span>' +
'</div>' +
'</div>' +
(c.public_key ? '<button class="btn-small" onclick="_gitKeyCopy(\'' + c.id + '\')">Copy Public Key</button>' : '') +
'<button class="btn-small btn-ghost" onclick="_gitKeyDelete(\'' + c.id + '\',\'' + esc(c.name) + '\')">Delete</button>' +
'</div>';
}).join('');
} catch (err) {
listEl.innerHTML = '<div style="color:var(--danger);padding:12px">Failed to load credentials</div>';
}
}
function showPublicKey(cred) {
if (!cred.public_key) return;
UI.modal('Public Key — ' + cred.name,
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">Add this key to your git host (GitHub → Settings → SSH Keys):</p>' +
'<textarea readonly style="width:100%;height:80px;font-family:var(--mono);font-size:11px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius);padding:8px;color:var(--text);resize:none">' + esc(cred.public_key) + '</textarea>' +
'<p style="font-size:11px;color:var(--text-3);margin-top:4px">Fingerprint: ' + esc(cred.fingerprint) + '</p>',
[{ label: 'Done', variant: 'primary' }]
);
}
// Register global handlers for onclick
window._gitKeyCopy = async function (id) {
try {
var d = await API.get('/git-credentials/' + id + '/public-key');
await navigator.clipboard.writeText(d.public_key);
UI.toast('Public key copied to clipboard', 'success');
} catch (err) {
UI.toast('Failed to copy: ' + (err.message || err), 'error');
}
};
window._gitKeyDelete = async function (id, name) {
var ok = await UI.confirm('Delete SSH key "' + name + '"?\n\nWorkspaces using this key will lose git access until reassigned.');
if (!ok) return;
try {
await API.del('/git-credentials/' + id);
UI.toast('Key deleted', 'success');
await refreshList();
} catch (err) {
UI.toast('Delete failed: ' + (err.message || err), 'error');
}
};
}
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
sb.register('_loadSettingsGitKeys', _loadSettingsGitKeys);

View File

@@ -1,370 +0,0 @@
// ==========================================
// Chat Switchboard Memory UI (v0.18.0 Phase 3)
// ==========================================
// User-facing memory management in Settings,
// admin memory review in Admin Panel,
// and persona memory config in persona forms.
//
// Exports: window.MemoryUI
const MemoryUI = {
// ── State ────────────────────────────────
_memories: [],
_pendingMemories: [],
_filter: { scope: 'user', status: 'active', query: '' },
// ═════════════════════════════════════════
// User Settings Tab
// ═════════════════════════════════════════
async openSettingsPanel() {
const panel = document.getElementById('settingsMemoryContent');
if (!panel) return;
panel.innerHTML = '<div class="loading">Loading memories...</div>';
try {
// Load memory count summary
const count = await API.getMemoryCount();
const active = count.active || 0;
const pending = count.pending || 0;
panel.innerHTML = `
<div class="memory-summary">
<div class="memory-stat">
<span class="memory-stat-value">${active}</span>
<span class="memory-stat-label">Active</span>
</div>
<div class="memory-stat">
<span class="memory-stat-value">${pending}</span>
<span class="memory-stat-label">Pending Review</span>
</div>
</div>
<div class="memory-toolbar">
<div class="memory-filter-row">
<select id="memoryStatusFilter" class="memory-select">
<option value="active">Active</option>
<option value="pending_review">Pending Review</option>
<option value="archived">Archived</option>
</select>
<input type="text" id="memorySearchInput" placeholder="Search memories..." class="memory-search">
</div>
<div class="memory-actions-row">
<button class="btn-small" id="memoryRefreshBtn" title="Refresh">↻ Refresh</button>
${pending > 0 ? `<button class="btn-small btn-primary" id="memoryApproveAllBtn">✓ Approve All Pending</button>` : ''}
</div>
</div>
<div id="memoryList" class="memory-list"></div>
`;
// Wire events
document.getElementById('memoryStatusFilter')?.addEventListener('change', (e) => {
MemoryUI._filter.status = e.target.value;
MemoryUI.loadMemories();
});
document.getElementById('memorySearchInput')?.addEventListener('input', _debounce((e) => {
MemoryUI._filter.query = e.target.value;
MemoryUI.loadMemories();
}, 300));
document.getElementById('memoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openSettingsPanel());
document.getElementById('memoryApproveAllBtn')?.addEventListener('click', () => MemoryUI.approveAllPending());
// Load initial list
await this.loadMemories();
} catch (e) {
panel.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadMemories() {
const el = document.getElementById('memoryList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.listMemories(this._filter.status, this._filter.query);
this._memories = data.data || [];
if (!this._memories.length) {
el.innerHTML = `<div class="empty-hint">No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}</div>`;
return;
}
el.innerHTML = this._memories.map(m => this._renderMemoryCard(m)).join('');
this._wireMemoryCards(el);
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
_renderMemoryCard(m) {
const confidence = Math.round(m.confidence * 100);
const confidenceClass = confidence >= 80 ? 'high' : confidence >= 50 ? 'medium' : 'low';
const updated = m.updated_at ? new Date(m.updated_at).toLocaleDateString() : '';
const scopeLabel = m.scope === 'persona_user' ? 'persona' : m.scope;
return `
<div class="memory-card" data-id="${m.id}">
<div class="memory-card-header">
<span class="memory-key">${esc(m.key)}</span>
<div class="memory-badges">
<span class="memory-scope-badge memory-scope-${m.scope}">${scopeLabel}</span>
<span class="memory-confidence memory-confidence-${confidenceClass}" title="Confidence: ${confidence}%">${confidence}%</span>
</div>
</div>
<div class="memory-card-value">${esc(m.value)}</div>
<div class="memory-card-footer">
<span class="memory-date">${updated}</span>
<div class="memory-card-actions">
${m.status === 'pending_review' ? `
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}" title="Approve">✓</button>
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}" title="Reject">✕</button>
` : ''}
<button class="btn-tiny" data-action="edit" data-id="${m.id}" title="Edit">✎</button>
<button class="btn-tiny btn-danger" data-action="delete" data-id="${m.id}" title="Delete">🗑</button>
</div>
</div>
</div>`;
},
_wireMemoryCards(container) {
container.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = btn.dataset.id;
const action = btn.dataset.action;
if (action === 'approve') await this.approveMemory(id);
else if (action === 'reject') await this.rejectMemory(id);
else if (action === 'delete') await this.deleteMemory(id);
else if (action === 'edit') this.editMemory(id);
});
});
},
async approveMemory(id) {
try {
await API.approveMemory(id);
UI.toast('Memory approved', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
async rejectMemory(id) {
try {
await API.rejectMemory(id);
UI.toast('Memory rejected', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
async deleteMemory(id) {
if (!await showConfirm('Delete this memory permanently?')) return;
try {
await API.deleteMemory(id);
UI.toast('Memory deleted', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
editMemory(id) {
const m = this._memories.find(x => x.id === id);
if (!m) return;
const card = document.querySelector(`.memory-card[data-id="${id}"]`);
if (!card) return;
card.innerHTML = `
<div class="memory-edit-form">
<div class="form-group">
<label>Key</label>
<input type="text" id="memEdit_key_${id}" value="${esc(m.key)}" class="memory-edit-input">
</div>
<div class="form-group">
<label>Value</label>
<textarea id="memEdit_val_${id}" rows="2" class="memory-edit-input">${esc(m.value)}</textarea>
</div>
<div class="form-row" style="gap:6px">
<button class="btn-small btn-primary" id="memEditSave_${id}">Save</button>
<button class="btn-small" id="memEditCancel_${id}">Cancel</button>
</div>
</div>
`;
document.getElementById(`memEditSave_${id}`)?.addEventListener('click', async () => {
const key = document.getElementById(`memEdit_key_${id}`).value.trim();
const value = document.getElementById(`memEdit_val_${id}`).value.trim();
if (!key || !value) return UI.toast('Key and value are required', 'warning');
try {
await API.updateMemory(id, { key, value });
UI.toast('Memory updated', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById(`memEditCancel_${id}`)?.addEventListener('click', () => {
this.loadMemories();
});
},
async approveAllPending() {
try {
const data = await API.listMemories('pending_review', '');
const ids = (data.data || []).map(m => m.id);
if (!ids.length) return UI.toast('No pending memories', 'info');
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
this.openSettingsPanel();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ═════════════════════════════════════════
// Admin Panel — Memory Review
// ═════════════════════════════════════════
async openAdminPanel() {
const el = document.getElementById('adminMemoryContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPendingMemories();
const pending = data.data || [];
el.innerHTML = `
<div class="admin-toolbar">
<span class="admin-toolbar-label">${pending.length} pending memories</span>
${pending.length > 0 ? `
<button class="btn-small btn-primary" id="adminBulkApproveBtn">✓ Approve All</button>
` : ''}
<button class="btn-small" id="adminMemoryRefreshBtn">↻ Refresh</button>
</div>
<div id="adminMemoryList" class="memory-list">
${pending.length === 0 ? '<div class="empty-hint">No memories pending review</div>' : ''}
${pending.map(m => this._renderAdminMemoryRow(m)).join('')}
</div>
`;
// Wire events
document.getElementById('adminBulkApproveBtn')?.addEventListener('click', async () => {
const ids = pending.map(m => m.id);
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
try {
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
MemoryUI.openAdminPanel();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminMemoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openAdminPanel());
// Wire per-row actions
el.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
try {
if (btn.dataset.action === 'approve') {
await API.approveMemory(id);
UI.toast('Approved', 'success');
} else if (btn.dataset.action === 'reject') {
await API.rejectMemory(id);
UI.toast('Rejected', 'success');
}
MemoryUI.openAdminPanel();
} catch (e) { UI.toast(e.message, 'error'); }
});
});
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
_renderAdminMemoryRow(m) {
const confidence = Math.round(m.confidence * 100);
const scope = m.scope === 'persona_user' ? 'persona' : m.scope;
return `
<div class="memory-card memory-card-pending">
<div class="memory-card-header">
<span class="memory-key">${esc(m.key)}</span>
<div class="memory-badges">
<span class="memory-scope-badge memory-scope-${m.scope}">${scope}</span>
<span class="memory-confidence">${confidence}%</span>
<span class="memory-owner">${esc(m.owner_id?.substring(0, 8) || '?')}…</span>
</div>
</div>
<div class="memory-card-value">${esc(m.value)}</div>
<div class="memory-card-footer">
<span class="memory-date">${m.updated_at ? new Date(m.updated_at).toLocaleDateString() : ''}</span>
<div class="memory-card-actions">
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}">✓ Approve</button>
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}">✕ Reject</button>
</div>
</div>
</div>`;
},
// ═════════════════════════════════════════
// Persona Form Memory Fields
// ═════════════════════════════════════════
// Call this after renderPersonaForm to append memory fields.
// container: the form container element
// prefix: the form prefix (e.g. 'pf', 'apf')
appendMemoryFields(container, prefix) {
const section = document.createElement('div');
section.className = 'memory-persona-section';
section.innerHTML = `
<div class="form-divider"></div>
<h4 class="form-section-title">Memory</h4>
<label class="checkbox-label">
<input type="checkbox" id="${prefix}_memoryEnabled" checked>
Enable memory for this persona
</label>
<p class="section-hint" style="margin:4px 0 8px">When enabled, this persona can save and recall memories from conversations.</p>
<div class="form-group" id="${prefix}_memoryPromptGroup">
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
<textarea id="${prefix}_memoryExtractionPrompt" rows="3" placeholder="Leave blank for default extraction behavior. Custom prompts let you control what facts this persona remembers."></textarea>
</div>
`;
// Insert before the submit/cancel buttons
const submitRow = container.querySelector('.form-row:last-child');
if (submitRow) {
container.insertBefore(section, submitRow);
} else {
container.appendChild(section);
}
},
// Read memory values from a persona form
getMemoryValues(prefix) {
const enabled = document.getElementById(`${prefix}_memoryEnabled`)?.checked ?? true;
const prompt = document.getElementById(`${prefix}_memoryExtractionPrompt`)?.value.trim() || null;
return { memory_enabled: enabled, memory_extraction_prompt: prompt };
},
// Set memory values in a persona form
setMemoryValues(prefix, persona) {
const enabledEl = document.getElementById(`${prefix}_memoryEnabled`);
if (enabledEl) enabledEl.checked = persona.memory_enabled !== false;
const promptEl = document.getElementById(`${prefix}_memoryExtractionPrompt`);
if (promptEl) promptEl.value = persona.memory_extraction_prompt || '';
},
};
// ── Debounce helper ──────────────────────────
function _debounce(fn, ms) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
// ── Exports ─────────────────────────────────
sb.ns('MemoryUI', MemoryUI);

View File

@@ -1,169 +0,0 @@
/* ─────────────────────────────────────────────
Notification Preferences (v0.20.0 Phase 3)
Settings → Notifications tab
───────────────────────────────────────────── */
// eslint-disable-next-line no-unused-vars
//
// Exports: window.NotifPrefs
const NotifPrefs = {
_loaded: false,
_prefs: [],
// Known notification types for the UI
_types: [
{ key: 'kb.ready', label: 'Knowledge base ready' },
{ key: 'kb.error', label: 'Knowledge base error' },
{ key: 'role.fallback', label: 'Model fallback' },
{ key: 'grant.changed', label: 'Group membership change' },
{ key: '*', label: 'Default (all others)' },
],
async load() {
try {
this._prefs = await API.listNotifPrefs() || [];
} catch (e) {
console.warn('[notif-prefs] failed to load:', e.message);
this._prefs = [];
}
this._loaded = true;
this.render();
},
render() {
const container = document.getElementById('notifPrefsBody');
if (!container) return;
const prefMap = {};
for (const p of this._prefs) {
prefMap[p.type] = p;
}
let html = '';
for (const t of this._types) {
const p = prefMap[t.key];
const inApp = p ? p.in_app : true;
const email = p ? p.email : false;
html += `<tr class="notif-pref-row" data-type="${esc(t.key)}">
<td class="notif-pref-label">${esc(t.label)}</td>
<td class="notif-pref-toggle">
<label class="checkbox-label">
<input type="checkbox" class="notif-pref-inapp" ${inApp ? 'checked' : ''}>
</label>
</td>
<td class="notif-pref-toggle">
<label class="checkbox-label">
<input type="checkbox" class="notif-pref-email" ${email ? 'checked' : ''}>
</label>
</td>
</tr>`;
}
container.innerHTML = `<table class="notif-pref-table">
<thead><tr>
<th>Notification Type</th>
<th>In-App</th>
<th>Email</th>
</tr></thead>
<tbody>${html}</tbody>
</table>`;
// Attach change listeners
container.querySelectorAll('.notif-pref-row').forEach(row => {
const type = row.dataset.type;
row.querySelector('.notif-pref-inapp').addEventListener('change', (e) => {
this._save(type, { in_app: e.target.checked });
});
row.querySelector('.notif-pref-email').addEventListener('change', (e) => {
this._save(type, { email: e.target.checked });
});
});
},
async _save(type, patch) {
try {
await API.setNotifPref(type, patch);
// Update cache
const existing = this._prefs.find(p => p.type === type);
if (existing) {
Object.assign(existing, patch);
} else {
this._prefs.push({ type, in_app: true, email: false, ...patch });
}
} catch (e) {
UI.toast('Failed to save preference', 'error');
this.render(); // revert checkboxes
}
},
};
// ── Admin SMTP UI helpers ───────────────────
// Called from index.html onclick
// eslint-disable-next-line no-unused-vars
NotifPrefs._testEmail = async function() {
const status = document.getElementById('smtpTestStatus');
const btn = document.getElementById('adminSmtpTestBtn');
if (!status || !btn) return;
btn.disabled = true;
status.textContent = 'Sending…';
status.className = 'smtp-test-status';
try {
const result = await API.adminTestEmail();
status.textContent = `✓ Sent to ${result.sent_to}`;
status.className = 'smtp-test-status success';
} catch (e) {
status.textContent = '✗ ' + (e.message || 'Failed');
status.className = 'smtp-test-status error';
} finally {
btn.disabled = false;
}
};
NotifPrefs._initAdminSmtp = function() {
const toggle = document.getElementById('adminEmailEnabled');
const fields = document.getElementById('smtpConfigFields');
if (!toggle || !fields) return;
toggle.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none';
});
};
NotifPrefs._loadAdminSmtp = function(cfg) {
if (!cfg) return;
const el = (id) => document.getElementById(id);
if (cfg.email_enabled) el('adminEmailEnabled').checked = true;
if (cfg.smtp_host) el('adminSmtpHost').value = cfg.smtp_host;
if (cfg.smtp_port) el('adminSmtpPort').value = cfg.smtp_port;
if (cfg.smtp_user) el('adminSmtpUser').value = cfg.smtp_user;
// Don't populate password (security)
if (cfg.smtp_from) el('adminSmtpFrom').value = cfg.smtp_from;
if (cfg.smtp_tls != null) el('adminSmtpTls').value = String(cfg.smtp_tls);
// Show/hide SMTP fields
const fields = document.getElementById('smtpConfigFields');
if (fields) fields.style.display = cfg.email_enabled ? '' : 'none';
};
NotifPrefs._getAdminSmtp = function() {
const el = (id) => document.getElementById(id);
const config = {
email_enabled: el('adminEmailEnabled')?.checked || false,
smtp_host: el('adminSmtpHost')?.value?.trim() || '',
smtp_port: parseInt(el('adminSmtpPort')?.value) || 587,
smtp_user: el('adminSmtpUser')?.value?.trim() || '',
smtp_from: el('adminSmtpFrom')?.value?.trim() || '',
smtp_tls: el('adminSmtpTls')?.value === 'true',
};
// Only include password if user typed something new
const pw = el('adminSmtpPassword')?.value;
if (pw) config.smtp_password = pw;
return config;
};
// ── Exports ─────────────────────────────────
sb.ns('NotifPrefs', NotifPrefs);

View File

@@ -1,183 +0,0 @@
// persona-kb.js — PersonaKnowledge Base binding UI (v0.17.0)
//
// Provides renderPersonaKBPicker(container, opts) which renders a multi-select
// KB picker with auto_search toggles for use in persona create/edit forms.
// Pattern: (container, options) → control object with getValues/setValues/clear.
//
// Exports: window.renderPersonaKBPicker,window.loadPersonaKBs,window.savePersonaKBs
// ── Persona-KB Picker ───────────────────────────
/**
* Renders a KB picker into a container element.
*
* Options:
* scope - 'admin' | 'team' | 'personal' — controls which KBs are shown
* teamId - required for team scope
* prefix - DOM id prefix (default: 'pkb')
*
* Returns: { getValues(), setValues(kbs), clear(), refresh() }
*/
function renderPersonaKBPicker(containerEl, options = {}) {
const pfx = options.prefix || 'pkb';
let _allKBs = []; // available KBs from server
let _selected = {}; // kb_id → { selected: bool, auto_search: bool }
containerEl.innerHTML = `
<div class="persona-kb-picker" id="${pfx}_picker">
<label>Knowledge Bases</label>
<div class="form-hint">Bind KBs to this persona — users will search these KBs automatically when using this persona.</div>
<div class="persona-kb-list" id="${pfx}_list">
<div class="empty-hint">Loading knowledge bases…</div>
</div>
</div>
`;
// ── Load available KBs ──
async function loadKBs() {
try {
let resp;
if (options.scope === 'team' && options.teamId) {
resp = await API._get(`/api/v1/teams/${options.teamId}/knowledge-bases`);
} else if (options.scope === 'admin') {
resp = await API._get('/api/v1/knowledge-bases');
} else {
// User scope — show discoverable KBs
resp = await API._get('/api/v1/knowledge-bases-discoverable');
}
_allKBs = (resp.data || []).filter(kb => kb.chunk_count > 0);
} catch (e) {
_allKBs = [];
}
render();
}
// ── Render KB list ──
function render() {
const list = document.getElementById(`${pfx}_list`);
if (!list) return;
if (_allKBs.length === 0) {
list.innerHTML = '<div class="empty-hint">No knowledge bases with content available.</div>';
return;
}
list.innerHTML = _allKBs.map(kb => {
const sel = _selected[kb.id] || { selected: false, auto_search: false };
return `
<label class="persona-kb-item ${sel.selected ? 'selected' : ''}">
<input type="checkbox" data-kb-id="${esc(kb.id)}" ${sel.selected ? 'checked' : ''}>
<span class="kb-name">${esc(kb.name)}</span>
<span class="kb-meta">${kb.document_count} docs · ${kb.chunk_count} chunks</span>
<label class="auto-search-toggle ${sel.selected ? '' : 'hidden'}" title="Auto-inject top results into context (no tool call needed)">
<input type="checkbox" data-kb-auto="${esc(kb.id)}" ${sel.auto_search ? 'checked' : ''}>
<span>Auto-inject</span>
</label>
</label>`;
}).join('');
// Wire checkboxes
list.querySelectorAll('input[data-kb-id]').forEach(cb => {
cb.addEventListener('change', function() {
const kbId = this.dataset.kbId;
if (!_selected[kbId]) _selected[kbId] = { selected: false, auto_search: false };
_selected[kbId].selected = this.checked;
render();
});
});
list.querySelectorAll('input[data-kb-auto]').forEach(cb => {
cb.addEventListener('change', function() {
const kbId = this.dataset.kbAuto;
if (_selected[kbId]) {
_selected[kbId].auto_search = this.checked;
}
});
});
}
// ── Control object ──
const control = {
getValues() {
const kbIds = [];
const autoSearch = {};
for (const [kbId, state] of Object.entries(_selected)) {
if (state.selected) {
kbIds.push(kbId);
if (state.auto_search) {
autoSearch[kbId] = true;
}
}
}
return { kb_ids: kbIds, auto_search: autoSearch };
},
setValues(personaKBs) {
_selected = {};
if (Array.isArray(personaKBs)) {
for (const pkb of personaKBs) {
_selected[pkb.kb_id] = {
selected: true,
auto_search: !!pkb.auto_search,
};
}
}
render();
},
clear() {
_selected = {};
render();
},
async refresh() {
await loadKBs();
}
};
// Auto-load on create
loadKBs();
return control;
}
// ── Persona-KB Save/Load Helpers ────────────────
/**
* Load persona KB bindings and populate the picker.
* @param {string} personaId
* @param {object} kbPicker - control object from renderPersonaKBPicker
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
*/
async function loadPersonaKBs(personaId, kbPicker, apiPrefix) {
if (!personaId || !kbPicker) return;
try {
const resp = await API._get(`${apiPrefix}/personas/${personaId}/knowledge-bases`);
kbPicker.setValues(resp.data || []);
} catch (e) {
console.warn('Failed to load persona KBs:', e.message);
}
}
/**
* Save persona KB bindings from the picker.
* @param {string} personaId
* @param {object} kbPicker - control object from renderPersonaKBPicker
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
*/
async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
if (!personaId || !kbPicker) return;
const values = kbPicker.getValues();
try {
await API._put(`${apiPrefix}/personas/${personaId}/knowledge-bases`, values);
} catch (e) {
console.warn('Failed to save persona KBs:', e.message);
}
}
// ── Exports ─────────────────────────────────
sb.register('renderPersonaKBPicker', renderPersonaKBPicker);
sb.register('loadPersonaKBs', loadPersonaKBs);
sb.register('savePersonaKBs', savePersonaKBs);

View File

@@ -1,908 +0,0 @@
// ==========================================
// Chat Switchboard Settings Handlers
// ==========================================
// Settings save, provider CRUD, avatar, command palette,
// user model roles.
// ── Settings ─────────────────────────────────
async function loadSettings() {
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
}
async function saveSettings() {
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
});
} catch (e) { console.warn('Settings sync failed:', e.message); }
}
// ── Avatar Preview ──────────────────────────
function updateAvatarPreview(dataURI) {
const preview = document.getElementById('avatarPreview');
const letter = document.getElementById('avatarPreviewLetter');
const removeBtn = document.getElementById('avatarRemoveBtn');
const existingImg = preview.querySelector('img');
if (dataURI) {
letter.style.display = 'none';
if (existingImg) {
existingImg.src = dataURI;
} else {
const img = document.createElement('img');
img.src = dataURI;
img.alt = 'Avatar';
preview.insertBefore(img, letter);
}
removeBtn.style.display = '';
} else {
if (existingImg) existingImg.remove();
const name = API.user?.display_name || API.user?.username || '?';
letter.textContent = name[0].toUpperCase();
letter.style.display = '';
removeBtn.style.display = 'none';
}
}
// ── Settings Handlers ────────────────────────
async function handleSaveSettings() {
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
App.settings.showThinking = document.getElementById('settingsThinking').checked;
saveSettings();
// ── Save profile fields (display name, email) ──
const displayName = document.getElementById('profileDisplayName').value.trim();
const email = document.getElementById('profileEmail').value.trim();
const profileUpdates = {};
if (displayName !== (API.user?.display_name || '')) profileUpdates.display_name = displayName;
if (email !== (API.user?.email || '')) profileUpdates.email = email;
if (Object.keys(profileUpdates).length > 0) {
try {
const updated = await API.updateProfile(profileUpdates);
// Sync local user object so sidebar/avatar reflect the change
if (updated) {
if (updated.display_name !== undefined) API.user.display_name = updated.display_name;
if (updated.email !== undefined) API.user.email = updated.email;
API.saveTokens();
UI.updateUser();
}
} catch (e) {
console.warn('Profile save failed:', e.message);
UI.toast('Profile update failed: ' + e.message, 'error');
}
}
UI.updateModelSelector();
UI.updateCapabilityBadges();
UI.closeSettings();
UI.toast('Settings saved', 'success');
}
async function handleChangePassword() {
const cur = document.getElementById('profileCurrentPw').value;
const pw = document.getElementById('profileNewPw').value;
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
try {
await API.changePassword(cur, pw);
UI.toast('Password updated', 'success');
document.getElementById('profileChangePwForm').style.display = 'none';
document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = '';
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── User BYOK Provider — primitive instances (initialized in _initSettingsListeners) ──
var _userProvForm = null;
var _userProvList = null;
function _initUserProviderPrimitives() {
const formEl = document.getElementById('providerAddForm');
const listEl = document.getElementById('providerList');
if (!formEl || !listEl) return;
_userProvForm = renderProviderForm(formEl, {
prefix: 'userProv',
showPrivate: false,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
if (!vals.name || !vals.endpoint) return UI.toast('Fill in required fields', 'warning');
const patch = { name: vals.name, provider: vals.provider, endpoint: vals.endpoint, model_default: vals.model_default };
if (vals.api_key) patch.api_key = vals.api_key;
await API.updateConfig(vals._editId, patch);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) return UI.toast('Fill in required fields', 'warning');
const result = await API.createConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default);
if (result.warning) UI.toast(`Provider added — ${result.warning}`, 'warning');
else {
const count = result.models_fetched || 0;
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
}
}
formEl.style.display = 'none';
_userProvForm.setCreateMode();
_userProvList.refresh();
if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
_userProvForm.setCreateMode();
},
});
_userProvList = renderProviderList(listEl, {
apiFetch: () => API.listConfigs(),
showRefresh: true,
emptyMsg: 'No providers configured.',
onEdit: async (prov) => {
try {
const cfg = await API.getConfig(prov.id);
_userProvForm.setEditMode(cfg.id, cfg);
formEl.style.display = '';
} catch (e) { UI.toast(e.message, 'error'); }
},
onDelete: async (prov) => {
if (!await showConfirm(`Remove provider "${prov.name}"?`)) return;
try {
await API.deleteConfig(prov.id);
UI.toast('Provider removed', 'success');
_userProvList.refresh();
if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onRefresh: async (prov) => {
try {
UI.toast(`Fetching models for ${prov.name}...`, 'info');
const result = await API.fetchProviderModels(prov.id);
const total = result.total || 0;
UI.toast(`${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); }
},
});
}
async function handleSaveAdminSettings() {
try {
// Admin system prompt → global_settings (JSON value)
const sysPromptContent = document.getElementById('adminSystemPrompt').value.trim();
await API.adminUpdateSetting('system_prompt', { value: { content: sysPromptContent } });
// Policies — send as string "true"/"false" so backend routes to platform_policies
const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
// Registration default state → default_user_active policy
const regState = document.getElementById('adminRegDefaultState').value;
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
// User BYOK providers → allow_user_byok policy
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
// User personas → allow_user_personas policy
const userPersonas = document.getElementById('adminUserPersonasToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPersonas ? 'true' : 'false' });
// Default model → default_model policy
const defaultModel = document.getElementById('adminDefaultModel').value;
await API.adminUpdateSetting('default_model', { value: defaultModel });
// KB direct access → kb_direct_access policy (v0.17.0)
const kbDirect = document.getElementById('adminKBDirectAccessToggle')?.checked;
if (kbDirect != null) {
await API.adminUpdateSetting('kb_direct_access', { value: kbDirect ? 'true' : 'false' });
}
// Banner → global_settings (JSON value)
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
text: document.getElementById('adminBannerText').value,
bg: document.getElementById('adminBannerBg').value,
fg: document.getElementById('adminBannerFg').value,
};
await API.adminUpdateSetting('banner', { value: banner });
// Web Search config → global_settings (JSON value)
const searchConfig = {
provider: document.getElementById('adminSearchProvider').value,
endpoint: document.getElementById('adminSearchEndpoint').value.trim(),
api_key: document.getElementById('adminSearchApiKey').value.trim(),
max_results: parseInt(document.getElementById('adminSearchMaxResults').value) || 5,
};
await API.adminUpdateSetting('search_config', { value: searchConfig });
// Auto-Compaction config → global_settings
const compactionEnabled = document.getElementById('adminCompactionEnabled')?.checked || false;
const compactionThreshold = parseInt(document.getElementById('adminCompactionThreshold')?.value) || 70;
const compactionCooldown = parseInt(document.getElementById('adminCompactionCooldown')?.value) || 30;
await API.adminUpdateSetting('auto_compaction', { value: {
enabled: compactionEnabled,
threshold: compactionThreshold,
cooldown: compactionCooldown,
}});
// Scanner reads these individual keys each tick
await API.adminUpdateSetting('auto_compaction_enabled', { value: compactionEnabled });
await API.adminUpdateSetting('auto_compaction_threshold', { value: compactionThreshold / 100 });
await API.adminUpdateSetting('auto_compaction_cooldown_minutes', { value: compactionCooldown });
// Memory extraction (v0.18.0)
const memExtractionEnabled = document.getElementById('adminMemoryExtractionEnabled')?.checked || false;
const memAutoApprove = document.getElementById('adminMemoryAutoApprove')?.checked || false;
await API.adminUpdateSetting('memory_extraction', { value: {
enabled: memExtractionEnabled,
auto_approve: memAutoApprove,
}});
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
// Email / SMTP config (v0.20.0 Phase 3)
if (typeof NotifPrefs !== 'undefined' && NotifPrefs._getAdminSmtp) {
const smtpConfig = NotifPrefs._getAdminSmtp();
await API.adminUpdateSetting('notifications', { value: smtpConfig });
}
UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI
if (typeof initBanners === 'function') await initBanners();
if (typeof UI.checkUserProvidersAllowed === 'function') UI.checkUserProvidersAllowed();
if (typeof UI.checkUserPersonasAllowed === 'function') UI.checkUserPersonasAllowed();
if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── User Model Role — primitive instance (initialized in _initSettingsListeners) ──
var _userRoleConfig = null;
function _initUserRolePrimitive() {
const el = document.getElementById('userRolesConfig');
if (!el) return;
_userRoleConfig = renderRoleConfig(el, {
scope: 'personal',
showFallback: false,
modelIdField: 'baseModelId',
modelNameField: 'name',
providerIdField: 'configId',
noneLabel: '— use org default —',
fetchProviders: async () => {
const configs = await API.listConfigs();
const list = configs.configs || configs.data || [];
return list.filter(c => c.scope === 'personal');
},
fetchModels: async () => {
// Refresh App.models to ensure personal provider models are current.
if (typeof fetchModels === 'function') await fetchModels();
return (typeof App !== 'undefined' && App.models) || [];
},
fetchRoles: async () => {
const settings = await API.getSettings() || {};
return settings.model_roles || {};
},
onSave: async (roleId, config) => {
if (!config.primary) throw new Error('Select both a provider and model');
const settings = await API.getSettings() || {};
const roles = settings.model_roles || {};
roles[roleId] = config;
await API.updateSettings({ model_roles: roles });
UI.toast(`${roleId} role override saved`, 'success');
},
onClear: async (roleId) => {
const settings = await API.getSettings() || {};
const roles = settings.model_roles || {};
delete roles[roleId];
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
UI.toast(`${roleId} role override removed — using org default`, 'success');
},
});
}
// ── Command Palette ──────────────────────────
const _cmdCommands = [
// Navigation
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>', action: () => UI.toggleSidebar() },
// Tools
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
// Settings
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
// Account
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', action: () => handleLogout() },
];
let _cmdActiveIndex = 0;
function toggleCmdPalette() {
const el = document.getElementById('cmdPalette');
if (el.classList.contains('active')) { closeCmdPalette(); return; }
openCmdPalette();
}
function openCmdPalette() {
const el = document.getElementById('cmdPalette');
const input = document.getElementById('cmdInput');
el.classList.add('active');
input.value = '';
_cmdActiveIndex = 0;
_renderCmdResults('');
input.focus();
// Wire up input events (use named handler to avoid duplicates)
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
input.onkeydown = _handleCmdKey;
// Close on overlay click
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
}
function closeCmdPalette() {
const el = document.getElementById('cmdPalette');
el.classList.remove('active');
document.getElementById('cmdInput').oninput = null;
document.getElementById('cmdInput').onkeydown = null;
}
function _handleCmdKey(e) {
const results = document.getElementById('cmdResults');
const items = results.querySelectorAll('.cmd-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
items[_cmdActiveIndex]?.click();
}
}
function _highlightCmdItem(items) {
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
}
function _getVisibleCommands() {
return _cmdCommands.filter(c => !c.visible || c.visible());
}
function _renderCmdResults(query) {
const el = document.getElementById('cmdResults');
const q = query.trim().toLowerCase();
let commands = _getVisibleCommands();
// Add recent chats as commands when searching
if (q) {
const chatMatches = App.chats
.filter(c => (c.title || '').toLowerCase().includes(q))
.slice(0, 5)
.map(c => ({
id: 'chat-' + c.id,
label: c.title || 'Untitled',
group: 'Chats',
hint: _relativeTime(c.updatedAt),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
action: () => selectChat(c.id),
}));
commands = [...chatMatches, ...commands];
}
// Filter by query
if (q) {
commands = commands.filter(c => c.label.toLowerCase().includes(q));
}
if (commands.length === 0) {
el.innerHTML = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
return;
}
// Clamp active index
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
// Render grouped
let html = '';
let lastGroup = '';
commands.forEach((c, i) => {
if (c.group !== lastGroup) {
lastGroup = c.group;
html += `<div class="cmd-group-label">${c.group}</div>`;
}
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
${c.icon}
<span class="cmd-item-label">${c.label}</span>
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
</div>`;
});
el.innerHTML = html;
// Attach click handlers
el.querySelectorAll('.cmd-item').forEach((item, i) => {
item.addEventListener('click', () => {
closeCmdPalette();
commands[i].action();
});
item.addEventListener('mouseenter', () => {
_cmdActiveIndex = i;
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
});
});
}
// ── Settings Listeners (extracted from initListeners) ──
// Settings surface elements (settingsModel, providerShowAddBtn, etc.) only
// exist on the /settings/ surface. Team admin elements (settingsTeamAddMemberBtn,
// etc.) exist on the chat surface. Both are wired here — settings-surface
// features under a guard, team admin features unconditionally via ?.
function _initSettingsListeners() {
// ── Settings surface features (only exist on /settings/) ──
// Use data-surface to detect we're on the settings surface (not chat).
// Individual features guard on their own elements since each section
// only renders its own DOM.
const isSettingsSurface = document.body.dataset.surface === 'settings';
if (isSettingsSurface) {
// General section: model change hint
const settingsModel = document.getElementById('settingsModel');
if (settingsModel) {
settingsModel.addEventListener('change', function() {
const model = App.findModel(this.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
: '';
}
});
}
// Providers section: BYOK form + list primitives
_initUserProviderPrimitives();
document.getElementById('providerShowAddBtn')?.addEventListener('click', () => {
const formEl = document.getElementById('providerAddForm');
if (_userProvForm) _userProvForm.setCreateMode();
if (formEl) formEl.style.display = '';
});
// Models section: role primitives
_initUserRolePrimitive();
}
// ── Team management (team admin self-service, on chat surface) ──
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('settingsTeamAddMember').style.display = '';
const sel = document.getElementById('settingsTeamMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.teamListMembers(UI._managingTeamId);
const existingIds = new Set((resp.data || []).map(m => m.user_id));
sel.innerHTML = '<option value="">Enter user ID or ask sys-admin</option>';
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
});
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddMember').style.display = 'none';
});
var _teamPersonaForm = null;
document.getElementById('settingsTeamAddPersonaBtn')?.addEventListener('click', () => {
const container = document.getElementById('settingsTeamAddPersona');
container.style.display = '';
if (!_teamPersonaForm) {
_teamPersonaForm = renderPersonaForm(container, {
prefix: 'teamPersona',
showAvatar: true,
showProviderConfig: false,
showKBPicker: true,
kbScope: 'team',
kbTeamId: UI._managingTeamId,
onSubmit: async (vals) => {
const teamId = UI._managingTeamId;
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.teamCreatePersona(teamId, vals);
const personaId = result?.id;
if (personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
}
if (personaId && vals._kbValues) {
try { await API.teamSetPersonaKBs(teamId, personaId, vals._kbValues); }
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
}
container.style.display = 'none';
_teamPersonaForm.clearForm();
UI.toast('Team persona created');
await UI.loadTeamManagePersonas(teamId);
if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _teamPersonaForm.clearForm(); }
});
} else {
_teamPersonaForm.clearForm();
}
UI.loadTeamPersonaModelDropdown(UI._managingTeamId);
});
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
const formEl = document.getElementById('settingsTeamProviderForm');
if (!formEl) return;
if (UI._teamProvForm) UI._teamProvForm.setCreateMode();
formEl.style.display = formEl.style.display === 'none' ? '' : 'none';
});
// User — personal personas
var _userPersonaForm = null;
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPersonaForm');
container.style.display = container.style.display === 'none' ? '' : 'none';
if (!_userPersonaForm) {
_userPersonaForm = renderPersonaForm(container, {
prefix: 'userPersona',
showAvatar: true,
showProviderConfig: false,
showKBPicker: true,
kbScope: 'personal',
onSubmit: async (vals) => {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.createUserPersona(vals);
const personaId = result?.id;
if (personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
}
if (personaId && vals._kbValues) {
try { await API.setUserPersonaKBs(personaId, vals._kbValues); }
catch (e) { console.warn('User persona KB binding failed:', e.message); }
}
container.style.display = 'none';
_userPersonaForm.clearForm();
UI.toast('Persona created');
await UI.loadUserPersonas();
if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _userPersonaForm.clearForm(); }
});
}
const sel = _userPersonaForm.getModelSelect();
if (sel) {
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPersona).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
});
}
// Admin settings toggle wiring — safe to call from admin surface scaffold
// (no App dependencies, all optional-chained)
function _initAdminSettingsToggles() {
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
});
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
});
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
});
// Memory extraction toggle
document.getElementById('adminMemoryExtractionEnabled')?.addEventListener('change', (e) => {
const f = document.getElementById('memoryExtractionConfigFields');
if (f) f.style.display = e.target.checked ? '' : 'none';
});
// Admin — audit log
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
// Admin — usage
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
// Admin — settings export/import
document.getElementById('adminExportSettings')?.addEventListener('click', _exportAdminSettings);
const importBtn = document.getElementById('adminImportSettings');
const importFile = document.getElementById('adminImportFile');
if (importBtn && importFile) {
importBtn.addEventListener('click', () => importFile.click());
importFile.addEventListener('change', () => {
if (importFile.files[0]) _importAdminSettings(importFile.files[0]);
importFile.value = ''; // reset so same file can be re-selected
});
}
}
// ── Admin Settings Export ────────────────────
async function _exportAdminSettings() {
try {
const data = await API.adminGetSettings();
const envelope = {
_type: 'switchboard_admin_settings',
_version: 1,
_exported_at: new Date().toISOString(),
_switchboard_version: window.__VERSION__ || 'unknown',
settings: data.settings || {},
policies: data.policies || {},
};
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
a.download = `switchboard-admin-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
// Count sensitive keys so admin is aware
const sensitiveKeys = ['search_config', 'notifications'];
const hasSensitive = sensitiveKeys.some(k => data.settings?.[k]);
UI.toast(
'Settings exported' + (hasSensitive ? ' (includes API keys/credentials)' : ''),
hasSensitive ? 'warning' : 'success'
);
} catch (e) {
UI.toast('Export failed: ' + e.message, 'error');
}
}
// ── Admin Settings Import ────────────────────
async function _importAdminSettings(file) {
try {
const text = await file.text();
let envelope;
try {
envelope = JSON.parse(text);
} catch (_) {
UI.toast('Invalid JSON file', 'error');
return;
}
if (envelope._type !== 'switchboard_admin_settings') {
UI.toast('Not a Switchboard settings export', 'error');
return;
}
const settingsCount = Object.keys(envelope.settings || {}).length;
const policiesCount = Object.keys(envelope.policies || {}).length;
const totalKeys = settingsCount + policiesCount;
if (totalKeys === 0) {
UI.toast('Settings file is empty', 'warning');
return;
}
const meta = envelope._exported_at
? `Exported ${new Date(envelope._exported_at).toLocaleString()}`
: 'Unknown export date';
const ver = envelope._switchboard_version
? ` from v${envelope._switchboard_version}`
: '';
const confirmed = await showConfirm(
`Import ${totalKeys} settings?\n\n` +
`${settingsCount} config keys, ${policiesCount} policies\n` +
`${meta}${ver}\n\n` +
`This will overwrite current admin settings.`
);
if (!confirmed) return;
let applied = 0;
let errors = 0;
// Apply policies (string values)
for (const [key, val] of Object.entries(envelope.policies || {})) {
try {
await API.adminUpdateSetting(key, { value: val });
applied++;
} catch (e) {
console.warn(`[import] policy "${key}" failed:`, e.message);
errors++;
}
}
// Apply settings (JSON values)
for (const [key, val] of Object.entries(envelope.settings || {})) {
try {
await API.adminUpdateSetting(key, { value: val });
applied++;
} catch (e) {
console.warn(`[import] setting "${key}" failed:`, e.message);
errors++;
}
}
if (errors === 0) {
UI.toast(`Imported ${applied} settings`, 'success');
} else {
UI.toast(`Imported ${applied} settings, ${errors} failed (check console)`, 'warning');
}
// Reload the settings form to reflect imported values
if (typeof UI !== 'undefined' && UI.loadAdminSettings) {
await UI.loadAdminSettings();
}
} catch (e) {
UI.toast('Import failed: ' + e.message, 'error');
}
}
// ── User-Facing Model/Persona Functions ──────
// These are called from onclick handlers rendered by loadUserModels() and
// loadUserPersonas(). Defined here so they're available on the settings
// surface (admin-handlers.js which also defines them is only loaded on
// chat and admin surfaces).
if (typeof toggleUserModelVisibility === 'undefined') {
sb.register('toggleUserModelVisibility', async function(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId;
try {
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
else App.hiddenModels.add(compositeKey);
await UI.loadUserModels();
if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
});
}
if (typeof bulkSetUserModelVisibility === 'undefined') {
sb.register('bulkSetUserModelVisibility', async function(show) {
const models = (App.models || []).filter(m => !m.isPersona);
if (!models.length) return;
const shouldHide = !show;
const toUpdate = models
.filter(m => {
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
return App.hiddenModels.has(compositeKey) !== shouldHide;
})
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(e => {
const key = (e.provider_config_id || '') + ':' + e.model_id;
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
});
await UI.loadUserModels();
if (typeof fetchModels === 'function') await fetchModels();
UI.toast(`${toUpdate.length} model${toUpdate.length !== 1 ? 's' : ''} ${show ? 'shown' : 'hidden'}`);
} catch (e) { UI.toast(e.message, 'error'); }
});
}
if (typeof deleteUserPersona === 'undefined') {
sb.register('deleteUserPersona', async function(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.deleteUserPersona(id);
UI.toast('Persona deleted');
await UI.loadUserPersonas();
if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
});
}
// v0.27.0: Team Workflows settings section — lists workflows for user's teams.
// Loaded as a dynamic section in the settings surface.
sb.register('loadTeamWorkflows', async function() {
var el = document.getElementById('settingsDynamic');
if (!el) return;
try {
var teamsResp = await API.listMyTeams();
var teams = teamsResp.data || teamsResp || [];
if (teams.length === 0) {
el.innerHTML = '<div class="settings-placeholder">You are not a member of any teams.</div>';
return;
}
var html = '<p style="font-size:13px;color:var(--text-2);margin-bottom:20px;">' +
'Manage workflows owned by your teams. Use the Admin panel for global workflows.</p>';
for (var t = 0; t < teams.length; t++) {
var team = teams[t];
var wfResp;
try {
wfResp = await API.listWorkflows(team.id);
} catch (_) {
continue;
}
var workflows = wfResp.data || wfResp || [];
html += '<div class="settings-section" style="margin-bottom:16px;">' +
'<h4 style="margin:0 0 10px;font-size:14px;">' + esc(team.name) + '</h4>';
if (workflows.length === 0) {
html += '<div class="settings-placeholder" style="padding:12px;">No workflows for this team.</div>';
} else {
workflows.forEach(function(wf) {
var statusBadge = wf.is_active ?
'<span class="badge badge-ok">Active</span>' :
'<span class="badge">Inactive</span>';
var base = window.__BASE__ || '';
html += '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;' +
'background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;margin-bottom:6px;">' +
'<span style="flex:1;font-weight:500;">' + esc(wf.name) + '</span>' +
statusBadge +
'<span class="badge">v' + wf.version + '</span>' +
'<span style="font-size:11px;color:var(--text-3);">' + esc(wf.slug) + '</span>' +
(API.isAdmin ?
'<a href="' + base + '/admin/workflows" class="btn-small" style="text-decoration:none;">Edit in Admin</a>' : '') +
'</div>';
});
}
html += '</div>';
}
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>';
}
});
// ── Exports ─────────────────────────────────
sb.register('loadSettings', loadSettings);
sb.register('saveSettings', saveSettings);
sb.register('updateAvatarPreview', updateAvatarPreview);
sb.register('_userProvForm', _userProvForm);
sb.register('_userProvList', _userProvList);
sb.register('handleSaveAdminSettings', handleSaveAdminSettings);
sb.register('_userRoleConfig', _userRoleConfig);
sb.register('toggleCmdPalette', toggleCmdPalette);
sb.register('closeCmdPalette', closeCmdPalette);
sb.register('_initSettingsListeners', _initSettingsListeners);
sb.register('_initAdminSettingsToggles', _initAdminSettingsToggles);

View File

@@ -214,12 +214,14 @@ export function createDomains(restClient) {
teams: { teams: {
mine: () => rc.get('/api/v1/teams/mine'), mine: () => rc.get('/api/v1/teams/mine'),
get: (id) => rc.get(`/api/v1/teams/${id}`), 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`), members: (id) => rc.get(`/api/v1/teams/${id}/members`),
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }), 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 }), updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`), removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
personas: (id) => rc.get(`/api/v1/teams/${id}/personas`), personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data), 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}`), deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`), 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), 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)), audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`), auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)), 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 ────────────────────── // ── 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) ───────── // ── Misc (not domain-specific) ─────────
folders: { folders: {
list: () => rc.get('/api/v1/folders'), list: () => rc.get('/api/v1/folders'),

View File

@@ -1,32 +0,0 @@
/**
* BridgeSection — thin Preact wrapper for deferred settings sections
*
* Renders a container <div> with the expected DOM IDs, then calls
* the old JS loader function so legacy code can populate it.
*
* Props:
* id — mount div ID (e.g. 'settingsTasksMount')
* loader — function to call after mount (e.g. () => _loadSettingsTasks?.())
*/
const { html } = window;
const { useEffect, useRef } = hooks;
export function BridgeSection({ id, loader }) {
const ref = useRef(null);
useEffect(() => {
if (loader) {
// Defer to next tick so the DOM node is committed
requestAnimationFrame(() => {
try { loader(); }
catch (e) { console.warn('[settings/bridge]', id, e.message); }
});
}
}, [id, loader]);
return html`
<div ref=${ref} id=${id}>
<div class="settings-placeholder">Loading\u2026</div>
</div>
`;
}

View File

@@ -0,0 +1,59 @@
/**
* Settings > Data & Privacy — export and account deletion
*/
const { html } = window;
const { useState, useCallback } = hooks;
export default function DataSection() {
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const exportData = useCallback(async () => {
setExporting(true);
try {
await sw.api.dataPortability.exportMe();
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setExporting(false); }
}, []);
const deleteAccount = useCallback(async () => {
const ok = await sw.confirm(
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
true,
);
if (!ok) return;
setDeleting(true);
try {
await sw.api.dataPortability.deleteAccount({ confirm: true });
sw.toast('Account deletion initiated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setDeleting(false); }
}, []);
return html`
<div>
<div class="settings-section">
<h3>Export Your Data</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Download a copy of all your data including conversations, settings, and memories.
The export will be prepared and a download link will be provided.
</p>
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
${exporting ? 'Preparing\u2026' : 'Export My Data'}
</button>
</div>
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
<h3 style="color:var(--danger);">Danger Zone</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Permanently delete your account and all associated data. This action cannot be undone.
We recommend exporting your data before proceeding.
</p>
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,97 @@
/**
* Settings > Git Keys — SSH credential management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function GitKeysSection() {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.git.credentials.list();
setKeys(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function addKey(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
const key = form.key.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.git.credentials.create({ name, public_key: key || undefined });
sw.toast('Credential added', 'success');
form.reset();
setShowAdd(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKey(id) {
const ok = await sw.confirm('Delete this SSH credential?', true);
if (!ok) return;
try {
await sw.api.git.credentials.del(id);
sw.toast('Credential deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
${showAdd ? 'Cancel' : '+ Add Key'}
</button>
</div>
${showAdd && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. deploy-key-prod" required />
</div>
<div class="form-group">
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
</div>
<button type="submit" class="btn-small btn-primary">Add Credential</button>
</form>
`}
<div class="admin-list">
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
${keys.map(k => html`
<div class="admin-surface-row" key=${k.id}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
${k.fingerprint && html`
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
${k.fingerprint.substring(0, 24)}\u2026
</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -7,39 +7,34 @@
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template * __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
* *
* Layout: topbar + left nav + content area (same CSS classes as before). * 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 { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks; const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact; const { render } = preact;
import { BridgeSection } from './bridge-section.js';
import { ToastContainer } from '../../primitives/toast.js'; import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js'; import { DialogStack } from '../../shell/dialog-stack.js';
// ── Lazy section imports ──────────────────── // ── Lazy section imports ────────────────────
// Each returns a Promise<{ default: Component }> or { SectionName: Component } // Each returns a Promise<{ default: Component }> or { SectionName: Component }
const sectionModules = { const sectionModules = {
general: () => import('./general.js'), general: () => import('./general.js'),
appearance: () => import('./appearance.js'), appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'), profile: () => import('./profile.js'),
teams: () => import('./teams.js'), teams: () => import('./teams.js'),
models: () => import('./models.js'), models: () => import('./models.js'),
providers: () => import('./providers.js'), providers: () => import('./providers.js'),
personas: () => import('./personas.js'), personas: () => import('./personas.js'),
roles: () => import('./roles.js'), roles: () => import('./roles.js'),
usage: () => import('./usage.js'), usage: () => import('./usage.js'),
}; workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
// ── Bridge section config (deferred to old JS) ── gitkeys: () => import('./gitkeys.js'),
const bridgeSections = { data: () => import('./data.js'),
workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() }, knowledge: () => import('./knowledge.js'),
tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() }, memory: () => import('./memory.js'),
gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() }, notifications: () => import('./notifications.js'),
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?.() },
}; };
// ── Nav structure ─────────────────────────── // ── Nav structure ───────────────────────────
@@ -52,8 +47,11 @@ const NAV_ITEMS = [
{ key: 'teams', label: 'Teams' }, { key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' }, { key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' }, { key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' }, { key: 'gitkeys', label: 'Git Keys' },
{ key: 'data', label: 'Data & Privacy' }, { key: 'knowledge', label: 'Knowledge Bases' },
{ key: 'memory', label: 'Memory' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'data', label: 'Data & Privacy' },
]; ];
const BYOK_ITEMS = [ const BYOK_ITEMS = [
@@ -198,12 +196,9 @@ function SettingsSurface() {
<div class="settings-content"> <div class="settings-content">
<h2>${title}</h2> <h2>${title}</h2>
${bridgeSections[section] ${SectionComponent
? html`<${BridgeSection} id=${bridgeSections[section].id} ? html`<${SectionComponent} />`
loader=${bridgeSections[section].loader} />` : html`<div class="settings-placeholder">Loading\u2026</div>`
: SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
} }
</div> </div>
</div> </div>

View File

@@ -0,0 +1,182 @@
/**
* Settings > Knowledge — personal knowledge base management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function formatBytes(b) {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
export default function KnowledgeSection() {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [docs, setDocs] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.knowledge.list();
setKbs(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(kb) {
setDetail(kb);
try {
const d = await sw.api.knowledge.documents(kb.id);
setDocs(Array.isArray(d) ? d : d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
async function createKb(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.knowledge.create({
name,
description: form.description.value.trim(),
});
sw.toast('Knowledge base created', 'success');
form.reset();
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKb(id) {
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
if (!ok) return;
try {
await sw.api.knowledge.del(id);
sw.toast('Knowledge base deleted', 'success');
setDetail(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function uploadDoc() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.md,.csv,.html,.pdf';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
await sw.api.knowledge.upload(detail.id, file);
sw.toast('Document uploaded', 'success');
const d = await sw.api.knowledge.documents(detail.id);
setDocs(Array.isArray(d) ? d : d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
};
input.click();
}
async function deleteDoc(docId) {
const ok = await sw.confirm('Delete this document?', true);
if (!ok) return;
try {
await sw.api.knowledge.delDoc(detail.id, docId);
sw.toast('Document deleted', 'success');
setDocs(prev => prev.filter(d => d.id !== docId));
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
// Detail view
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
${statusBadge(detail.status || 'active')}
<div style="flex:1"></div>
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
`}
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
</p>
<div class="admin-list">
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
${docs.map(d => html`
<div class="admin-surface-row" key=${d.id}>
<div style="flex:1;min-width:0;">
<strong>${d.filename}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
${' '}${statusBadge(d.status || 'pending')}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
// List view
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
${showCreate ? 'Cancel' : '+ Create'}
</button>
</div>
${showCreate && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. Product Documentation" required />
</div>
<div class="form-group">
<label>Description</label>
<input name="description" placeholder="Optional description" />
</div>
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
</form>
`}
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
${statusBadge(k.status || 'active')}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,117 @@
/**
* Settings > Memory — personal memory management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function truncate(s, len) {
if (!s) return '';
return s.length > len ? s.substring(0, len) + '\u2026' : s;
}
export default function MemorySection() {
const [memories, setMemories] = useState([]);
const [loading, setLoading] = useState(true);
const [count, setCount] = useState(0);
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState('');
const load = useCallback(async () => {
try {
const [data, c] = await Promise.all([
sw.api.memory.list(),
sw.api.memory.count().catch(() => null),
]);
setMemories(Array.isArray(data) ? data : data.data || []);
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
function startEdit(m) {
setEditingId(m.id);
setEditValue(m.value || m.content || '');
}
function cancelEdit() {
setEditingId(null);
setEditValue('');
}
async function saveEdit(id) {
try {
await sw.api.memory.update(id, { value: editValue, content: editValue });
sw.toast('Memory updated', 'success');
setEditingId(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteMemory(id) {
const ok = await sw.confirm('Delete this memory?', true);
if (!ok) return;
try {
await sw.api.memory.del(id);
sw.toast('Memory deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
if (!status) return null;
const cls = status === 'approved' ? 'badge-active'
: status === 'rejected' ? 'badge-inactive'
: 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
<div style="flex:1"></div>
</div>
<div class="admin-list">
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
${memories.map(m => html`
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
${editingId === m.id ? html`
<div>
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
value=${editValue}
onInput=${e => setEditValue(e.target.value)} />
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
</div>
</div>
` : html`
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
</div>
${statusBadge(m.status)}
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
</div>
<div style="display:flex;gap:6px;margin-top:6px;">
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
</div>
`}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,96 @@
/**
* Settings > Notifications — notification preferences
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function NotificationsSection() {
const [prefs, setPrefs] = useState(null);
const [saving, setSaving] = useState({});
const load = useCallback(async () => {
try {
const data = await sw.api.notifications.prefs();
// Normalize: could be an array of { type, enabled, ... } or an object keyed by type
if (Array.isArray(data)) {
const obj = {};
data.forEach(p => { obj[p.type] = p; });
setPrefs(obj);
} else {
setPrefs(data || {});
}
} catch (e) { sw.toast(e.message, 'error'); setPrefs({}); }
}, []);
useEffect(() => { load(); }, []);
async function togglePref(type, currentEnabled) {
setSaving(prev => ({ ...prev, [type]: true }));
try {
await sw.api.notifications.setPref(type, { enabled: !currentEnabled });
setPrefs(prev => ({
...prev,
[type]: { ...prev[type], enabled: !currentEnabled },
}));
sw.toast('Preference updated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(prev => ({ ...prev, [type]: false })); }
}
async function resetPref(type) {
setSaving(prev => ({ ...prev, [type]: true }));
try {
await sw.api.notifications.delPref(type);
sw.toast('Reset to default', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(prev => ({ ...prev, [type]: false })); }
}
if (prefs === null) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const types = Object.keys(prefs);
function friendlyName(type) {
return type
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
return html`
<div>
${types.length === 0 && html`<div class="empty-hint">No notification preferences available.</div>`}
<div class="admin-list">
${types.map(type => {
const pref = prefs[type];
const enabled = pref.enabled !== false;
const isSaving = saving[type];
return html`
<div class="admin-surface-row" key=${type}>
<div style="flex:1;min-width:0;">
<strong>${friendlyName(type)}</strong>
${pref.description && html`
<div class="text-muted" style="font-size:12px;margin-top:2px;">${pref.description}</div>
`}
</div>
<label class="toggle-label" style="margin:0;">
<input type="checkbox"
checked=${enabled}
disabled=${isSaving}
onChange=${() => togglePref(type, enabled)} />
<span class="toggle-track"></span>
</label>
<button class="btn-small btn-ghost"
disabled=${isSaving}
onClick=${() => resetPref(type)}
title="Reset to default">
Reset
</button>
</div>
`;
})}
</div>
</div>
`;
}

View File

@@ -0,0 +1,71 @@
/**
* Settings > Tasks — user's tasks with run/stop controls
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function TasksSection() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.tasks.list();
setTasks(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function runTask(id) {
try {
await sw.api.tasks.start(id);
sw.toast('Task started', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function stopTask(id) {
try {
await sw.api.tasks.stop(id);
sw.toast('Task stopped', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
${tasks.map(t => html`
<div class="admin-surface-row" key=${t.id}>
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
${t.schedule && html`
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
${t.is_active ? 'running' : 'stopped'}
</span>
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,88 @@
/**
* Settings > Workflows — read-only view of user's visible workflows
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function WorkflowsSection() {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [stages, setStages] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(wf) {
setDetail(wf);
try {
const d = await sw.api.workflows.get(wf.id);
setStages(d.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
${detail.is_active ? 'active' : 'inactive'}
</span>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
`}
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
<span>Slug: <strong>/${detail.slug}</strong></span>
<span>Entry: <strong>${detail.entry_mode}</strong></span>
<span>Version: <strong>${detail.version || 0}</strong></span>
</div>
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
</div>
`)}
</div>
</div>
`;
return html`
<div>
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
${w.is_active ? 'active' : 'inactive'}
</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,88 @@
/**
* Team Admin > Activity (Audit Log)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function ActivitySection({ teamId }) {
const [entries, setEntries] = useState([]);
const [actions, setActions] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filterAction, setFilterAction] = useState('');
const [filterResource, setFilterResource] = useState('');
const [loading, setLoading] = useState(true);
const perPage = 50;
const load = useCallback(async () => {
setLoading(true);
try {
const opts = { page, per_page: perPage };
if (filterAction) opts.action = filterAction;
if (filterResource) opts.resource_type = filterResource;
const data = await sw.api.teams.audit(teamId, opts);
setEntries(data.data || []);
setTotal(data.total || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId, page, filterAction, filterResource]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
sw.api.teams.auditActions(teamId).then(d => setActions(d.actions || [])).catch(() => {});
}, [teamId]);
const totalPages = Math.ceil(total / perPage) || 1;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
// Extract unique resource types from actions (action format: resource.verb)
const resourceTypes = [...new Set(actions.map(a => a.split('.')[0]).filter(Boolean))];
return html`
<div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label>Action</label>
<select value=${filterAction} onChange=${e => { setFilterAction(e.target.value); setPage(1); }}>
<option value="">All</option>
${actions.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
</select>
</div>
<div class="form-group"><label>Resource</label>
<select value=${filterResource} onChange=${e => { setFilterResource(e.target.value); setPage(1); }}>
<option value="">All</option>
${resourceTypes.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
${loading
? html`<div class="settings-placeholder">Loading\u2026</div>`
: html`
<div class="admin-list">
${entries.length === 0 && html`<div class="empty-hint">No activity entries</div>`}
${entries.map(e => html`
<div class="admin-surface-row" key=${e.id} style="font-size:12px;">
<span style="width:140px;">${fmtDate(e.created_at)}</span>
<span style="width:100px;"><strong>${e.actor_name || '\u2014'}</strong></span>
<span class="badge" style="margin:0 4px;">${e.action}</span>
<span style="flex:1;color:var(--text-3);">${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}</span>
<span class="text-muted" style="width:100px;">${e.ip_address || ''}</span>
</div>
`)}
</div>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
</div>
`
}
</div>
`;
}

View File

@@ -0,0 +1,37 @@
/**
* Team Admin > Groups
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function GroupsSection({ teamId }) {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.groups(teamId);
setGroups(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${groups.length === 0 && html`<div class="empty-hint">No groups</div>`}
${groups.map(g => html`
<div class="admin-surface-row" key=${g.id}>
<strong style="flex:1;">${g.name}</strong>
<span class="text-muted" style="font-size:12px;">${g.member_count || 0} members</span>
<span class="badge">${(g.permissions || []).length} permissions</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,229 @@
/**
* TeamAdminSurface — root team admin surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
*
* Layout: topbar (back + team name) + sidebar nav + content area.
* All 10 sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Section config ──────────────────────────
const SECTIONS = [
{ key: 'members', label: 'Members' },
{ key: 'providers', label: 'Providers' },
{ key: 'personas', label: 'Personas' },
{ key: 'knowledge', label: 'Knowledge' },
{ key: 'groups', label: 'Groups' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'settings', label: 'Settings' },
{ key: 'usage', label: 'Usage' },
{ key: 'activity', label: 'Activity' },
];
// ── Lazy section imports ────────────────────
const sectionModules = {
members: () => import('./members.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
knowledge: () => import('./knowledge.js'),
groups: () => import('./groups.js'),
workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
settings: () => import('./settings.js'),
usage: () => import('./usage.js'),
activity: () => import('./activity.js'),
};
function TeamAdminSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'members';
const [SectionComponent, setSectionComponent] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
// Determine teams this user can admin
const adminTeams = useMemo(() => {
const teams = sw.auth?.teams || [];
if (sw.isAdmin) return teams;
return teams.filter(t => t.my_role === 'admin');
}, []);
// Auto-select if only one team
useEffect(() => {
if (adminTeams.length === 1 && !selectedTeam) {
setSelectedTeam(adminTeams[0]);
}
}, [adminTeams]);
// Load section component
useEffect(() => {
setSectionComponent(null);
const loader = sectionModules[section];
if (loader) {
loader().then(mod => {
const Comp = mod.default || Object.values(mod)[0];
setSectionComponent(() => Comp);
}).catch(e => console.error('[team-admin] Failed to load section:', section, e));
}
}, [section]);
// Back button with return URL stash
useEffect(() => {
const RETURN_KEY = 'sb_team_admin_return';
if (!sessionStorage.getItem(RETURN_KEY)) {
const ref = document.referrer;
if (ref && ref.startsWith(location.origin) && !ref.includes('/team-admin')) {
sessionStorage.setItem(RETURN_KEY, ref);
} else {
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
}
}
}, []);
const backClick = useCallback((e) => {
e.preventDefault();
const RETURN_KEY = 'sb_team_admin_return';
const returnURL = sessionStorage.getItem(RETURN_KEY);
sessionStorage.removeItem(RETURN_KEY);
location.href = returnURL || BASE + '/';
}, []);
const navClick = useCallback((e) => {
e.preventDefault();
const href = e.currentTarget.getAttribute('href');
if (href) location.replace(href);
}, []);
const teamId = selectedTeam?.id || selectedTeam?.team_id || null;
const teamName = selectedTeam?.name || selectedTeam?.team_name || 'Team';
const sectionLabel = SECTIONS.find(s => s.key === section)?.label || 'Members';
// Team picker if multiple teams
if (!selectedTeam && adminTeams.length > 1) {
return html`
<div class="surface-settings">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">Team Administration</span>
</div>
<div style="padding:32px;max-width:480px;margin:0 auto;">
<h3 style="margin:0 0 16px;">Select a Team</h3>
<div class="admin-list">
${adminTeams.map(t => html`
<div class="admin-surface-row" key=${t.id || t.team_id}
style="cursor:pointer;" onClick=${() => setSelectedTeam(t)}>
<strong style="flex:1;">${t.name || t.team_name}</strong>
<span class="badge">${t.my_role || 'admin'}</span>
</div>
`)}
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// No teams
if (!selectedTeam && adminTeams.length === 0) {
return html`
<div class="surface-settings">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">Team Administration</span>
</div>
<div style="padding:32px;text-align:center;">
<div class="empty-hint">You are not an admin of any team.</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
return html`
<div class="surface-settings">
${/* Top Bar */``}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">
${teamName}
${adminTeams.length > 1 && html`
<button class="btn-small btn-ghost" style="margin-left:8px;font-size:11px;"
onClick=${() => setSelectedTeam(null)}>Switch Team</button>
`}
</span>
</div>
<div class="settings-body">
${/* Left Nav */``}
<div class="settings-nav">
${SECTIONS.map(s => html`
<a key=${s.key} href="${BASE}/team-admin/${s.key}"
class="settings-nav-link ${section === s.key ? 'active' : ''}"
onClick=${navClick}>
${s.label}
</a>
`)}
</div>
${/* Content */``}
<div class="settings-content">
<div class="settings-content-header">
<h2>${sectionLabel}</h2>
</div>
<div class="settings-content-body">
${SectionComponent && teamId
? html`<${SectionComponent} teamId=${teamId} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('team-admin-mount');
if (mount) {
render(html`<${TeamAdminSurface} />`, mount);
console.log('[team-admin] Surface mounted');
}
export { TeamAdminSurface };

View File

@@ -0,0 +1,44 @@
/**
* Team Admin > Knowledge
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function KnowledgeSection({ teamId }) {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.knowledgeBases(teamId);
setKbs(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,114 @@
/**
* Team Admin > Members
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function MembersSection({ teamId }) {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [users, setUsers] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.members(teamId);
setMembers(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function openAdd() {
setShowAdd(true);
try {
const data = await sw.api.admin.users.list();
setUsers(Array.isArray(data) ? data : data.users || data.data || []);
} catch {
// non-admin may not have access — leave empty
setUsers([]);
}
}
async function addMember(e) {
e.preventDefault();
const form = e.target;
const userId = form.userId.value;
const role = form.role.value;
if (!userId) return;
try {
await sw.api.teams.addMember(teamId, userId, role);
sw.toast('Member added', 'success');
setShowAdd(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateRole(memberId, newRole) {
try {
await sw.api.teams.updateMember(teamId, memberId, newRole);
sw.toast('Role updated', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function removeMember(memberId) {
const ok = await sw.confirm('Remove this member from the team?', true);
if (!ok) return;
try {
await sw.api.teams.removeMember(teamId, memberId);
sw.toast('Member removed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Member</button>
</div>
${showAdd && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${addMember}>
<div class="form-row">
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select\u2026</option>
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.username || u.email}</option>`)}
</select>
</div>
<div class="form-group"><label>Role</label>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAdd(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
<strong style="flex:1;">${m.username || m.user_id}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,106 @@
/**
* Team Admin > Personas
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function PersonasSection({ teamId }) {
const [personas, setPersonas] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | persona object
const load = useCallback(async () => {
try {
const data = await sw.api.teams.personas(teamId);
setPersonas(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function savePersona(e) {
e.preventDefault();
const form = e.target;
const data = {
name: form.pname.value.trim(),
description: form.description.value.trim(),
system_prompt: form.system_prompt.value,
};
try {
if (editing === 'new') {
await sw.api.teams.createPersona(teamId, data);
sw.toast('Persona created', 'success');
} else {
await sw.api.teams.updatePersona(teamId, editing.id, data);
sw.toast('Persona updated', 'success');
}
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deletePersona(pid) {
const ok = await sw.confirm('Delete this persona?', true);
if (!ok) return;
try {
await sw.api.teams.deletePersona(teamId, pid);
sw.toast('Persona deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) {
const isNew = editing === 'new';
const p = isNew ? {} : editing;
return html`
<form onSubmit=${savePersona}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
</div>
<div class="form-group"><label>Name</label>
<input name="pname" value=${p.name || ''} placeholder="Code Reviewer" />
</div>
<div class="form-group"><label>Description</label>
<input name="description" value=${p.description || ''} placeholder="Optional description" />
</div>
<div class="form-group"><label>System Prompt</label>
<textarea name="system_prompt" rows="6" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
</div>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`;
}
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
</div>
<div class="admin-list">
${personas.length === 0 && html`<div class="empty-hint">No personas</div>`}
${personas.map(p => html`
<div class="admin-surface-row" key=${p.id}>
<div style="flex:1;min-width:0;">
<strong>${p.name}</strong>
${p.description && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${p.description.substring(0, 60)}${p.description.length > 60 ? '\u2026' : ''}</span>`}
</div>
<span class="text-muted" style="font-size:11px;">${p.kb_count || 0} KBs</span>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,123 @@
/**
* Team Admin > Providers
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function ProvidersSection({ teamId }) {
const [configs, setConfigs] = useState([]);
const [providerTypes, setProviderTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | config object
const load = useCallback(async () => {
try {
const [c, t] = await Promise.all([
sw.api.teams.providers(teamId),
sw.api.admin.providers.types().catch(() => ({ types: [] })),
]);
setConfigs(Array.isArray(c) ? c : c.data || []);
setProviderTypes(t.types || t.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function saveProvider(e) {
e.preventDefault();
const form = e.target;
const data = {
name: form.pname.value.trim(),
provider: form.provider.value,
endpoint: form.endpoint.value.trim(),
api_key: form.api_key.value || undefined,
};
try {
if (editing === 'new') {
await sw.api.teams.createProvider(teamId, data);
sw.toast('Provider created', 'success');
} else {
await sw.api.teams.updateProvider(teamId, editing.id, data);
sw.toast('Provider updated', 'success');
}
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function syncModels(pid) {
try {
const result = await sw.api.teams.providerModels(teamId, pid);
sw.toast(`Models synced: ${result.added || result.count || 0}`, 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteProvider(pid) {
const ok = await sw.confirm('Delete this provider config?', true);
if (!ok) return;
try {
await sw.api.teams.deleteProvider(teamId, pid);
sw.toast('Provider deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
</div>
${editing && html`
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
<div class="form-row">
<div class="form-group"><label>Name</label>
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
</div>
<div class="form-group"><label>Type</label>
<select name="provider">
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:2;"><label>Endpoint</label>
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
</div>
<div class="form-group" style="flex:1;"><label>API Key</label>
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
</div>
</div>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`}
<div class="provider-cards">
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
${configs.map(c => html`
<div class="provider-card" key=${c.id}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong>${c.name}</strong>
<span class="badge">${c.provider}</span>
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-inactive">no key</span>`}
</div>
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,72 @@
/**
* Team Admin > Settings
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function SettingsSection({ teamId }) {
const [team, setTeam] = useState(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.get(teamId);
setTeam(data);
setName(data.name || '');
setDescription(data.description || '');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function save() {
setSaving(true);
try {
await sw.api.teams.update(teamId, {
name: name.trim(),
description: description.trim(),
});
sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div class="admin-settings-form">
<div class="settings-section">
<h3>Team Details</h3>
<div class="form-group">
<label>Team Name</label>
<input value=${name} onInput=${e => setName(e.target.value)} placeholder="My Team" />
</div>
<div class="form-group">
<label>Description</label>
<textarea rows="3" value=${description}
onInput=${e => setDescription(e.target.value)}
placeholder="Optional description\u2026"></textarea>
</div>
</div>
${team && html`
<div class="settings-section">
<h3>Info</h3>
<div class="text-muted" style="font-size:12px;">
<div>ID: ${team.id}</div>
${team.created_at && html`<div>Created: ${new Date(team.created_at).toLocaleString()}</div>`}
${team.member_count != null && html`<div>Members: ${team.member_count}</div>`}
</div>
</div>
`}
<div style="display:flex;gap:8px;align-items:center;margin-top:16px;">
<button class="btn-md btn-primary" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,111 @@
/**
* Team Admin > Tasks
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function TasksSection({ teamId }) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.tasks(teamId);
setTasks(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function createTask(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.createTask(teamId, {
name: form.name.value.trim(),
schedule: form.schedule.value.trim(),
description: form.description.value.trim(),
});
sw.toast('Task created', 'success');
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function runTask(id) {
try {
await sw.api.teams.runTask(teamId, id);
sw.toast('Task triggered', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function killTask(id) {
try {
await sw.api.teams.killTask(teamId, id);
sw.toast('Task cancelled', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteTask(id) {
const ok = await sw.confirm('Delete this task?', true);
if (!ok) return;
try {
await sw.api.teams.deleteTask(teamId, id);
sw.toast('Task deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createTask}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Daily Sync" /></div>
<div class="form-group"><label>Schedule</label><input name="schedule" placeholder="0 9 * * *" /></div>
</div>
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
${tasks.map(t => html`
<div class="admin-surface-row" key=${t.id}>
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
<div class="admin-actions-cell">
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,57 @@
/**
* Team Admin > Usage
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function UsageSection({ teamId }) {
const [usage, setUsage] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.usage(teamId);
setUsage(data);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
function fmtNum(n) {
if (n == null) return '\u2014';
return n.toLocaleString();
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${usage?.totals && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
</div>
`}
${usage?.results && usage.results.length > 0 && html`
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
<div class="admin-list">
${usage.results.map((r, i) => html`
<div class="admin-surface-row" key=${i}>
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
</div>
`)}
</div>
`}
${(!usage?.totals && !usage?.results) && html`
<div class="empty-hint">No usage data available</div>
`}
</div>
`;
}

View File

@@ -0,0 +1,171 @@
/**
* Team Admin > Workflows
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
export default function WorkflowsSection({ teamId }) {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | workflow
const [stages, setStages] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflows(teamId);
setWorkflows(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function openEdit(wf) {
setEditing(wf);
try {
const detail = await sw.api.teams.workflows(teamId);
const match = (Array.isArray(detail) ? detail : detail.data || []).find(w => w.id === wf.id);
setStages(match?.stages || wf.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
async function createWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.createWorkflow(teamId, {
name: form.name.value.trim(),
slug: form.slug.value.trim(),
entry_mode: form.entry_mode.value,
description: form.description.value.trim(),
});
sw.toast('Workflow created', 'success');
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.updateWorkflow(teamId, editing.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
sw.toast('Workflow updated', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow(wfId) {
try {
await sw.api.teams.publishWorkflow(teamId, wfId);
sw.toast('Workflow published', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow(id) {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, id);
sw.toast('Workflow deleted', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${editing.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Customer Intake" /></div>
<div class="form-group"><label>Slug</label><input name="slug" placeholder="customer-intake" /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">${w.is_active ? 'active' : 'inactive'}</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -1,352 +0,0 @@
// ==========================================
// Chat Switchboard — Task Admin Panel
// ==========================================
// Admin section for managing scheduled tasks, run history, and global
// task configuration. Registered as ADMIN_LOADERS.tasks in ui-admin.js.
var SCAFFOLD_HTML =
'<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' +
'<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' +
'<button class="btn-small" id="taskTabConfig">Configuration</button>' +
'<button class="btn-small" id="taskTabSystem">+ System Task</button>' +
'</div>' +
'<div id="taskListPane">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
'<h4 style="margin:0">Scheduled Tasks</h4>' +
'<span id="taskCount" class="badge">0</span>' +
'</div>' +
'<div id="taskAdminList" class="admin-list"></div>' +
'</div>' +
'<div id="taskConfigPane" style="display:none">' +
'<h4 style="margin:0 0 12px">Task Configuration</h4>' +
'<div class="settings-section">' +
'<div class="form-row" style="gap:12px">' +
'<label class="toggle-label"><input type="checkbox" id="taskCfgEnabled"><span class="toggle-track"></span><span>Tasks Enabled</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="taskCfgPersonal"><span class="toggle-track"></span><span>Allow Personal Tasks</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="taskCfgRequireBYOK"><span class="toggle-track"></span><span>Require BYOK for Personal</span></label>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:12px">' +
'<div class="form-group" style="flex:1"><label>Max Concurrent</label>' +
'<input type="number" id="taskCfgMaxConcurrent" min="1" max="50" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Default Max Tokens</label>' +
'<input type="number" id="taskCfgMaxTokens" min="1" style="width:100%"></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Default Max Tool Calls</label>' +
'<input type="number" id="taskCfgMaxToolCalls" min="1" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Default Max Wall Clock (seconds)</label>' +
'<input type="number" id="taskCfgMaxWallClock" min="10" style="width:100%"></div>' +
'</div>' +
'<button class="btn-small btn-primary" id="taskCfgSaveBtn" style="margin-top:12px">Save Configuration</button>' +
'</div>' +
'</div>' +
'<div id="taskRunPane" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="taskRunBackBtn">&larr; Back</button>' +
'<h4 id="taskRunTitle" style="margin:0;flex:1"></h4>' +
'<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' +
'</div>' +
'<div id="taskRunList" class="admin-list"></div>' +
'</div>' +
'<div id="taskSystemPane" style="display:none">' +
'<h4 style="margin:0 0 12px">Create System Task</h4>' +
'<p style="font-size:12px;color:var(--text-2);margin:0 0 16px;line-height:1.5">' +
'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
'pure platform operations (cleanup, sweeps, compaction). Admin-only.</p>' +
'<div class="settings-section" style="padding:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="sysTaskName" style="width:100%" placeholder="Nightly Session Cleanup"></div>' +
'<div class="form-group" style="flex:1"><label>Function</label>' +
'<select id="sysTaskFunc" style="width:100%"><option value="">Loading…</option></select></div>' +
'</div>' +
'<div id="sysTaskFuncDesc" style="font-size:11px;color:var(--text-3);margin:4px 0 12px;min-height:16px"></div>' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:1"><label>Schedule (cron)</label>' +
'<input type="text" id="sysTaskCron" style="width:100%" placeholder="0 3 * * *"></div>' +
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
'<input type="text" id="sysTaskTZ" style="width:100%" value="UTC"></div>' +
'</div>' +
'<button class="btn-small btn-primary" id="sysTaskCreateBtn" style="margin-top:12px">Create System Task</button>' +
'</div>' +
'</div>';
function showTab(tab) {
var el = function(id) { return document.getElementById(id); };
el('taskListPane').style.display = tab === 'list' ? '' : 'none';
el('taskConfigPane').style.display = tab === 'config' ? '' : 'none';
el('taskRunPane').style.display = tab === 'runs' ? '' : 'none';
el('taskSystemPane').style.display = tab === 'system' ? '' : 'none';
el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : '');
el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : '');
el('taskTabSystem').className = 'btn-small' + (tab === 'system' ? ' btn-primary' : '');
}
function statusBadge(status) {
var colors = {
running: 'badge-info', completed: 'badge-success', failed: 'badge-danger',
budget_exceeded: 'badge-warning', cancelled: 'badge-muted'
};
return '<span class="badge ' + (colors[status] || '') + '">' + status + '</span>';
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function loadTasks() {
try {
var resp = await API._get('/api/v1/admin/tasks');
var tasks = resp.data || [];
var list = document.getElementById('taskAdminList');
document.getElementById('taskCount').textContent = tasks.length;
if (!tasks.length) {
list.innerHTML = '<div class="admin-empty">No scheduled tasks</div>';
return;
}
list.innerHTML = tasks.map(function(t) {
var active = t.is_active
? '<span class="badge badge-success">active</span>'
: '<span class="badge badge-muted">paused</span>';
var scope = '<span class="badge">' + t.scope + '</span>';
var type = '<span class="badge">' + t.task_type + '</span>';
var funcBadge = t.task_type === 'system' && t.system_function
? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + escapeHtml(t.system_function) + '</span>' : '';
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never';
var triggerBtn = '';
if (t.schedule === 'webhook' && t.trigger_token) {
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + escapeHtml(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
}
return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' +
'<div style="flex:2">' +
'<div style="font-weight:600;font-size:13px">' + escapeHtml(t.name) + '</div>' +
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
'</div>' +
'</div>' +
'<div style="display:flex;gap:6px;align-items:center">' + type + funcBadge + scope + active + '</div>' +
'<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' +
'<div style="display:flex;gap:4px">' +
triggerBtn +
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
'<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' +
'<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
'</div>' +
'</div>';
}).join('');
list.querySelectorAll('.task-run-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) { e.stopPropagation(); triggerRun(btn.dataset.id); });
});
list.querySelectorAll('.task-history-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) { e.stopPropagation(); showRuns(btn.dataset.id, btn.dataset.name); });
});
list.querySelectorAll('.task-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); });
});
list.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
navigator.clipboard.writeText(url).then(function() {
UI.toast('Trigger URL copied', 'success');
}).catch(function() {
prompt('Trigger URL:', url);
});
});
});
} catch (err) {
console.error('[task-admin] Failed to load tasks:', err);
}
}
async function triggerRun(taskId) {
try {
await API._post('/api/v1/admin/tasks/' + taskId + '/run', {});
UI.toast('Task scheduled for immediate execution', 'success');
} catch (err) {
UI.toast(err.message || 'Failed to trigger run', 'error');
}
}
async function deleteTask(taskId) {
if (!confirm('Delete this task and all run history?')) return;
try {
await API._delete('/api/v1/admin/tasks/' + taskId);
UI.toast('Task deleted', 'success');
loadTasks();
} catch (err) {
UI.toast(err.message || 'Failed to delete task', 'error');
}
}
var _currentTaskId = null;
async function showRuns(taskId, taskName) {
_currentTaskId = taskId;
document.getElementById('taskRunTitle').textContent = 'Run History: ' + taskName;
showTab('runs');
try {
var resp = await API._get('/api/v1/tasks/' + taskId + '/runs');
var runs = resp.data || [];
var list = document.getElementById('taskRunList');
if (!runs.length) {
list.innerHTML = '<div class="admin-empty">No runs yet</div>';
return;
}
list.innerHTML = runs.map(function(r) {
var started = new Date(r.started_at).toLocaleString();
var completed = r.completed_at ? new Date(r.completed_at).toLocaleString() : '\u2014';
var duration = r.wall_clock ? r.wall_clock + 's' : '\u2014';
var errLine = r.error
? '<div style="font-size:11px;color:var(--danger);margin-top:2px">' + escapeHtml(r.error) + '</div>'
: '';
return '<div class="admin-row" style="padding:10px 12px">' +
'<div style="display:flex;align-items:center;gap:12px">' +
'<div style="flex:1">' +
'<div style="font-size:12px">' + started + ' \u2192 ' + completed + '</div>' +
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
'tokens: ' + r.tokens_used + ' \u00b7 tools: ' + r.tool_calls + ' \u00b7 wall: ' + duration +
'</div>' +
errLine +
'</div>' +
statusBadge(r.status) +
'</div>' +
'</div>';
}).join('');
} catch (err) {
console.error('[task-admin] Failed to load runs:', err);
}
}
async function killRun() {
if (!_currentTaskId) return;
try {
await API._post('/api/v1/admin/tasks/' + _currentTaskId + '/kill', {});
UI.toast('Active run cancelled', 'success');
showRuns(_currentTaskId, document.getElementById('taskRunTitle').textContent.replace('Run History: ', ''));
} catch (err) {
UI.toast(err.message || 'Failed to kill run', 'error');
}
}
async function loadConfig() {
try {
var resp = await API._get('/api/v1/admin/settings/tasks');
var cfg = resp || {};
document.getElementById('taskCfgEnabled').checked = cfg.enabled !== false;
document.getElementById('taskCfgPersonal').checked = cfg.allow_personal !== false;
document.getElementById('taskCfgRequireBYOK').checked = cfg.personal_require_byok === true;
document.getElementById('taskCfgMaxConcurrent').value = cfg.max_concurrent || 5;
document.getElementById('taskCfgMaxTokens').value = cfg.default_max_tokens || 4096;
document.getElementById('taskCfgMaxToolCalls').value = cfg.default_max_tool_calls || 10;
document.getElementById('taskCfgMaxWallClock').value = cfg.default_max_wall_clock || 300;
} catch (_) {
document.getElementById('taskCfgEnabled').checked = true;
document.getElementById('taskCfgPersonal').checked = true;
document.getElementById('taskCfgRequireBYOK').checked = false;
document.getElementById('taskCfgMaxConcurrent').value = 5;
document.getElementById('taskCfgMaxTokens').value = 4096;
document.getElementById('taskCfgMaxToolCalls').value = 10;
document.getElementById('taskCfgMaxWallClock').value = 300;
}
}
async function saveConfig() {
try {
var payload = {
enabled: document.getElementById('taskCfgEnabled').checked,
allow_personal: document.getElementById('taskCfgPersonal').checked,
personal_require_byok: document.getElementById('taskCfgRequireBYOK').checked,
max_concurrent: parseInt(document.getElementById('taskCfgMaxConcurrent').value) || 5,
default_max_tokens: parseInt(document.getElementById('taskCfgMaxTokens').value) || 4096,
default_max_tool_calls: parseInt(document.getElementById('taskCfgMaxToolCalls').value) || 10,
default_max_wall_clock: parseInt(document.getElementById('taskCfgMaxWallClock').value) || 300
};
await API._put('/api/v1/admin/settings/tasks', payload);
UI.toast('Task configuration saved', 'success');
} catch (err) {
UI.toast(err.message || 'Failed to save config', 'error');
}
}
// ── System functions ──────────────────────
var _sysFuncs = [];
async function loadSystemFuncs() {
try {
var resp = await API._get('/api/v1/admin/system-functions');
_sysFuncs = resp.data || [];
var sel = document.getElementById('sysTaskFunc');
sel.innerHTML = '<option value="">— select function —</option>';
_sysFuncs.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f.name;
opt.textContent = f.name;
sel.appendChild(opt);
});
sel.addEventListener('change', function() {
var desc = document.getElementById('sysTaskFuncDesc');
var found = _sysFuncs.find(function(f) { return f.name === sel.value; });
desc.textContent = found ? found.description : '';
});
} catch (err) {
console.error('[task-admin] Failed to load system functions:', err);
}
// Wire create button
document.getElementById('sysTaskCreateBtn').addEventListener('click', createSystemTask);
}
async function createSystemTask() {
var name = document.getElementById('sysTaskName').value.trim();
var func_ = document.getElementById('sysTaskFunc').value;
var cron = document.getElementById('sysTaskCron').value.trim();
var tz = document.getElementById('sysTaskTZ').value.trim() || 'UTC';
if (!name) { UI.toast('Name is required', 'warning'); return; }
if (!func_) { UI.toast('Select a system function', 'warning'); return; }
if (!cron) { UI.toast('Schedule (cron) is required', 'warning'); return; }
try {
await API._post('/api/v1/tasks', {
name: name,
task_type: 'system',
system_function: func_,
schedule: cron,
timezone: tz,
scope: 'global',
output_mode: 'channel',
});
UI.toast('System task created', 'success');
showTab('list');
loadTasks();
} catch (err) {
UI.toast(err.message || 'Failed to create system task', 'error');
}
}
sb.register('_loadAdminTasks', async function() {
var container = document.getElementById('adminDynamic');
if (!container) return;
container.innerHTML = SCAFFOLD_HTML;
document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); });
document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); });
document.getElementById('taskTabSystem').addEventListener('click', function() { showTab('system'); loadSystemFuncs(); });
document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); });
document.getElementById('taskKillBtn').addEventListener('click', killRun);
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
await loadTasks();
});

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// Detect browser timezone
function browserTZ() {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) { return 'UTC'; }
}
// ── Task list ────────────────────────────
async function loadTaskList() {
var mount = document.getElementById('settingsTasksMount');
if (!mount) return;
try {
// Load personal tasks
var resp = await API._get('/api/v1/tasks');
var personalTasks = (resp.data || []).map(function(t) { t._source = 'personal'; return t; });
// Load team tasks from all user's teams
var teamTasks = [];
try {
var teamsResp = await API._get('/api/v1/teams/mine');
var teams = teamsResp.data || teamsResp || [];
for (var i = 0; i < teams.length; i++) {
try {
var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks');
(tr.data || []).forEach(function(t) {
t._source = 'team';
t._teamName = teams[i].name;
teamTasks.push(t);
});
} catch (_) { /* team may not have tasks */ }
}
} catch (_) { /* no teams */ }
// Deduplicate (user might own a team task)
var seen = {};
personalTasks.forEach(function(t) { seen[t.id] = true; });
teamTasks = teamTasks.filter(function(t) { return !seen[t.id]; });
var tasks = personalTasks.concat(teamTasks);
var html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">' +
'<p style="color:var(--text-2);font-size:13px;margin:0">Your scheduled tasks. Tasks run automatically on their schedule.</p>' +
'<button class="btn-small btn-primary" id="taskNewBtn">+ New Task</button>' +
'</div>';
if (tasks.length > 0) {
html += tasks.map(function(t) {
var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>';
var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : '';
var sched = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
var lastStatus = '';
if (t.last_run_at) {
lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString();
}
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
var triggerBtn = '';
if (t.schedule === 'webhook' && t.trigger_token) {
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + esc(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
}
return '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' +
'<div style="display:flex;align-items:center;gap:12px">' +
'<div style="flex:1">' +
'<div style="font-weight:600;font-size:14px">' + esc(t.name) + '</div>' +
'<div style="font-size:11px;color:var(--text-2);margin-top:2px">' + esc(sched) + ' (' + esc(t.timezone) + ')' + lastStatus + '</div>' +
'<div style="font-size:11px;color:var(--text-2)">next: ' + nextRun + '</div>' +
'</div>' +
'<div style="display:flex;gap:6px;align-items:center">' +
teamBadge + status +
triggerBtn +
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
'<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' +
'<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
// Templates section
html += '<div style="margin-top:24px"><h4 style="margin:0 0 8px">Starter Templates</h4>' +
'<p style="color:var(--text-2);font-size:12px;margin:0 0 12px">Quick-start with a pre-configured task. You can customize after creation.</p>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">';
TEMPLATES.forEach(function(tmpl) {
html += '<div class="settings-section" style="padding:10px 12px;cursor:pointer" data-action="_createFromTemplate" data-args=\'' + JSON.stringify([esc(tmpl.name)]) + '\'>' +
'<div style="font-weight:600;font-size:13px">' + esc(tmpl.name) + '</div>' +
'<div style="font-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' +
'</div>';
});
html += '</div></div>';
mount.innerHTML = html;
// Wire buttons
document.getElementById('taskNewBtn').addEventListener('click', function() { showCreateForm(); });
mount.querySelectorAll('.task-run-btn').forEach(function(btn) {
btn.addEventListener('click', function() { runTask(btn.dataset.id); });
});
mount.querySelectorAll('.task-toggle-btn').forEach(function(btn) {
btn.addEventListener('click', function() { toggleTask(btn.dataset.id, btn.dataset.active === 'true'); });
});
mount.querySelectorAll('.task-del-btn').forEach(function(btn) {
btn.addEventListener('click', function() { deleteTask(btn.dataset.id); });
});
mount.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
navigator.clipboard.writeText(url).then(function() {
UI.toast('Trigger URL copied', 'success');
}).catch(function() {
prompt('Trigger URL:', url);
});
});
});
} catch (err) {
mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>';
}
}
// ── Create form ──────────────────────────
function showCreateForm(defaults) {
var d = defaults || {};
var mount = document.getElementById('settingsTasksMount');
if (!mount) return;
var schedOpts = PRESETS.map(function(p) {
var sel = (d.schedule && d.schedule === p.cron) ? ' selected' : '';
return '<option value="' + esc(p.cron) + '"' + sel + '>' + esc(p.label) + '</option>';
}).join('');
mount.innerHTML =
'<div style="margin-bottom:16px"><button class="btn-small" id="taskBackBtn">\u2190 Back</button></div>' +
'<div class="settings-section" style="padding:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="taskName" value="' + esc(d.name || '') + '" style="width:100%" placeholder="Morning News Digest"></div>' +
'<div class="form-group" style="flex:1"><label>Model</label>' +
'<input type="text" id="taskModel" value="' + esc(d.model || '') + '" style="width:100%" placeholder="(use default)"></div>' +
'</div>' +
'<div class="form-group" style="margin-top:8px"><label>Prompt</label>' +
'<textarea id="taskPrompt" rows="4" style="width:100%">' + esc(d.prompt || '') + '</textarea></div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Schedule</label>' +
'<select id="taskPreset" style="width:100%">' + schedOpts + '</select></div>' +
'<div class="form-group" style="flex:1" id="taskCustomCronGroup" style="display:none"><label>Custom Cron</label>' +
'<input type="text" id="taskCron" value="' + esc(d.schedule || '') + '" style="width:100%" placeholder="0 6 * * *"></div>' +
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
'<input type="text" id="taskTZ" value="' + esc(d.timezone || browserTZ()) + '" style="width:100%"></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Output Mode</label>' +
'<select id="taskOutput" style="width:100%"><option value="channel">Channel</option><option value="note">Note</option><option value="webhook">Webhook</option></select></div>' +
'<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' +
'<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' +
'</div>' +
'<div class="form-group" style="margin-top:8px" id="taskWebhookSecretGroup" style="display:none"><label>Webhook Secret (HMAC signing)</label>' +
'<input type="text" id="taskWebhookSecret" style="width:100%" placeholder="auto-generated if empty">' +
'<p style="font-size:11px;color:var(--text-3);margin:2px 0 0">Used to sign outbound webhook payloads. Leave blank for auto-generated secret.</p>' +
'</div>' +
'<div id="taskWebhookTriggerInfo" style="display:none;margin-top:12px;padding:12px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
'<div style="font-weight:600;font-size:12px;margin-bottom:4px">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>' +
' Webhook-triggered task</div>' +
'<p style="font-size:12px;color:var(--text-2);margin:0;line-height:1.5">This task runs when an external service sends a POST request to its trigger URL. ' +
'The trigger URL will be shown after creation — copy it into your CI, cron, or another task\\u2019s webhook URL for task-to-task chaining.</p>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Max Tokens</label>' +
'<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' +
'<div class="form-group" style="flex:1"><label>Max Tool Calls</label>' +
'<input type="number" id="taskMaxToolCalls" value="" style="width:100%" placeholder="default"></div>' +
'</div>' +
'<div style="display:flex;gap:8px;margin-top:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="taskNotifyComplete"><span class="toggle-track"></span><span>Notify on complete</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="taskNotifyFail" checked><span class="toggle-track"></span><span>Notify on failure</span></label>' +
'</div>' +
'<button class="btn-small btn-primary" id="taskCreateBtn" style="margin-top:12px">Create Task</button>' +
'</div>';
document.getElementById('taskBackBtn').addEventListener('click', loadTaskList);
document.getElementById('taskCreateBtn').addEventListener('click', createTask);
// Toggle custom cron visibility
var preset = document.getElementById('taskPreset');
var customGroup = document.getElementById('taskCustomCronGroup');
var triggerInfo = document.getElementById('taskWebhookTriggerInfo');
preset.addEventListener('change', function() {
var isWebhook = preset.value === 'webhook';
var isCustom = preset.value === '';
customGroup.style.display = isCustom ? '' : 'none';
triggerInfo.style.display = isWebhook ? '' : 'none';
if (!isWebhook && !isCustom) document.getElementById('taskCron').value = preset.value;
if (isWebhook) document.getElementById('taskCron').value = 'webhook';
});
// Initial state
customGroup.style.display = preset.value === '' ? '' : 'none';
triggerInfo.style.display = preset.value === 'webhook' ? '' : 'none';
if (preset.value !== '' && preset.value !== 'webhook') document.getElementById('taskCron').value = preset.value;
// Toggle webhook URL and secret visibility
var output = document.getElementById('taskOutput');
var webhookGroup = document.getElementById('taskWebhookGroup');
var webhookSecretGroup = document.getElementById('taskWebhookSecretGroup');
output.addEventListener('change', function() {
var isWH = output.value === 'webhook';
webhookGroup.style.display = isWH ? '' : 'none';
webhookSecretGroup.style.display = isWH ? '' : 'none';
});
}
async function createTask() {
var schedule = document.getElementById('taskCron').value || document.getElementById('taskPreset').value;
if (!schedule) { UI.toast('Schedule is required', 'error'); return; }
var payload = {
name: document.getElementById('taskName').value,
task_type: 'prompt',
user_prompt: document.getElementById('taskPrompt').value,
schedule: schedule,
timezone: document.getElementById('taskTZ').value || 'UTC',
output_mode: document.getElementById('taskOutput').value,
notify_on_complete: document.getElementById('taskNotifyComplete').checked,
notify_on_failure: document.getElementById('taskNotifyFail').checked,
};
var model = document.getElementById('taskModel').value;
if (model) payload.model_id = model;
var maxTokens = parseInt(document.getElementById('taskMaxTokens').value);
if (maxTokens > 0) payload.max_tokens = maxTokens;
var maxTools = parseInt(document.getElementById('taskMaxToolCalls').value);
if (maxTools > 0) payload.max_tool_calls = maxTools;
var webhookURL = document.getElementById('taskWebhookURL')?.value;
if (payload.output_mode === 'webhook' && webhookURL) {
payload.webhook_url = webhookURL;
}
var webhookSecret = document.getElementById('taskWebhookSecret')?.value;
if (payload.output_mode === 'webhook' && webhookSecret) {
payload.webhook_secret = webhookSecret;
}
try {
var created = await API._post('/api/v1/tasks', payload);
UI.toast('Task created', 'success');
// Show trigger URL for webhook-scheduled tasks
if (created.schedule === 'webhook' && created.trigger_token) {
var triggerURL = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + created.trigger_token;
UI.modal('Webhook Trigger URL',
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">POST to this URL to trigger the task. ' +
'You can use this in CI pipelines, cron jobs, or as another task\\u2019s webhook URL for chaining.</p>' +
'<div style="display:flex;gap:8px;align-items:center">' +
'<input type="text" readonly value="' + esc(triggerURL) + '" style="flex:1;font-family:var(--mono);font-size:11px" id="_triggerURLInput">' +
'<button class="btn-small btn-primary" id="_copyTriggerBtn">Copy</button>' +
'</div>' +
'<p style="font-size:11px;color:var(--text-3);margin-top:8px">The request body is passed to the task as trigger_payload. ' +
'For task-to-task chaining: set Task A\\u2019s webhook_url to Task B\\u2019s trigger URL.</p>',
[{ label: 'Done', variant: 'primary' }]
);
setTimeout(function() {
var copyBtn = document.getElementById('_copyTriggerBtn');
if (copyBtn) copyBtn.addEventListener('click', function() {
var input = document.getElementById('_triggerURLInput');
if (input) { navigator.clipboard.writeText(input.value); UI.toast('Copied', 'success'); }
});
}, 100);
}
loadTaskList();
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
} catch (err) {
UI.toast(err.message || 'Failed to create task', 'error');
}
}
// ── Actions ──────────────────────────────
async function runTask(id) {
try {
await API._post('/api/v1/tasks/' + id + '/run', {});
UI.toast('Task scheduled for immediate run', 'success');
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
}
async function toggleTask(id, isActive) {
try {
await API._put('/api/v1/tasks/' + id, { is_active: !isActive });
UI.toast(isActive ? 'Task paused' : 'Task resumed', 'success');
loadTaskList();
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
}
async function deleteTask(id) {
if (!confirm('Delete this task?')) return;
try {
await API._delete('/api/v1/tasks/' + id);
UI.toast('Task deleted', 'success');
loadTaskList();
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
}
// Template launcher
sb.register('_createFromTemplate', function(name) {
var tmpl = TEMPLATES.find(function(t) { return t.name === name; });
if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule });
});
// ── Entry point (called from settings loader) ──
sb.register('_loadSettingsTasks', function() {
loadTaskList();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,867 +0,0 @@
// ==========================================
// Chat Switchboard UI Settings & Teams
// ==========================================
// Extends UI with settings modal tabs, team management,
// providers, usage, model roles, and user preferences.
//
// Exports: none (extends window.UI via Object.assign)
Object.assign(UI, {
// ── General Settings (Settings Surface) ──
async loadGeneralSettings() {
// Fetch settings from API into App.settings
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
if (remote.show_thinking !== undefined) App.settings.showThinking = remote.show_thinking;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
// Fetch models for the model dropdown
await fetchModels();
// Populate form elements
const modelSel = document.getElementById('settingsModel');
if (modelSel) {
modelSel.innerHTML = '';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
modelSel.appendChild(opt);
});
if (App.settings.model) modelSel.value = App.settings.model;
}
const sysEl = document.getElementById('settingsSystemPrompt');
if (sysEl) sysEl.value = App.settings.systemPrompt || '';
const maxEl = document.getElementById('settingsMaxTokens');
if (maxEl) maxEl.value = App.settings.maxTokens || '';
const tempEl = document.getElementById('settingsTemp');
const tempVal = document.getElementById('settingsTempValue');
if (tempEl) {
tempEl.value = App.settings.temperature;
if (tempVal) tempVal.textContent = App.settings.temperature;
tempEl.addEventListener('input', () => {
if (tempVal) tempVal.textContent = tempEl.value;
});
}
const thinkEl = document.getElementById('settingsThinking');
if (thinkEl) thinkEl.checked = App.settings.showThinking;
// Model change → update max tokens hint
if (modelSel) {
modelSel.addEventListener('change', () => {
const model = App.findModel(modelSel.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : '';
}
});
// Trigger once to show current model's hint
modelSel.dispatchEvent(new Event('change'));
}
// Save button (reuse existing pattern — form auto-saves are not a thing yet)
const section = document.querySelector('.settings-section');
if (section && !section.querySelector('.settings-save-btn')) {
const btn = document.createElement('button');
btn.className = 'btn-md btn-primary settings-save-btn';
btn.textContent = 'Save';
btn.style.marginTop = '12px';
btn.addEventListener('click', async () => {
App.settings.model = modelSel?.value || App.settings.model;
App.settings.systemPrompt = sysEl?.value?.trim() || '';
App.settings.maxTokens = parseInt(maxEl?.value) || 0;
App.settings.temperature = parseFloat(tempEl?.value) || 0.7;
App.settings.showThinking = thinkEl?.checked || false;
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
show_thinking: App.settings.showThinking,
});
UI.toast('Settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
section.appendChild(btn);
}
},
// ── Appearance Settings ─────────────────
saveAppearance() {
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
const activeThemeBtn = document.querySelector('#themeToggle .toggle-btn.active');
if (activeThemeBtn && typeof Theme !== 'undefined') {
Theme.set(activeThemeBtn.dataset.theme);
}
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
UI.toast('Appearance saved', 'success');
},
// ── Teams Settings (Settings Surface) ────
async loadTeamsSettings() {
const el = document.getElementById('settingsDynamic');
if (!el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
if (!teams.length) {
el.innerHTML = '<div class="empty-hint">You are not a member of any teams.</div>';
return;
}
el.innerHTML = teams.map(t => `
<div class="settings-section" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0">${esc(t.name)}</h3>
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}" style="font-size:11px;">${t.my_role}</span>
</div>
${t.description ? `<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">${esc(t.description)}</p>` : ''}
<div style="font-size:12px;color:var(--text-3);">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed to load teams.</div>';
}
},
loadAppearanceSettings() {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
// v0.23.2: Read theme from Theme API
const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button and wire click
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
btn.onclick = () => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
};
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
},
initAppearance() {
// Theme and appearance are already applied by the universal init
// in base.html (Theme.init() + UI.restoreAppearance()). This method
// only wires up the settings UI controls.
// Scale slider: show value live, but apply zoom only on release to avoid
// jarring layout jumps mid-drag (zoom changes the layout viewport).
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) {
scaleEl.addEventListener('input', () => {
document.getElementById('scaleValue').textContent = parseInt(scaleEl.value) + '%';
});
scaleEl.addEventListener('change', () => {
UI.applyAppearance(parseInt(scaleEl.value), parseInt(msgFontEl?.value || 14));
});
}
if (msgFontEl) {
msgFontEl.addEventListener('input', () => {
document.getElementById('msgFontValue').textContent = parseInt(msgFontEl.value) + 'px';
});
msgFontEl.addEventListener('change', () => {
UI.applyAppearance(parseInt(scaleEl?.value || 100), parseInt(msgFontEl.value));
});
}
// Theme toggle buttons
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme); // delegates to Theme.set() — single source of truth
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
});
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.editorKeymap = mode;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Notify open editors
if (typeof sw !== 'undefined') {
sw.emit('keymap.changed', { mode });
}
});
});
// System theme change listener (for "system" mode)
// Note: Theme.set('system') registers its own listener, but this
// additionally fires the theme.changed event for CM6 editors.
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
UI._systemThemeQuery.addEventListener('change', () => {
const mode = typeof Theme !== 'undefined' ? Theme.get() : 'system';
if (mode === 'system') UI.applyTheme('system');
});
},
/**
* Apply theme to the document.
* @param {'light'|'dark'|'system'} mode
*/
applyTheme(mode) {
// Delegate to Theme API which handles system-mode resolution and
// listens for OS preference changes.
if (typeof Theme !== 'undefined') {
Theme.set(mode);
} else {
// Fallback if Theme not loaded — resolve manually
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', resolved);
}
// Publish event for CM6 editors and extensions
if (typeof sw !== 'undefined') {
const resolved = sw.theme?.current || mode;
sw.emit('theme.changed', { mode, resolved });
}
},
/** Get the currently resolved theme ('dark' or 'light') */
getResolvedTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
},
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
const kbTabBtn = document.getElementById('settingsKBTabBtn');
if (!notice) return;
const allowed = App.policies?.allow_user_byok === 'true';
if (!kbTabBtn) console.warn('[Settings] settingsKBTabBtn not found in DOM');
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
},
checkUserPersonasAllowed() {
const addBtn = document.getElementById('userAddPersonaBtn');
const addForm = document.getElementById('userAddPersonaForm');
const tabBtn = document.getElementById('settingsPersonasTabBtn');
const allowed = App.policies?.allow_user_personas === 'true';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (addForm && !allowed) addForm.style.display = 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
updateAvatarPreview(p.avatar || null);
} catch (e) { /* optional */ }
},
async loadMyTeams() {
const section = document.getElementById('settingsTeamsSection');
const el = document.getElementById('settingsTeamsList');
const menuBtn = document.getElementById('menuTeamAdmin');
if (!section || !el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
UI._myTeams = teams;
// Show/hide the Team Management flyout item for team admins
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
if (teams.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
el.innerHTML = teams.map(t => `
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" data-action="UI.openTeamManage" data-args='${JSON.stringify([t.id, esc(t.name)])}'"` : ''}>
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
${t.my_role === 'admin' ? '<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>' : ''}
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
},
_myTeams: [],
_managingTeamId: null,
loadTeamsTab() {
// No longer used — picker is in team admin modal
},
async openTeamManage(teamId, teamName) {
UI._managingTeamId = teamId;
UI._teamAuditPage = 1;
// If the modal isn't open yet (called from settings "Manage →"), open it
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
openModal('teamAdminModal');
}
// Show tabbed content, hide picker
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
const titleEl = document.getElementById('teamAdminTitle');
if (adminTeams.length > 1) {
titleEl.innerHTML = `<span class="team-admin-back" data-action="UI.showTeamPicker" " title="Back to teams">←</span> ${esc(teamName)}`;
} else {
titleEl.textContent = teamName;
}
document.getElementById('teamAdminPicker').style.display = 'none';
document.getElementById('teamAdminContent').style.display = 'flex';
// Reset forms (null-safe — not all form containers may exist)
['settingsTeamAddMember', 'settingsTeamAddPersona', 'settingsTeamProviderForm'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const af = document.getElementById('teamAuditFilterAction');
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
// Show Members tab first, load its data
UI.switchTeamTab('members');
},
showTeamPicker() {
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPicker').style.display = '';
},
async loadTeamManageMembers(teamId) {
const el = document.getElementById('settingsTeamMembers');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListMembers(teamId);
const members = resp.data || [];
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500;font-size:13px">${esc(m.display_name || m.email)}</div>
${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
</td>
<td>
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
<td class="admin-actions-cell">
<button class="icon-btn icon-btn-danger" data-action="settingsRemoveTeamMember" data-args='${JSON.stringify([teamId, m.id, esc(m.email)])}'" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
const formEl = document.getElementById('settingsTeamProviderForm');
// Initialize team provider form primitive (once)
if (!UI._teamProvForm && formEl) {
UI._teamProvForm = renderProviderForm(formEl, {
prefix: 'teamProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
const tid = UI._managingTeamId;
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
if (vals.model_default) updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.teamUpdateProvider(tid, vals._editId, updates);
UI.toast('Provider updated');
} else {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.endpoint) return UI.toast('Endpoint required', 'error');
await API.teamCreateProvider(tid, {
name: vals.name, provider: vals.provider, endpoint: vals.endpoint,
api_key: vals.api_key, is_private: vals.is_private || false,
});
UI.toast('Provider added');
}
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
},
});
}
// Initialize team provider list primitive (once)
if (!UI._teamProvList) {
UI._teamProvList = renderProviderList(el, {
apiFetch: async () => {
const resp = await API.teamListProviders(UI._managingTeamId);
// Handle policy check
const allowed = resp.allow_team_providers !== false;
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (!allowed && !(resp.data || resp.providers || []).length) {
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
}
return resp;
},
showPrivate: true,
showActive: true,
emptyMsg: 'No team providers — add one to give your team access to additional models',
onEdit: (prov) => {
if (UI._teamProvForm) {
UI._teamProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm(`Delete team provider "${prov.name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(UI._managingTeamId, prov.id);
UI.toast('Provider deleted');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onToggleActive: async (prov) => {
try {
await API.teamUpdateProvider(UI._managingTeamId, prov.id, { is_active: !prov.is_active });
UI.toast(prov.is_active ? 'Provider deactivated' : 'Provider activated');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._teamProvList.refresh();
},
async loadTeamManagePersonas(teamId) {
const el = document.getElementById('settingsTeamPersonas');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPersonas(teamId);
const personas = resp.data || [];
el.innerHTML = personas.map(p => {
const icon = p.icon || '';
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div>
<button class="btn-delete" data-action="settingsDeleteTeamPersona" data-args='${JSON.stringify([teamId, p.id, esc(p.name)])}'" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageGroups(teamId) {
const el = document.getElementById('settingsTeamGroups');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Group</th><th>Members</th></tr></thead>
<tbody>${groups.map(g => `<tr>
<td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
</tr>`).join('')}</tbody>
</table>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageSettings(teamId) {
if (!teamId) return;
try {
const team = await API.teamGetTeam ? API.teamGetTeam(teamId) : await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
const privEl = document.getElementById('teamSettingPrivatePolicy');
const allowEl = document.getElementById('teamSettingAllowProviders');
if (privEl) privEl.checked = !!settings.require_private_providers;
if (allowEl) allowEl.checked = settings.allow_team_providers !== false;
// Wire save button (once)
const saveBtn = document.getElementById('teamSettingSaveBtn');
if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true;
saveBtn.addEventListener('click', async () => {
const tid = UI._managingTeamId;
const priv = document.getElementById('teamSettingPrivatePolicy')?.checked || false;
const allow = document.getElementById('teamSettingAllowProviders')?.checked !== false;
try {
await API.adminUpdateTeam(tid, { settings: JSON.stringify({ require_private_providers: priv, allow_team_providers: allow }) });
UI.toast('Team settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
} catch (e) { /* proceed with defaults */ }
},
async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;
if (!UI._myUsageDash) {
UI._myUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.getMyUsage(p),
periodElId: 'myUsagePeriod',
groupByElId: 'myUsageGroupBy',
compact: true,
});
}
await UI._myUsageDash.refresh();
},
async loadTeamUsage() {
const teamId = UI._managingTeamId;
if (!teamId) return;
const el = document.getElementById('settingsTeamUsageTotals')?.parentElement;
if (!el) return;
if (!UI._teamUsageDash) {
UI._teamUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.teamGetUsage(teamId, p),
periodElId: 'teamUsagePeriod',
groupByElId: 'teamUsageGroupBy',
compact: true,
showUserColumn: true,
});
}
await UI._teamUsageDash.refresh();
},
_teamAuditPage: 1,
_teamAuditPerPage: 20,
// ── User Model Roles (BYOK overrides) ──
async loadUserRoles() {
const el = document.getElementById('userRolesConfig');
const notice = document.getElementById('userRolesDisabled');
if (!el) return;
// Only show if BYOK is enabled
const byokAllowed = App.policies?.allow_user_byok === 'true';
if (!byokAllowed) {
el.style.display = 'none';
if (notice) {
notice.innerHTML = '<p style="color:var(--text-2);font-size:13px;">BYOK (Bring Your Own Key) must be enabled by your admin before you can configure model role overrides.</p>';
notice.style.display = '';
}
return;
}
// Check if user has personal providers
try {
const configs = await API.listConfigs();
const configList = configs.configs || configs.data || [];
const personalProviders = configList.filter(c => c.scope === 'personal');
if (personalProviders.length === 0) {
el.style.display = 'none';
if (notice) {
notice.innerHTML = '<p style="color:var(--text-2);font-size:13px;">Add a personal provider in <strong>My Providers</strong> first to configure model role overrides.</p>';
notice.style.display = '';
}
return;
}
el.style.display = '';
if (notice) notice.style.display = 'none';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
return;
}
if (_userRoleConfig) await _userRoleConfig.refresh();
},
async loadTeamAuditLog(page) {
const teamId = UI._managingTeamId;
if (!teamId) return;
if (page) UI._teamAuditPage = page;
const el = document.getElementById('settingsTeamAudit');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('teamAuditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.teamListAuditActions(teamId);
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._teamAuditPage,
per_page: UI._teamAuditPerPage,
action: document.getElementById('teamAuditFilterAction')?.value || '',
};
try {
const resp = await API.teamListAudit(teamId, params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('teamAuditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamPersonaModelDropdown(teamId) {
const sel = document.getElementById('teamPersona_model');
if (!sel) return;
sel.innerHTML = '<option value="">Loading models...</option>';
try {
const resp = await API.teamListAvailableModels(teamId);
const models = resp.models || [];
sel.innerHTML = '<option value="">Select base model...</option>';
// Group by source for clarity
const globalModels = models.filter(m => m.source !== 'team');
const teamModels = models.filter(m => m.source === 'team');
const addGroup = (label, items) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
group.appendChild(opt);
});
sel.appendChild(group);
};
addGroup('Global Models', globalModels);
addGroup('Team Provider Models', teamModels);
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPersona).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
},
// ── Providers ────────────────────────────
async loadProviderList() {
if (_userProvList) { await _userProvList.refresh(); return; }
// Fallback if primitives not yet initialized
const el = document.getElementById('providerList');
el.innerHTML = '<div class="loading">Loading...</div>';
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
if (_userProvForm) _userProvForm.setCreateMode();
},
// ── User Model List ─────────────────────
async loadUserModels() {
const el = document.getElementById('userModelList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
// Load preferences if not already loaded
if (!App.hiddenModels) {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
}
const data = await API.listEnabledModels();
const models = (data.data || data.models || []).filter(m => !m.is_persona);
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available</div>';
return;
}
const hiddenCount = models.filter(m => {
const cfgId = m.config_id || m.provider_config_id || '';
return App.hiddenModels.has((cfgId || '') + ':' + (m.model_id || m.id));
}).length;
const visibleCount = models.length - hiddenCount;
let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px">${visibleCount} visible, ${hiddenCount} hidden of ${models.length} total</span>
<div style="flex:1"></div>
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[true]'">Show All</button>
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[false]'">Hide All</button>
</div>`;
html += models.map(m => {
const mid = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const compositeKey = (cfgId || '') + ':' + mid;
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(compositeKey);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
<span class="model-name">${esc(mid)}</span>
<span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" data-action="toggleUserModelVisibility" data-args='${JSON.stringify([esc(mid), esc(cfgId), hidden])}'" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`;
}).join('');
el.innerHTML = html;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadUserPersonas() {
const el = document.getElementById('userPersonaList');
if (!el) return;
try {
const data = await API.listUserPersonas();
// Only show personal personas owned by user
const personas = (data.data || []).filter(p => p.scope === 'personal');
if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
return;
}
el.innerHTML = personas.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` : '';
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${avatarEl}${esc(p.name)}</strong>
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div>
<div class="persona-actions">
<button class="btn-delete" data-action="deleteUserPersona" data-args='${JSON.stringify([p.id, esc(p.name)])}'" title="Delete">✕</button>
</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
});

View File

@@ -1,826 +0,0 @@
// ==========================================
// Chat Switchboard — Workflow Builder (Admin)
// ==========================================
// Admin panel for managing workflow definitions, stages, and publishing.
// Registered as ADMIN_LOADERS.workflows in ui-admin.js.
// ── Scaffold HTML ────────────────────────
// Injected directly into adminDynamic by the loader since SCAFFOLDING
// is scoped inside admin-scaffold.js's IIFE and not accessible here.
var SCAFFOLD_HTML =
'<div id="wfAdminList" class="admin-list"></div>' +
'<div id="wfAdminDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="wfBackBtn">&larr; Back</button>' +
'<h4 id="wfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="wfDetailVersion" class="badge"></span>' +
'<span id="wfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="wfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="wfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="wfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="wfEditEntry" style="width:100%">' +
'<option value="authenticated">Authenticated</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
'<button class="btn-small" id="wfExportBtn">Export .pkg</button>' +
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="wfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="wfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="wfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="wfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="wfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="wfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>System Prompt</label>' +
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
'<select id="wfStageMode" style="width:100%">' +
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Custom Surface Package</label>' +
'<select id="wfStageSurfacePkg" style="width:100%">' +
'<option value="">— Built-in (use stage mode) —</option>' +
'</select></div>' +
'</div>' +
'<div id="wfFormBuilderWrap" style="display:none;margin-top:8px">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
'<label style="margin:0;font-weight:600">Form Fields</label>' +
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="wfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
'</div>' +
'<div id="wfFormFields"></div>' +
'<button class="btn-small" id="wfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
'<textarea id="wfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="wfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="wfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── State ───────────────────────────────
var _currentWf = null;
var _editingStageId = null;
var _personaCache = []; // [{id, name, icon}]
var _surfacePkgCache = []; // [{id, title}]
// Load personas into the stage editor dropdown.
async function _wfLoadPersonaSelect(selectedId) {
var sel = document.getElementById('wfStagePersona');
if (!sel) return;
// Fetch once per detail session
if (_personaCache.length === 0) {
try {
var resp = await API.adminListPersonas();
_personaCache = (resp.data || resp || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e) {
// Fall back to user-visible personas
try {
var resp2 = await API.listPersonas();
_personaCache = (resp2.data || resp2 || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e2) { /* no personas available */ }
}
}
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
_personaCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// Load surface packages into the stage editor dropdown.
async function _wfLoadSurfacePkgSelect(selectedId) {
var sel = document.getElementById('wfStageSurfacePkg');
if (!sel) return;
if (_surfacePkgCache.length === 0) {
try {
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages', {
headers: API._authHeaders ? API._authHeaders() : {},
});
var data = await resp.json();
var pkgs = data.data || data || [];
// Include packages that could have surface JS (type surface or workflow)
_surfacePkgCache = pkgs.filter(function(p) {
return p.type === 'surface' || p.type === 'workflow' || p.type === 'full';
}).map(function(p) {
return { id: p.id, title: p.title || p.id };
});
} catch (e) { /* no packages available */ }
}
sel.innerHTML = '<option value="">— Built-in (use stage mode) —</option>';
_surfacePkgCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.title;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// ── Loader (called from ADMIN_LOADERS.workflows) ──
sb.register('_loadAdminWorkflows', async function() {
// Self-scaffold: inject our HTML into adminDynamic
var target = document.getElementById('adminDynamic');
if (target && !document.getElementById('wfAdminList')) {
target.innerHTML = SCAFFOLD_HTML;
_wfWireFormBuilder();
}
const list = document.getElementById('wfAdminList');
const detail = document.getElementById('wfAdminDetail');
if (!list) return;
detail.style.display = 'none';
list.style.display = '';
try {
const resp = await API.listWorkflows();
const wfs = resp.data || resp || [];
if (wfs.length === 0) {
list.innerHTML =
'<div class="empty-hint">No workflows yet</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'data-action="_wfCreate">+ Create Workflow</button>';
return;
}
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<div style="display:flex;gap:6px">' +
'<button class="btn-small" data-action="_wfImportPkg">Import .pkg</button>' +
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button>' +
'</div></div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (const wf of wfs) {
html += '<tr style="cursor:pointer" data-action="_wfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
});
// ── Create ──────────────────────────────
sb.register('_wfCreate', async function() {
const name = prompt('Workflow name:');
if (!name) return;
try {
const wf = await API.createWorkflow({ name });
UI.toast('Workflow created', 'success');
_wfOpen(wf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Open Detail ─────────────────────────
sb.register('_wfOpen', async function(id) {
const list = document.getElementById('wfAdminList');
const detail = document.getElementById('wfAdminDetail');
try {
const wf = await API.getWorkflow(id);
_currentWf = wf;
list.style.display = 'none';
detail.style.display = '';
document.getElementById('wfDetailName').textContent = wf.name;
document.getElementById('wfDetailVersion').textContent = 'v' + (wf.version || 1);
document.getElementById('wfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
document.getElementById('wfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
document.getElementById('wfEditName').value = wf.name;
document.getElementById('wfEditSlug').value = wf.slug;
document.getElementById('wfEditDesc').value = wf.description || '';
document.getElementById('wfEditEntry').value = wf.entry_mode || 'authenticated';
document.getElementById('wfEditActive').checked = wf.is_active;
// Public URL
const scope = wf.team_id || 'global';
const base = location.origin + (window.__BASE__ || '');
document.getElementById('wfPublicUrl').textContent =
wf.entry_mode === 'public_link'
? base + '/w/' + scope + '/' + wf.slug
: 'Enable "Public Link" entry mode to generate a URL';
// Preload persona cache for stage list display
await _wfLoadPersonaSelect('');
// Load stages
await _wfLoadStages(id);
_wfWireEvents();
} catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error');
}
});
// ── Stage List ──────────────────────────
async function _wfLoadStages(wfId) {
const el = document.getElementById('wfStageList');
if (!el) return;
try {
const resp = await API.listStages(wfId);
const stages = resp.data || resp || [];
if (stages.length === 0) {
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
return;
}
let html = '';
stages.forEach(function(s, i) {
var personaLabel = '';
if (s.persona_id) {
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
}
var surfaceLabel = '';
if (s.surface_pkg_id) {
surfaceLabel = '<span class="badge badge-accent">pkg: ' + esc(s.surface_pkg_id) + '</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel + modeLabel + surfaceLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
'<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
'</div>';
});
el.innerHTML = html;
// v0.27.0: Wire DnD reorder on stage rows
_wfWireStageDnD(el, wfId);
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── Wire Events (called once per detail open) ──
// v0.27.0: Wire drag-and-drop reorder on stage rows
function _wfWireStageDnD(container, wfId) {
var dragSrc = null;
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragSrc = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
r.classList.remove('drag-over');
});
dragSrc = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (row !== dragSrc) row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
row.classList.remove('drag-over');
if (!dragSrc || dragSrc === row) return;
// Reorder in DOM
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
var fromIdx = rows.indexOf(dragSrc);
var toIdx = rows.indexOf(row);
if (fromIdx < toIdx) {
row.parentNode.insertBefore(dragSrc, row.nextSibling);
} else {
row.parentNode.insertBefore(dragSrc, row);
}
// Collect new order and send to backend
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
return r.dataset.id;
});
API.reorderStages(wfId, orderedIDs).then(function() {
UI.toast('Stages reordered', 'success');
_wfLoadStages(wfId);
}).catch(function(err) {
UI.toast('Reorder failed: ' + (err.message || err), 'error');
_wfLoadStages(wfId);
});
});
});
}
var _wired = false;
function _wfWireEvents() {
if (_wired) return;
_wired = true;
document.getElementById('wfBackBtn').onclick = function() {
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_surfacePkgCache = [];
_wired = false;
_loadAdminWorkflows();
};
document.getElementById('wfSaveBtn').onclick = async function() {
if (!_currentWf) return;
try {
await API.updateWorkflow(_currentWf.id, {
name: document.getElementById('wfEditName').value.trim(),
description: document.getElementById('wfEditDesc').value.trim(),
entry_mode: document.getElementById('wfEditEntry').value,
is_active: document.getElementById('wfEditActive').checked,
});
UI.toast('Saved', 'success');
_wfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfPublishBtn').onclick = async function() {
if (!_currentWf) return;
try {
const ver = await API.publishWorkflow(_currentWf.id);
UI.toast('Published v' + ver.version_number, 'success');
_wfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfExportBtn').onclick = function() {
if (!_currentWf) return;
API.exportWorkflowPkg(_currentWf.id);
};
document.getElementById('wfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
try {
await API.deleteWorkflow(_currentWf.id);
UI.toast('Deleted', 'success');
_currentWf = null;
_personaCache = [];
_surfacePkgCache = [];
_wired = false;
_loadAdminWorkflows();
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfAddStageBtn').onclick = async function() {
_editingStageId = null;
document.getElementById('wfStageName').value = '';
document.getElementById('wfStageHistory').value = 'full';
document.getElementById('wfStagePersona').value = '';
document.getElementById('wfStagePrompt').value = '';
document.getElementById('wfStageMode').value = 'chat_only';
document.getElementById('wfStageForm').value = '';
document.getElementById('wfFormFields').innerHTML = '';
_wfUpdateFormBuilderVisibility();
await _wfLoadPersonaSelect('');
await _wfLoadSurfacePkgSelect('');
document.getElementById('wfStageEditor').style.display = '';
};
document.getElementById('wfCancelStageBtn').onclick = function() {
document.getElementById('wfStageEditor').style.display = 'none';
_editingStageId = null;
};
document.getElementById('wfSaveStageBtn').onclick = async function() {
if (!_currentWf) return;
var name = document.getElementById('wfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var stageMode = document.getElementById('wfStageMode').value;
var formTemplate = null;
if (stageMode === 'form_only' || stageMode === 'form_chat') {
// Build from visual builder or JSON textarea
var jsonToggle = document.getElementById('wfFormJsonToggle');
if (jsonToggle && jsonToggle.checked) {
var formRaw = document.getElementById('wfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
} else {
formTemplate = _wfBuildFormTemplateFromUI();
}
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
UI.toast('Form modes require at least one form field', 'error');
return;
}
} else {
// chat_only: still allow optional legacy form template
var formRaw = document.getElementById('wfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
}
var personaId = document.getElementById('wfStagePersona').value || null;
var surfacePkgId = document.getElementById('wfStageSurfacePkg').value || null;
var data = {
name: name,
history_mode: document.getElementById('wfStageHistory').value,
stage_mode: stageMode,
persona_id: personaId,
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
form_template: formTemplate,
surface_pkg_id: surfacePkgId,
};
try {
if (_editingStageId) {
await API.updateStage(_currentWf.id, _editingStageId, data);
} else {
data.ordinal = document.querySelectorAll('.wf-stage-row').length;
await API.createStage(_currentWf.id, data);
}
UI.toast('Stage saved', 'success');
document.getElementById('wfStageEditor').style.display = 'none';
_editingStageId = null;
await _wfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
}
// ── Edit / Delete Stage ─────────────────
sb.register('_wfEditStage', async function(stageId) {
if (!_currentWf) return;
try {
const resp = await API.listStages(_currentWf.id);
const stages = resp.data || resp || [];
const stage = stages.find(function(s) { return s.id === stageId; });
if (!stage) return;
_editingStageId = stageId;
document.getElementById('wfStageName').value = stage.name;
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
document.getElementById('wfStageMode').value = stage.stage_mode || 'chat_only';
await _wfLoadPersonaSelect(stage.persona_id || '');
document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
// Populate form builder from existing template
var tpl = stage.form_template;
document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
_wfPopulateFormBuilder(tpl);
_wfUpdateFormBuilderVisibility();
// Surface package selector
await _wfLoadSurfacePkgSelect(stage.surface_pkg_id || '');
document.getElementById('wfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Import .pkg ──────────────────────────
sb.register('_wfImportPkg', function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.pkg,.zip';
input.onchange = async function() {
if (!input.files || !input.files[0]) return;
var formData = new FormData();
formData.append('file', input.files[0]);
try {
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages/install', {
method: 'POST',
headers: API._authHeaders ? { 'Authorization': API._authHeaders()['Authorization'] } : {},
body: formData,
});
var result = await resp.json();
if (!resp.ok) {
UI.toast('Import failed: ' + (result.error || 'unknown'), 'error');
return;
}
UI.toast('Workflow imported: ' + (result.title || result.id), 'success');
_loadAdminWorkflows();
} catch (e) {
UI.toast('Import error: ' + e.message, 'error');
}
};
input.click();
});
sb.register('_wfDeleteStage', async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;
try {
await API.deleteStage(_currentWf.id, stageId);
UI.toast('Stage deleted', 'success');
await _wfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Form Builder Helpers ────────────────
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
// Show/hide the form builder based on stage_mode
function _wfUpdateFormBuilderVisibility() {
var mode = document.getElementById('wfStageMode').value;
var wrap = document.getElementById('wfFormBuilderWrap');
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
}
// Build a single field row element
function _wfCreateFieldRow(field) {
field = field || { key: '', type: 'text', label: '', required: false };
var row = document.createElement('div');
row.className = 'wf-field-row';
row.draggable = true;
row.innerHTML =
'<span class="wf-stage-grip" style="cursor:grab">⠿</span>' +
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
'<select class="wf-fb-type" style="width:90px">' +
FIELD_TYPES.map(function(t) {
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
}).join('') +
'</select>' +
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
// Delete handler
row.querySelector('.wf-fb-del').onclick = function() {
row.remove();
_wfSyncFormJSON();
};
// Type change: show options editor for select
row.querySelector('.wf-fb-type').onchange = function() {
var optWrap = row.querySelector('.wf-fb-options');
if (this.value === 'select') {
if (!optWrap) {
optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
optWrap.style.display = '';
} else if (optWrap) {
optWrap.style.display = 'none';
}
_wfSyncFormJSON();
};
// If select type, add options input
if (field.type === 'select') {
var optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
// Sync JSON on any input change
row.querySelectorAll('input, select').forEach(function(el) {
el.addEventListener('change', _wfSyncFormJSON);
el.addEventListener('input', _wfSyncFormJSON);
});
// DnD within field list
row.addEventListener('dragstart', function(e) {
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
_wfDragFieldSrc = row;
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
document.querySelectorAll('.wf-field-row').forEach(function(r) { r.classList.remove('drag-over'); });
_wfDragFieldSrc = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
if (row !== _wfDragFieldSrc) row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() { row.classList.remove('drag-over'); });
row.addEventListener('drop', function(e) {
e.preventDefault();
row.classList.remove('drag-over');
if (!_wfDragFieldSrc || _wfDragFieldSrc === row) return;
var container = document.getElementById('wfFormFields');
var rows = Array.from(container.querySelectorAll('.wf-field-row'));
var from = rows.indexOf(_wfDragFieldSrc);
var to = rows.indexOf(row);
if (from < to) {
row.parentNode.insertBefore(_wfDragFieldSrc, row.nextSibling);
} else {
row.parentNode.insertBefore(_wfDragFieldSrc, row);
}
_wfSyncFormJSON();
});
return row;
}
var _wfDragFieldSrc = null;
// Populate the form builder from an existing template object
function _wfPopulateFormBuilder(tpl) {
var container = document.getElementById('wfFormFields');
if (!container) return;
container.innerHTML = '';
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
tpl.fields.forEach(function(f) {
// Support both typed objects and legacy string-only fields
if (typeof f === 'string') {
f = { key: f, type: 'text', label: f, required: false };
}
container.appendChild(_wfCreateFieldRow(f));
});
}
// Build a typed form_template from the visual builder
function _wfBuildFormTemplateFromUI() {
var container = document.getElementById('wfFormFields');
if (!container) return null;
var rows = container.querySelectorAll('.wf-field-row');
if (rows.length === 0) return null;
var fields = [];
rows.forEach(function(row) {
var key = row.querySelector('.wf-fb-key').value.trim();
var type = row.querySelector('.wf-fb-type').value;
var label = row.querySelector('.wf-fb-label').value.trim();
var required = row.querySelector('.wf-fb-required').checked;
if (!key) return; // skip empty key fields
var field = { key: key, type: type, label: label || key, required: required };
// Parse select options
if (type === 'select') {
var optsInput = row.querySelector('.wf-fb-opts-input');
if (optsInput && optsInput.value.trim()) {
field.options = optsInput.value.split(',').map(function(s) {
s = s.trim();
return { value: s, label: s };
}).filter(function(o) { return o.value; });
}
}
fields.push(field);
});
return fields.length > 0 ? { fields: fields } : null;
}
// Sync visual builder → JSON textarea (one-way, when builder is active)
function _wfSyncFormJSON() {
var jsonToggle = document.getElementById('wfFormJsonToggle');
if (!jsonToggle) return;
var tpl = _wfBuildFormTemplateFromUI();
document.getElementById('wfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
}
// Wire form builder controls (called once after scaffold injection)
function _wfWireFormBuilder() {
var modeSelect = document.getElementById('wfStageMode');
if (modeSelect) {
modeSelect.onchange = _wfUpdateFormBuilderVisibility;
}
var addBtn = document.getElementById('wfAddFieldBtn');
if (addBtn) {
addBtn.onclick = function() {
var container = document.getElementById('wfFormFields');
if (container) {
container.appendChild(_wfCreateFieldRow());
_wfSyncFormJSON();
}
};
}
var jsonToggle = document.getElementById('wfFormJsonToggle');
if (jsonToggle) {
jsonToggle.onchange = function() {
var textarea = document.getElementById('wfStageForm');
if (this.checked) {
// Sync builder → JSON before showing
_wfSyncFormJSON();
textarea.style.display = '';
} else {
// Sync JSON → builder
var raw = textarea.value.trim();
if (raw) {
try {
var tpl = JSON.parse(raw);
_wfPopulateFormBuilder(tpl);
} catch (e) {
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
this.checked = true;
return;
}
}
textarea.style.display = 'none';
}
};
}
}

View File

@@ -1,707 +0,0 @@
// ==========================================
// Chat Switchboard — Team Workflow Builder (v0.31.2)
// ==========================================
// Team admin panel for managing team-scoped workflow definitions,
// stages, and publishing. Adapted from workflow-admin.js using
// team-scoped API endpoints. Loaded as a team admin tab.
(function () {
'use strict';
var esc = typeof window.esc === 'function' ? window.esc : function (s) {
var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
};
// ── Scaffold HTML ────────────────────────
var SCAFFOLD_HTML =
'<div id="twfList" class="admin-list"></div>' +
'<div id="twfDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="twfBackBtn">&larr; Back</button>' +
'<h4 id="twfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="twfDetailVersion" class="badge"></span>' +
'<span id="twfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="twfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="twfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="twfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="twfEditEntry" style="width:100%">' +
'<option value="team_only">Team Only</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="twfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="twfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="twfPublishBtn">Publish</button>' +
'<button class="btn-small btn-danger" id="twfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="twfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="twfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="twfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="twfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="twfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="twfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
'<select id="twfStageMode" style="width:100%">' +
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
'<div id="twfFormBuilderWrap" style="display:none;margin-top:8px">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
'<label style="margin:0;font-weight:600">Form Fields</label>' +
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="twfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
'</div>' +
'<div id="twfFormFields"></div>' +
'<button class="btn-small" id="twfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
'<textarea id="twfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="twfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="twfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="twfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── State ───────────────────────────────
var _teamId = null;
var _teamSlug = null;
var _currentWf = null;
var _editingStageId = null;
var _personaCache = [];
function _getTeamId() {
return _teamId || (UI && UI._managingTeamId) || null;
}
// Load team personas into the stage editor dropdown.
async function _twfLoadPersonaSelect(selectedId) {
var sel = document.getElementById('twfStagePersona');
if (!sel) return;
var teamId = _getTeamId();
if (_personaCache.length === 0 && teamId) {
try {
var resp = await API._get('/api/v1/teams/' + teamId + '/personas');
_personaCache = (resp.data || resp || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e) {
// Fall back to user-visible personas
try {
var resp2 = await API.listPersonas();
_personaCache = (resp2.data || resp2 || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e2) { /* no personas available */ }
}
}
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
_personaCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// ── Loader (called from switchTeamTab) ──
UI.loadTeamWorkflows = async function(teamId) {
_teamId = teamId;
_teamSlug = null;
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
// Fetch team slug for clean public URLs
try {
var teamResp = await API._get('/api/v1/admin/teams/' + teamId);
_teamSlug = teamResp.slug || null;
} catch (e) { /* fallback to team ID in URLs */ }
var target = document.getElementById('teamTabWorkflows');
if (!target) return;
// Self-scaffold
if (!document.getElementById('twfList')) {
target.innerHTML = SCAFFOLD_HTML;
_twfWireFormBuilder();
}
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
if (!list) return;
detail.style.display = 'none';
list.style.display = '';
try {
var resp = await API.listTeamWorkflows(teamId);
var wfs = resp.data || resp || [];
if (wfs.length === 0) {
list.innerHTML =
'<div class="empty-hint">No workflows for this team</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'data-action="_twfCreate">+ Create Workflow</button>';
return;
}
var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" data-action="_twfCreate">+ New</button>' +
'</div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (var i = 0; i < wfs.length; i++) {
var wf = wfs[i];
html += '<tr style="cursor:pointer" data-action="_twfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'team_only') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
};
// ── Create ──────────────────────────────
sb.register('_twfCreate', async function() {
var teamId = _getTeamId();
if (!teamId) return;
var name = prompt('Workflow name:');
if (!name) return;
try {
var wf = await API.createTeamWorkflow(teamId, { name: name });
UI.toast('Workflow created', 'success');
_twfOpen(wf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Open Detail ─────────────────────────
async function _twfOpen(id) {
var teamId = _getTeamId();
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
try {
var wf = await API.getTeamWorkflow(teamId, id);
_currentWf = wf;
list.style.display = 'none';
detail.style.display = '';
document.getElementById('twfDetailName').textContent = wf.name;
document.getElementById('twfDetailVersion').textContent = 'v' + (wf.version || 1);
document.getElementById('twfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
document.getElementById('twfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
document.getElementById('twfEditName').value = wf.name;
document.getElementById('twfEditSlug').value = wf.slug;
document.getElementById('twfEditDesc').value = wf.description || '';
document.getElementById('twfEditEntry').value = wf.entry_mode || 'team_only';
document.getElementById('twfEditActive').checked = wf.is_active;
// Public URL (v0.31.2: use team slug for clean URLs)
var scope = _teamSlug || wf.team_id || teamId;
var base = location.origin + (window.__BASE__ || '');
document.getElementById('twfPublicUrl').textContent =
wf.entry_mode === 'public_link'
? base + '/w/' + scope + '/' + wf.slug
: 'Enable "Public Link" entry mode to generate a URL';
// Preload persona cache
await _twfLoadPersonaSelect('');
// Load stages
await _twfLoadStages(id);
_twfWireEvents();
} catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error');
}
}
sb.register('_twfOpen', function(id) { return _twfOpen(id); });
// ── Stage List ──────────────────────────
async function _twfLoadStages(wfId) {
var teamId = _getTeamId();
var el = document.getElementById('twfStageList');
if (!el) return;
try {
var resp = await API.listTeamWorkflowStages(teamId, wfId);
var stages = resp.data || resp || [];
if (stages.length === 0) {
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
return;
}
var html = '';
stages.forEach(function(s, i) {
var personaLabel = '';
if (s.persona_id) {
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel + modeLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" data-action="_twfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
'<button class="btn-small btn-danger" data-action="_twfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
'</div>';
});
el.innerHTML = html;
_twfWireStageDnD(el, wfId);
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── Stage DnD ───────────────────────────
function _twfWireStageDnD(container, wfId) {
var dragSrc = null;
var teamId = _getTeamId();
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragSrc = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
r.classList.remove('drag-over');
});
dragSrc = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (row !== dragSrc) row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
row.classList.remove('drag-over');
if (!dragSrc || dragSrc === row) return;
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
var fromIdx = rows.indexOf(dragSrc);
var toIdx = rows.indexOf(row);
if (fromIdx < toIdx) {
row.parentNode.insertBefore(dragSrc, row.nextSibling);
} else {
row.parentNode.insertBefore(dragSrc, row);
}
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
return r.dataset.id;
});
API.reorderTeamWorkflowStages(teamId, wfId, orderedIDs).then(function() {
UI.toast('Stages reordered', 'success');
_twfLoadStages(wfId);
}).catch(function(err) {
UI.toast('Reorder failed: ' + (err.message || err), 'error');
_twfLoadStages(wfId);
});
});
});
}
// ── Wire Events ─────────────────────────
var _twfWired = false;
function _twfWireEvents() {
if (_twfWired) return;
_twfWired = true;
document.getElementById('twfBackBtn').onclick = function() {
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(_getTeamId());
};
document.getElementById('twfSaveBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
await API.updateTeamWorkflow(teamId, _currentWf.id, {
name: document.getElementById('twfEditName').value.trim(),
description: document.getElementById('twfEditDesc').value.trim(),
entry_mode: document.getElementById('twfEditEntry').value,
is_active: document.getElementById('twfEditActive').checked,
});
UI.toast('Saved', 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfPublishBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var ver = await API.publishTeamWorkflow(teamId, _currentWf.id);
UI.toast('Published v' + ver.version_number, 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflow(teamId, _currentWf.id);
UI.toast('Deleted', 'success');
_currentWf = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(teamId);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfAddStageBtn').onclick = async function() {
_editingStageId = null;
document.getElementById('twfStageName').value = '';
document.getElementById('twfStageHistory').value = 'full';
document.getElementById('twfStagePersona').value = '';
document.getElementById('twfStageMode').value = 'chat_only';
document.getElementById('twfStageForm').value = '';
document.getElementById('twfFormFields').innerHTML = '';
_twfUpdateFormBuilderVisibility();
await _twfLoadPersonaSelect('');
document.getElementById('twfStageEditor').style.display = '';
};
document.getElementById('twfCancelStageBtn').onclick = function() {
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
};
document.getElementById('twfSaveStageBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
var name = document.getElementById('twfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var stageMode = document.getElementById('twfStageMode').value;
var formTemplate = null;
if (stageMode === 'form_only' || stageMode === 'form_chat') {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle && jsonToggle.checked) {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
} else {
formTemplate = _twfBuildFormTemplateFromUI();
}
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
UI.toast('Form modes require at least one form field', 'error');
return;
}
} else {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
}
var personaId = document.getElementById('twfStagePersona').value || null;
var data = {
name: name,
history_mode: document.getElementById('twfStageHistory').value,
stage_mode: stageMode,
persona_id: personaId,
form_template: formTemplate,
};
try {
if (_editingStageId) {
await API.updateTeamWorkflowStage(teamId, _currentWf.id, _editingStageId, data);
} else {
data.ordinal = document.querySelectorAll('#twfStageList .wf-stage-row').length;
await API.createTeamWorkflowStage(teamId, _currentWf.id, data);
}
UI.toast('Stage saved', 'success');
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
}
// ── Edit / Delete Stage ─────────────────
sb.register('_twfEditStage', async function(stageId) {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var resp = await API.listTeamWorkflowStages(teamId, _currentWf.id);
var stages = resp.data || resp || [];
var stage = stages.find(function(s) { return s.id === stageId; });
if (!stage) return;
_editingStageId = stageId;
document.getElementById('twfStageName').value = stage.name;
document.getElementById('twfStageHistory').value = stage.history_mode || 'full';
document.getElementById('twfStageMode').value = stage.stage_mode || 'chat_only';
await _twfLoadPersonaSelect(stage.persona_id || '');
var tpl = stage.form_template;
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
_twfPopulateFormBuilder(tpl);
_twfUpdateFormBuilderVisibility();
document.getElementById('twfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
sb.register('_twfDeleteStage', async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflowStage(teamId, _currentWf.id, stageId);
UI.toast('Stage deleted', 'success');
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Form Builder Helpers ────────────────
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
function _twfUpdateFormBuilderVisibility() {
var mode = document.getElementById('twfStageMode').value;
var wrap = document.getElementById('twfFormBuilderWrap');
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
}
function _twfCreateFieldRow(field) {
field = field || { key: '', type: 'text', label: '', required: false };
var row = document.createElement('div');
row.className = 'wf-field-row';
row.innerHTML =
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
'<select class="wf-fb-type" style="width:90px">' +
FIELD_TYPES.map(function(t) {
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
}).join('') +
'</select>' +
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
row.querySelector('.wf-fb-del').onclick = function() {
row.remove();
_twfSyncFormJSON();
};
row.querySelector('.wf-fb-type').onchange = function() {
var optWrap = row.querySelector('.wf-fb-options');
if (this.value === 'select') {
if (!optWrap) {
optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
optWrap.style.display = '';
} else if (optWrap) {
optWrap.style.display = 'none';
}
_twfSyncFormJSON();
};
if (field.type === 'select') {
var optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
row.querySelectorAll('input, select').forEach(function(el) {
el.addEventListener('change', _twfSyncFormJSON);
el.addEventListener('input', _twfSyncFormJSON);
});
return row;
}
function _twfPopulateFormBuilder(tpl) {
var container = document.getElementById('twfFormFields');
if (!container) return;
container.innerHTML = '';
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
tpl.fields.forEach(function(f) {
if (typeof f === 'string') {
f = { key: f, type: 'text', label: f, required: false };
}
container.appendChild(_twfCreateFieldRow(f));
});
}
function _twfBuildFormTemplateFromUI() {
var container = document.getElementById('twfFormFields');
if (!container) return null;
var rows = container.querySelectorAll('.wf-field-row');
if (rows.length === 0) return null;
var fields = [];
rows.forEach(function(row) {
var key = row.querySelector('.wf-fb-key').value.trim();
var type = row.querySelector('.wf-fb-type').value;
var label = row.querySelector('.wf-fb-label').value.trim();
var required = row.querySelector('.wf-fb-required').checked;
if (!key) return;
var field = { key: key, type: type, label: label || key, required: required };
if (type === 'select') {
var optsInput = row.querySelector('.wf-fb-opts-input');
if (optsInput && optsInput.value.trim()) {
field.options = optsInput.value.split(',').map(function(s) {
s = s.trim();
return { value: s, label: s };
}).filter(function(o) { return o.value; });
}
}
fields.push(field);
});
return fields.length > 0 ? { fields: fields } : null;
}
function _twfSyncFormJSON() {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (!jsonToggle) return;
var tpl = _twfBuildFormTemplateFromUI();
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
}
function _twfWireFormBuilder() {
var modeSelect = document.getElementById('twfStageMode');
if (modeSelect) {
modeSelect.onchange = _twfUpdateFormBuilderVisibility;
}
var addBtn = document.getElementById('twfAddFieldBtn');
if (addBtn) {
addBtn.onclick = function() {
var container = document.getElementById('twfFormFields');
if (container) {
container.appendChild(_twfCreateFieldRow());
_twfSyncFormJSON();
}
};
}
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle) {
jsonToggle.onchange = function() {
var textarea = document.getElementById('twfStageForm');
if (this.checked) {
_twfSyncFormJSON();
textarea.style.display = '';
} else {
var raw = textarea.value.trim();
if (raw) {
try {
var tpl = JSON.parse(raw);
_twfPopulateFormBuilder(tpl);
} catch (e) {
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
this.checked = true;
return;
}
}
textarea.style.display = 'none';
}
};
}
}
})();