Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
Nightly Build / build (push) Failing after 6m12s
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' }));
|
||||
|
||||
Reference in New Issue
Block a user