Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s

This commit is contained in:
2026-07-20 12:54:56 +01:00
parent b6128e4053
commit 44dc67cda0
29 changed files with 1631 additions and 110 deletions
+26
View File
@@ -25,6 +25,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true },
_groupOpen: { state: true },
_tabs: { state: true },
_activeSource: { state: true },
_cmdMenu: { state: true },
@@ -38,6 +39,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._mode = 'dock';
this._me = null;
this._modelOpen = false;
this._groupOpen = false;
this._resizing = false;
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
@@ -62,6 +64,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._restoreState();
this._loadCommands();
this._loadMe();
this._loadSecurityGroups();
// Same element, two layouts: the chat is the home page ('full') and docks
// to the side on every other route — state is never lost, it only resizes.
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
@@ -450,6 +453,29 @@ export class AppCopilot extends I18nMixin(ChatSession) {
</button>
</div>
` : nothing}
${this._securityGroups.length > 1 ? html`
<div class="copilot-model-wrap">
${this._groupOpen ? html`
<div class="copilot-model-overlay" @click=${() => { this._groupOpen = false; }}></div>
<div class="copilot-model-dropdown">
${this._securityGroups.map(g => html`
<button
class="copilot-model-item ${g.id === this._selectedGroup ? 'active' : ''}"
@click=${() => { this._selectGroup(g.id); this._groupOpen = false; }}
>${g.name}</button>
`)}
</div>
` : nothing}
<button
class="copilot-model-pill"
title=${t('chat.security_group')}
@click=${() => { this._groupOpen = !this._groupOpen; }}>
<i class="bi bi-shield-lock"></i>
<span>${this._securityGroups.find(g => g.id === this._selectedGroup)?.name ?? this._selectedGroup}</span>
<i class="bi bi-chevron-${this._groupOpen ? 'down' : 'up'}"></i>
</button>
</div>
` : nothing}
<button
class="copilot-toolbar-btn"
title=${t('chat.new_session')}
+34 -5
View File
@@ -67,10 +67,21 @@ export class RolesPage extends LightElement {
catch { return 'full'; }
}
_mergeAttrs(attrs, uiMode) {
// 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 []; }
}
_mergeAttrs(attrs, uiMode, allowedGroups) {
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;
const keys = Object.keys(o);
return keys.length ? JSON.stringify(o) : null;
}
@@ -78,7 +89,7 @@ export class RolesPage extends LightElement {
_openCreate() {
this._modal = {
mode: 'create',
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [] },
};
}
@@ -86,7 +97,7 @@ export class RolesPage extends LightElement {
this._modal = {
mode: 'edit',
role,
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs) },
};
}
@@ -96,6 +107,12 @@ export class RolesPage extends LightElement {
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() {
@@ -112,7 +129,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(),
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -129,7 +146,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -189,6 +206,18 @@ export class RolesPage extends LightElement {
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.allowed')}</label>
<div class="form-text mb-2" style="font-size:.75rem">${t('roles.form.allowed_hint')}</div>
${(this._groups ?? []).filter(g => g.id !== form.permission_group).map(g => html`
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allow-${g.id}"
.checked=${(form.allowed_groups || []).includes(g.id)}
@change=${e => this._toggleAllowedGroup(g.id, e.target.checked)} />
<label class="form-check-label" for="allow-${g.id}">${g.name}</label>
</div>
`)}
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.interface')}</label>
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
+37
View File
@@ -11,6 +11,8 @@ export class SetupPage extends I18nMixin(LightElement) {
_confirm: { state: true },
_encrypted: { state: true },
_locale: { state: true },
_profiles: { state: true },
_profile: { state: true },
_error: { state: true },
_busy: { state: true },
};
@@ -23,10 +25,29 @@ export class SetupPage extends I18nMixin(LightElement) {
this._confirm = '';
this._encrypted = true;
this._locale = getLocale();
this._profiles = [];
this._profile = 'family';
this._error = null;
this._busy = false;
}
connectedCallback() {
super.connectedCallback();
this._loadProfiles();
}
async _loadProfiles() {
try {
const res = await fetch('/api/setup/profiles');
if (!res.ok) return;
const list = await res.json();
if (Array.isArray(list) && list.length) {
this._profiles = list;
this._profile = list[0].id;
}
} catch { /* one preset ships; a failed fetch just keeps the default */ }
}
_submit(e) {
e.preventDefault();
if (this._busy) return;
@@ -60,6 +81,7 @@ export class SetupPage extends I18nMixin(LightElement) {
password: this._password,
encrypted: this._encrypted,
locale: this._locale,
profile: this._profile,
}),
});
if (!res.ok) {
@@ -92,6 +114,21 @@ export class SetupPage extends I18nMixin(LightElement) {
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
${this._profiles.length > 1 ? html`
<div class="mb-3">
<label class="form-label">${t('setup.profile')}</label>
<select
class="form-select"
.value=${this._profile}
@change=${e => this._profile = e.target.value}
?disabled=${this._busy}>
${this._profiles.map(p => html`
<option value=${p.id} ?selected=${this._profile === p.id}>${p.label}</option>
`)}
</select>
</div>
` : null}
<div class="mb-3">
<label class="form-label">${t('login.username')}</label>
<input
+5 -1
View File
@@ -59,6 +59,7 @@ export default {
'chat.thinking': 'Thinking…',
'chat.attach': 'Attach files',
'chat.new_session': 'New conversation',
'chat.security_group': 'Security group',
'chat.collapse': 'Hide chat',
'chat.close_tab': 'Close tab',
'chat.privacy': 'Private to you',
@@ -306,6 +307,7 @@ export default {
'setup.pw.mismatch': 'The two passwords do not match.',
'setup.confirm': 'Confirm password',
'setup.language': 'Interface language',
'setup.profile': 'Instance type',
'setup.encrypt': 'Encrypt my conversation history',
'setup.warn': 'your password derives the encryption key. If you forget it, your entire conversation history will be permanently lost — there is no recovery.',
'setup.warn.strong': 'Warning:',
@@ -670,7 +672,9 @@ export default {
'roles.form.id_ph': 'e.g. editor',
'roles.form.id_desc': 'Lowercase, no spaces. Cannot be changed later.',
'roles.form.label': 'Label',
'roles.form.group': 'Permission group',
'roles.form.group': 'Default security group',
'roles.form.allowed': 'Additional security groups',
'roles.form.allowed_hint': 'Groups this role may also switch to at runtime, on top of the default. Users pick from these in chat.',
'roles.form.interface': 'Interface',
'roles.form.interface_full': 'Full — all pages',
'roles.form.interface_simple': 'Simple — chat only',
+5 -1
View File
@@ -59,6 +59,7 @@ export default {
'chat.thinking': 'Réflexion…',
'chat.attach': 'Joindre des fichiers',
'chat.new_session': 'Nouvelle conversation',
'chat.security_group': 'Groupe de sécurité',
'chat.collapse': 'Masquer la discussion',
'chat.close_tab': 'Fermer l\'onglet',
'chat.privacy': 'Privé pour vous',
@@ -306,6 +307,7 @@ export default {
'setup.pw.mismatch': 'Les deux mots de passe ne correspondent pas.',
'setup.confirm': 'Confirmer le mot de passe',
'setup.language': 'Langue de l\'interface',
'setup.profile': 'Type d\'instance',
'setup.encrypt': 'Chiffrer mon historique de conversations',
'setup.warn': 'votre mot de passe génère la clé de chiffrement. Si vous l\'oubliez, tout votre historique de conversations sera définitivement perdu — il n\'y a aucune récupération possible.',
'setup.warn.strong': 'Attention :',
@@ -670,7 +672,9 @@ export default {
'roles.form.id_ph': 'ex. redacteur',
'roles.form.id_desc': 'Minuscules, sans espaces. Ne peut pas être modifié ultérieurement.',
'roles.form.label': 'Libellé',
'roles.form.group': 'Groupe de permissions',
'roles.form.group': 'Groupe de sécurité par défaut',
'roles.form.allowed': 'Groupes de sécurité supplémentaires',
'roles.form.allowed_hint': 'Groupes que ce rôle peut aussi choisir à l\'exécution, en plus du groupe par défaut. Les utilisateurs les sélectionnent dans le chat.',
'roles.form.interface': 'Interface',
'roles.form.interface_full': 'Complet — toutes les pages',
'roles.form.interface_simple': 'Simple — discussion uniquement',
+5 -1
View File
@@ -59,6 +59,7 @@ export default {
'chat.thinking': 'Sto pensando…',
'chat.attach': 'Allega file',
'chat.new_session': 'Nuova conversazione',
'chat.security_group': 'Gruppo di sicurezza',
'chat.collapse': 'Nascondi la chat',
'chat.close_tab': 'Chiudi scheda',
'chat.privacy': 'Privata',
@@ -306,6 +307,7 @@ export default {
'setup.pw.mismatch': 'Le due password non coincidono.',
'setup.confirm': 'Conferma password',
'setup.language': 'Lingua dell\'interfaccia',
'setup.profile': 'Tipo di istanza',
'setup.encrypt': 'Cifra la cronologia delle mie conversazioni',
'setup.warn': 'la tua password genera la chiave di cifratura. Se la dimentichi, l\'intera cronologia delle conversazioni andrà persa per sempre — non esiste alcun recupero.',
'setup.warn.strong': 'Attenzione:',
@@ -670,7 +672,9 @@ export default {
'roles.form.id_ph': 'es. editor',
'roles.form.id_desc': 'Minuscolo, senza spazi. Non può essere modificato in seguito.',
'roles.form.label': 'Etichetta',
'roles.form.group': 'Gruppo di permessi',
'roles.form.group': 'Gruppo di sicurezza predefinito',
'roles.form.allowed': 'Gruppi di sicurezza aggiuntivi',
'roles.form.allowed_hint': 'Gruppi a cui questo ruolo può passare a runtime, oltre al predefinito. Gli utenti li scelgono in chat.',
'roles.form.interface': 'Interfaccia',
'roles.form.interface_full': 'Completa — tutte le pagine',
'roles.form.interface_simple': 'Semplice — solo chat',
+98 -1
View File
@@ -31,6 +31,11 @@ export class ChatSession extends LightElement {
_providers: { state: true },
_selectedClient: { state: true },
_providersLoaded: { state: true },
// Session security-group (permission group) picker — the twin of the model
// pill. `_securityGroups` is the caller's selectable set; `_selectedGroup` is
// the session's current group (backend is the source of truth).
_securityGroups: { state: true },
_selectedGroup: { state: true },
_rejectingId: { state: true },
_rejectNote: { state: true },
_clarificationAnswer: { state: true },
@@ -54,9 +59,16 @@ export class ChatSession extends LightElement {
this._waiting = false;
this._expanded = new Set();
this._ws = null;
// True only for an auto-reconnect after an unexpected socket close (set in
// `onclose`), so the next `onopen` reconciles tool state that may have advanced
// while we were disconnected. A deliberate teardown (source switch / new session)
// nulls `onclose` first, so it never sets this.
this._reconnecting = false;
this._providers = [];
this._selectedClient = null;
this._providersLoaded = false;
this._securityGroups = [];
this._selectedGroup = 'default';
this._rejectingId = null;
this._rejectNote = '';
this._clarificationAnswer = '';
@@ -174,13 +186,63 @@ export class ChatSession extends LightElement {
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
this._ws = ws;
ws.onopen = () => {
// After an auto-reconnect, reconcile tool state: a terminal event
// (tool_done / tool_error) delivered while the socket was down is lost —
// the server bus is a broadcast with no replay — so a card could otherwise
// stay 'running' forever until a manual reload. Re-fetch history and advance
// any locally-unfinished card that has since reached a terminal state.
if (this._reconnecting) {
this._reconnecting = false;
this._resyncOnReconnect();
}
if (this._hasPendingTools) {
ws.send(JSON.stringify({ type: 'resume' }));
this._hasPendingTools = false;
}
};
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
ws.onclose = () => setTimeout(() => this._connectWS(), 2000);
ws.onclose = () => { this._reconnecting = true; setTimeout(() => this._connectWS(), 2000); };
}
/**
* Reconcile tool cards after an unexpected reconnect. Re-fetches the server's
* message history and advances any locally-unfinished tool card (`running` or
* `pending`) whose server row has reached a **terminal** state while the socket
* was down. Only ever moves a card *forward* to a terminal state: a tool still
* executing reads as `pending`/Interrupted in history and is deliberately left
* untouched, so this never re-shows a spinner or an approval form for live work.
*/
async _resyncOnReconnect() {
let items;
try {
const res = await fetch(`/api/${this._source}/messages`);
if (!res.ok) return;
items = await res.json();
} catch { return; }
// Terminal from the history projection: 'done', or a genuine 'error' (a tool
// that was merely interrupted mid-run surfaces as error 'Interrupted.' and is
// NOT terminal — it may still be executing).
const isTerminal = (it) =>
it.kind === 'tool' &&
(it.status === 'done' || (it.status === 'error' && it.error !== 'Interrupted.'));
for (const it of items) {
if (!isTerminal(it)) continue;
const local = this._messages.find(
m => m.kind === 'tool' && m.tool_call_id === it.tool_call_id
);
if (!local || (local.status !== 'running' && local.status !== 'pending')) continue;
this._updateTool(it.tool_call_id, {
status: it.status,
result: it.result,
result_type: it.result_type,
error: it.error,
request_id: null,
});
// Collapse a resolved approval form.
const expanded = new Set(this._expanded);
expanded.delete(it.tool_call_id);
this._expanded = expanded;
}
}
async _startNewSession() {
@@ -397,6 +459,14 @@ export class ChatSession extends LightElement {
this._selectedClient = msg.client;
break;
case 'security_group_selected':
// Twin of `client_selected`: the backend is the source of truth for the
// session's security-group. Arrives on connect (initial state) and on
// every change (this tab, another tab, or a role-default), so the picker
// stays in sync. Direct set — Lit re-renders (`_selectedGroup` is state).
this._selectedGroup = msg.group;
break;
case 'llm_failed':
this._waiting = false;
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
@@ -559,6 +629,33 @@ export class ChatSession extends LightElement {
}
}
/**
* Load the caller's selectable security-groups (the role's effective set). One
* fetch; the current selection arrives over the WS (`security_group_selected`),
* so this only feeds the dropdown's options.
*/
async _loadSecurityGroups() {
try {
const res = await fetch('/api/my/security-groups');
if (!res.ok) return;
const list = await res.json();
if (Array.isArray(list)) this._securityGroups = list;
} catch { /* the picker just stays hidden if this fails */ }
}
/**
* Pick a security-group for the current session. Mirrors [`_selectClient`]: set
* locally for instant feedback, then notify the backend, which validates against
* the role, persists on the session, and broadcasts `security_group_selected`
* back to every client (this tab included) so the picker re-syncs from truth.
*/
_selectGroup(group) {
this._selectedGroup = group;
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'select_security_group', group }));
}
}
_cancel() {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'cancel' }));