import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; const ADMIN_ID = 'admin'; // Source-driven chat agent (needs a project run-context): never a personal default, // so it is excluded from the role assistant picker (mirrors the server-side guard). const PROJECT_COORDINATOR_ID = 'project-coordinator'; export class RolesPage extends LightElement { static get properties() { return { _open: { state: true }, _roles: { state: true }, _groups: { state: true }, _agents: { state: true }, _error: { state: true }, _modal: { state: true }, // null | { mode: 'create'|'edit', role?, form } }; } constructor() { super(); this._open = false; this._roles = null; this._groups = null; this._agents = null; this._error = null; this._modal = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'roles'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _load() { this._error = null; try { const [rRes, gRes, aRes] = await Promise.all([ fetch('/api/roles'), fetch('/api/tool-permission-groups'), fetch('/api/agents'), ]); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); if (!aRes.ok) throw new Error(`HTTP ${aRes.status}`); this._roles = await rRes.json(); this._groups = await gRes.json(); // Only `type:chat` agents are entry agents; project-coordinator is source-driven. this._agents = (await aRes.json()) .filter(a => a.type === 'chat' && a.id !== PROJECT_COORDINATOR_ID); } catch (e) { this._error = e.message; } } // ── Modal helpers ──────────────────────────────────────────────────────────── // `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI // surfaces it as a first-class select without hardcoding any role semantics. _attrsUiMode(attrs) { try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; } catch { return 'full'; } } // Extra security-groups the role may pick beyond its default `permission_group` // (the effective set is default ∪ these). Lives in attrs JSON (§0.1). _attrsAllowedGroups(attrs) { try { const a = JSON.parse(attrs || '{}').permission_groups; return Array.isArray(a) ? a : []; } catch { return []; } } // The role's default entry agent (data-driven, §0.1). Empty means "fall back to the // instance default assistant" — the server resolver handles it, so we store nothing. _attrsChatAgent(attrs) { try { const a = JSON.parse(attrs || '{}').chat_agent; return typeof a === 'string' ? a : ''; } catch { return ''; } } // Display name for a chat-agent id (falls back to the id, or the default label when unset). _agentName(id) { if (!id) return t('roles.form.assistant_default'); return this._agents?.find(a => a.id === id)?.name ?? id; } _mergeAttrs(attrs, uiMode, allowedGroups, chatAgent) { let o = {}; try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; } if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode; const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : []; if (extras.length) o.permission_groups = extras; else delete o.permission_groups; if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent; const keys = Object.keys(o); return keys.length ? JSON.stringify(o) : null; } _openCreate() { this._modal = { mode: 'create', form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '' }, }; } _openEdit(role) { this._modal = { mode: 'edit', role, form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs) }, }; } _closeModal() { this._modal = null; this._error = null; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } _toggleAllowedGroup(id, checked) { const cur = new Set(this._modal.form.allowed_groups || []); if (checked) cur.add(id); else cur.delete(id); this._patch('allowed_groups', [...cur]); } // ── API actions ────────────────────────────────────────────────────────────── async _save() { const { mode, form } = this._modal; this._error = null; if (mode === 'create') { if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; } try { const res = await fetch('/api/roles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: form.id.trim(), label: form.label.trim(), permission_group: form.permission_group, attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } else { const { role } = this._modal; if (!form.label.trim()) { this._error = t('roles.error.label'); return; } try { const res = await fetch(`/api/roles/${role.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: form.label.trim(), permission_group: form.permission_group, attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } } async _delete(role) { if (!confirm(t('roles.confirm.delete', { name: role.label }))) return; try { const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Render ────────────────────────────────────────────────────────────────── _groupLabel(groupId) { return this._groups?.find(g => g.id === groupId)?.name ?? groupId; } _renderModal() { if (!this._modal) return nothing; const { mode, form, role } = this._modal; const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label }); return html`
`; } render() { if (!this._open) return nothing; const roles = this._roles ?? []; const loading = this._roles === null; return html`${t('roles.empty')}
| ${t('roles.col.id')} | ${t('roles.col.label')} | ${t('roles.col.group')} | ${t('roles.col.interface')} | ${t('roles.col.assistant')} | |
|---|---|---|---|---|---|
${r.id} |
${r.label} | ${this._groupLabel(r.permission_group)} | ${this._attrsUiMode(r.attrs) === 'simple' ? html`${t('roles.badge.simple')}` : html`${t('roles.badge.full')}`} | ${this._agentName(this._attrsChatAgent(r.attrs))} |
|