mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(react-ui): show and edit the acts of a trigger
The trigger form has a Permissions section that lists the act.* grants like the grants of an agent: a plain-words line on each, sensitive ones marked, and a grant the chosen agent does not hold cannot be ticked. It says in a callout when a run only reads and why: no acts, the agent holds none of them, the proposal said read only, or a read only connection made it. The details page shows the same, the trigger list marks a read only trigger, and the proposal card has a Permissions row, so the person sees the acts before pressing Create. The editor always sends the acts it loaded, so saving a form never drops them silently. New strings are in every locale. Assisted-by: Claude:claude-sonnet-5-5 node playwright
This commit is contained in:
28 files changed
+654
-39
No files matched your search
@@ -21,11 +21,11 @@ const HISTORY = [
|
||||
]
|
||||
|
||||
// The server, in memory: the state of the proposal and what each call does.
|
||||
async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false } = {}) {
|
||||
async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false, extra = {} } = {}) {
|
||||
const s = { state: 'pending', canUndo: true, blocked: '', calls: [] }
|
||||
const view = () => ({
|
||||
state: s.state,
|
||||
trigger: trigger(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}),
|
||||
trigger: trigger({ ...extra, ...(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}) }),
|
||||
answered_at: s.state === 'pending' ? undefined : AT,
|
||||
can_undo: s.state === 'accepted' && s.canUndo,
|
||||
undo_blocked: s.blocked || undefined,
|
||||
@@ -36,6 +36,10 @@ async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails
|
||||
await page.route('**/api/chats/c1', route => route.fulfill({ json: detail }))
|
||||
await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } }))
|
||||
await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } }))
|
||||
await page.route('**/api/chats/agents/builtin-grants', route => route.fulfill({ json: { grants: [
|
||||
{ name: 'act.files', title: 'Write files on its own', description: 'A run that nobody watches may write files inside the agent\'s folders.', sensitive: true },
|
||||
{ name: 'act.spend', title: 'Use paid or external tools on its own', description: 'A run that nobody watches may use paid or external tools and the network.', sensitive: true },
|
||||
] } }))
|
||||
await page.route('**/api/chats/triggers/preview*', route => route.fulfill({ json: { times: ['2026-11-02T08:00:00Z'], summary: 'Every Monday at 09:00' } }))
|
||||
await page.route(/\/api\/chats\/triggers\/proposals\/p1(\/[a-z]+)?$/, (route) => {
|
||||
const req = route.request()
|
||||
@@ -100,6 +104,25 @@ for (const [name, size] of [['desktop', { width: 1440, height: 900 }], ['phone',
|
||||
await page.screenshot({ path: `test-results/proposal-${name}-pending.png` })
|
||||
})
|
||||
|
||||
test('the card says a run only reads when the trigger holds no act, and why', async ({ page }) => {
|
||||
await open(page, { extra: { acts: [], acts_held: [], read_only: true, read_only_why: 'no_acts' } })
|
||||
const sum = card(page).getByTestId('trigger-acts-summary')
|
||||
await expect(sum).toHaveAttribute('data-read-only', 'true')
|
||||
await expect(sum).toContainText('Read only')
|
||||
await expect(card(page).getByTestId('trigger-acts-why')).toContainText('only read and report')
|
||||
})
|
||||
|
||||
test('the card lists the acts the trigger would run with, sensitive ones marked, before it is created', async ({ page }) => {
|
||||
const s = await open(page, { extra: { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false } })
|
||||
const sum = card(page).getByTestId('trigger-acts-summary')
|
||||
await expect(sum.locator('[data-act="act.files"]')).toContainText('Write files on its own')
|
||||
await expect(sum.locator('[data-act="act.files"]')).toContainText('needs care')
|
||||
await expect(sum.locator('[data-act="act.spend"]')).toHaveCount(0)
|
||||
await expect(card(page).getByTestId('trigger-acts-unused')).toContainText('Use paid or external tools on its own')
|
||||
expect(s.calls).not.toContain('POST confirm')
|
||||
await page.screenshot({ path: `test-results/proposal-${name}-acts.png` })
|
||||
})
|
||||
|
||||
test('accepting leaves a receipt in place, undo takes it back, and the state survives a reload', async ({ page }) => {
|
||||
const s = await open(page)
|
||||
await card(page).getByRole('button', { name: 'Create trigger' }).click()
|
||||
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Berechtigungen"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Berechtigungen"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Darf {{count}} Art von Aktion nutzen",
|
||||
"acts_other": "Darf {{count}} Arten von Aktionen nutzen"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_other": "Die Tokens eines Laufs sind nach dem ersten Lauf bekannt. Höchstens {{count}} Läufe pro Tag.",
|
||||
"remoteNote": "{{model}} ist ein entfernter Dienst: Was ein Lauf liest, verlässt diesen Server.",
|
||||
"stopWidget": "Pausiere ihn im Widget oder lösche ihn unter Trigger",
|
||||
"stop": "Schalte ihn aus oder lösche ihn unter Trigger"
|
||||
"stop": "Schalte ihn aus oder lösche ihn unter Trigger",
|
||||
"actsLabel": "Berechtigungen"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "{{count}} Dateien löschen",
|
||||
"cleared_one": "{{count}} Datei in den Papierkorb verschoben.",
|
||||
"cleared_other": "{{count}} Dateien in den Papierkorb verschoben."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Ein Lauf startet von selbst, also bestätigt niemand einen Schritt. Er kann lesen und berichten. Hake an, was er zusätzlich tun darf. Er bekommt nie mehr, als sein Agent hat.",
|
||||
"noAgent": "Es ist kein Agent gewählt, daher begrenzt nur das Angehakte den Lauf.",
|
||||
"readOnly": "Nur lesen",
|
||||
"canAct": "Darf selbstständig handeln",
|
||||
"canActText_one": "Ein Lauf darf {{count}} Art von Aktion ohne Rückfrage nutzen.",
|
||||
"canActText_other": "Ein Lauf darf {{count}} Arten von Aktionen ohne Rückfrage nutzen.",
|
||||
"sensitive": "mit Vorsicht",
|
||||
"addonRow": "Ein Lauf darf die Aktionen des Add-ons {{id}} nutzen.",
|
||||
"blocked": "Dein Agent hat dies nicht, daher lässt es sich nicht anhaken. Füge es zuerst dem Agenten hinzu.",
|
||||
"unused": "Dein Agent hat dies nicht, daher nutzt ein Lauf es nicht.",
|
||||
"unusedList": "Aufgeführt, aber nicht genutzt, weil der Agent es nicht hat: {{names}}.",
|
||||
"writesNote": "Sein Plan nennt die Datei {{file}}, daher darf ein Lauf auch Dateien schreiben.",
|
||||
"listCan_one": "Kann handeln ({{count}})",
|
||||
"listCan_other": "Kann handeln ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "Keine Berechtigung zum Handeln gesetzt, daher lesen und berichten seine Läufe nur.",
|
||||
"agent": "Sein Agent hat die verlangten Berechtigungen nicht, daher lesen und berichten seine Läufe nur.",
|
||||
"declared": "Er wurde so eingerichtet, dass er nur liest und berichtet. Nichts, was hier angehakt wird, ändert das.",
|
||||
"binding": "Eine Verbindung im Nur-Lesen-Modus hat ihn erstellt. Nichts, was hier angehakt wird, ändert das."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,8 @@
|
||||
"runMeta": "took {{time}}",
|
||||
"agent": "Agent",
|
||||
"cap": "Most runs a day",
|
||||
"watches": "Watches the folder {{folder}}"
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"acts": "Permissions"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -444,7 +445,8 @@
|
||||
"schedule": "When",
|
||||
"media": "Media sources",
|
||||
"webhooks": "Webhooks",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Permissions"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -453,7 +455,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "May use {{count}} kind of action",
|
||||
"acts_other": "May use {{count}} kinds of action"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -723,7 +723,8 @@
|
||||
"costUnknown_other": "The tokens of a run are known after the first run. At most {{count}} runs a day.",
|
||||
"remoteNote": "{{model}} is a remote service: what a run reads leaves this server.",
|
||||
"stopWidget": "Pause it from the widget, or delete it in Triggers",
|
||||
"stop": "Switch it off or delete it in Triggers"
|
||||
"stop": "Switch it off or delete it in Triggers",
|
||||
"actsLabel": "Permissions"
|
||||
}
|
||||
},
|
||||
"subAgents": {
|
||||
@@ -1052,5 +1053,27 @@
|
||||
"clearYes_other": "Delete {{count}} files",
|
||||
"cleared_one": "Moved {{count}} file to the Trash.",
|
||||
"cleared_other": "Moved {{count}} files to the Trash."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "A run starts by itself, so nobody is there to approve a step. It can read and report. Tick what it may also do. It never gets more than its agent holds.",
|
||||
"noAgent": "No agent is chosen, so only what you tick limits it.",
|
||||
"readOnly": "Read only",
|
||||
"canAct": "May act on its own",
|
||||
"canActText_one": "A run may use {{count}} kind of action without asking.",
|
||||
"canActText_other": "A run may use {{count}} kinds of action without asking.",
|
||||
"sensitive": "needs care",
|
||||
"addonRow": "A run may use the actions of the add-on {{id}}.",
|
||||
"blocked": "Your agent does not hold this, so it cannot be ticked. Add it to the agent first.",
|
||||
"unused": "Your agent does not hold this, so a run does not use it.",
|
||||
"unusedList": "Listed, but not used because the agent does not hold it: {{names}}.",
|
||||
"writesNote": "Its plan names the file {{file}}, so a run may also write files.",
|
||||
"listCan_one": "Can act ({{count}})",
|
||||
"listCan_other": "Can act ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "No permission to act is set, so its runs only read and report.",
|
||||
"agent": "Its agent does not hold the permissions it asks for, so its runs only read and report.",
|
||||
"declared": "It was set up to read and report only. Nothing ticked here changes that.",
|
||||
"binding": "A connection in read only mode made it. Nothing ticked here changes that."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Permisos"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Permisos"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Puede usar {{count}} tipo de acción",
|
||||
"acts_other": "Puede usar {{count}} tipos de acción"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_other": "Los tokens de una ejecución se conocen tras la primera. Como máximo {{count}} ejecuciones al día.",
|
||||
"remoteNote": "{{model}} es un servicio remoto: lo que lee una ejecución sale de este servidor.",
|
||||
"stopWidget": "Páusalo desde el widget o elimínalo en Disparadores",
|
||||
"stop": "Apágalo o elimínalo en Disparadores"
|
||||
"stop": "Apágalo o elimínalo en Disparadores",
|
||||
"actsLabel": "Permisos"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "Eliminar {{count}} archivos",
|
||||
"cleared_one": "{{count}} archivo movido a la Papelera.",
|
||||
"cleared_other": "{{count}} archivos movidos a la Papelera."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Una ejecución empieza sola, así que nadie aprueba un paso. Puede leer e informar. Marca lo que además puede hacer. Nunca recibe más de lo que tiene su agente.",
|
||||
"noAgent": "No hay agente elegido, así que solo lo que marques la limita.",
|
||||
"readOnly": "Solo lectura",
|
||||
"canAct": "Puede actuar por sí misma",
|
||||
"canActText_one": "Una ejecución puede usar {{count}} tipo de acción sin preguntar.",
|
||||
"canActText_other": "Una ejecución puede usar {{count}} tipos de acción sin preguntar.",
|
||||
"sensitive": "requiere cuidado",
|
||||
"addonRow": "Una ejecución puede usar las acciones del complemento {{id}}.",
|
||||
"blocked": "Tu agente no tiene esto, así que no se puede marcar. Añádelo antes al agente.",
|
||||
"unused": "Tu agente no tiene esto, así que una ejecución no lo usa.",
|
||||
"unusedList": "En la lista, pero sin uso porque el agente no lo tiene: {{names}}.",
|
||||
"writesNote": "Su plan nombra el archivo {{file}}, así que una ejecución también puede escribir archivos.",
|
||||
"listCan_one": "Puede actuar ({{count}})",
|
||||
"listCan_other": "Puede actuar ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "No tiene permiso para actuar, así que sus ejecuciones solo leen e informan.",
|
||||
"agent": "Su agente no tiene los permisos que pide, así que sus ejecuciones solo leen e informan.",
|
||||
"declared": "Se creó para leer e informar solamente. Nada de lo que marques aquí lo cambia.",
|
||||
"binding": "La creó una conexión en modo de solo lectura. Nada de lo que marques aquí lo cambia."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Permissions"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Permissions"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Peut utiliser {{count}} type d'action",
|
||||
"acts_other": "Peut utiliser {{count}} types d'action"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_other": "Les jetons d’une exécution sont connus après la première. Au plus {{count}} exécutions par jour.",
|
||||
"remoteNote": "{{model}} est un service distant : ce que lit une exécution quitte ce serveur.",
|
||||
"stopWidget": "Mettez-le en pause depuis le widget, ou supprimez-le dans Déclencheurs",
|
||||
"stop": "Désactivez-le ou supprimez-le dans Déclencheurs"
|
||||
"stop": "Désactivez-le ou supprimez-le dans Déclencheurs",
|
||||
"actsLabel": "Permissions"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "Supprimer {{count}} fichiers",
|
||||
"cleared_one": "{{count}} fichier déplacé dans la corbeille.",
|
||||
"cleared_other": "{{count}} fichiers déplacés dans la corbeille."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Une exécution démarre seule, donc personne n'approuve une étape. Elle peut lire et rendre compte. Cochez ce qu'elle peut faire en plus. Elle n'obtient jamais plus que son agent.",
|
||||
"noAgent": "Aucun agent n'est choisi, donc seul ce que vous cochez la limite.",
|
||||
"readOnly": "Lecture seule",
|
||||
"canAct": "Peut agir seule",
|
||||
"canActText_one": "Une exécution peut utiliser {{count}} type d'action sans demander.",
|
||||
"canActText_other": "Une exécution peut utiliser {{count}} types d'action sans demander.",
|
||||
"sensitive": "à manier avec soin",
|
||||
"addonRow": "Une exécution peut utiliser les actions du module {{id}}.",
|
||||
"blocked": "Votre agent n'a pas cette permission, on ne peut donc pas la cocher. Ajoutez-la d'abord à l'agent.",
|
||||
"unused": "Votre agent n'a pas cette permission, donc une exécution ne l'utilise pas.",
|
||||
"unusedList": "Listée, mais inutilisée car l'agent ne l'a pas : {{names}}.",
|
||||
"writesNote": "Son plan nomme le fichier {{file}}, donc une exécution peut aussi écrire des fichiers.",
|
||||
"listCan_one": "Peut agir ({{count}})",
|
||||
"listCan_other": "Peut agir ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "Aucune permission d'agir n'est définie, donc ses exécutions lisent et rendent compte seulement.",
|
||||
"agent": "Son agent n'a pas les permissions demandées, donc ses exécutions lisent et rendent compte seulement.",
|
||||
"declared": "Il a été créé pour lire et rendre compte seulement. Rien de ce qui est coché ici ne change cela.",
|
||||
"binding": "Une connexion en lecture seule l'a créé. Rien de ce qui est coché ici ne change cela."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Izin"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Izin"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Boleh memakai {{count}} jenis tindakan",
|
||||
"acts_other": "Boleh memakai {{count}} jenis tindakan"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_one": "Token per jalan diketahui setelah jalan pertama. Paling banyak {{count}} jalan per hari.",
|
||||
"remoteNote": "{{model}} adalah layanan jarak jauh: apa yang dibaca sebuah jalan meninggalkan server ini.",
|
||||
"stopWidget": "Jeda dari widget, atau hapus di Pemicu",
|
||||
"stop": "Matikan atau hapus di Pemicu"
|
||||
"stop": "Matikan atau hapus di Pemicu",
|
||||
"actsLabel": "Izin"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "Hapus {{count}} berkas",
|
||||
"cleared_one": "{{count}} berkas dipindahkan ke Tempat Sampah.",
|
||||
"cleared_other": "{{count}} berkas dipindahkan ke Tempat Sampah."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Satu jalan dimulai sendiri, jadi tidak ada yang menyetujui langkahnya. Ia bisa membaca dan melapor. Centang apa yang boleh dilakukannya juga. Ia tidak pernah mendapat lebih dari yang dimiliki agennya.",
|
||||
"noAgent": "Belum ada agen yang dipilih, jadi hanya centangan Anda yang membatasinya.",
|
||||
"readOnly": "Hanya baca",
|
||||
"canAct": "Boleh bertindak sendiri",
|
||||
"canActText_one": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.",
|
||||
"canActText_other": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.",
|
||||
"sensitive": "perlu hati-hati",
|
||||
"addonRow": "Satu jalan boleh memakai tindakan add-on {{id}}.",
|
||||
"blocked": "Agen Anda tidak memiliki izin ini, jadi tidak bisa dicentang. Tambahkan dulu ke agen.",
|
||||
"unused": "Agen Anda tidak memiliki izin ini, jadi satu jalan tidak memakainya.",
|
||||
"unusedList": "Terdaftar, tetapi tidak dipakai karena agen tidak memilikinya: {{names}}.",
|
||||
"writesNote": "Rencananya menyebut berkas {{file}}, jadi satu jalan juga boleh menulis berkas.",
|
||||
"listCan_one": "Bisa bertindak ({{count}})",
|
||||
"listCan_other": "Bisa bertindak ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "Tidak ada izin bertindak, jadi jalannya hanya membaca dan melapor.",
|
||||
"agent": "Agennya tidak memiliki izin yang diminta, jadi jalannya hanya membaca dan melapor.",
|
||||
"declared": "Ini dibuat untuk hanya membaca dan melapor. Centangan di sini tidak mengubahnya.",
|
||||
"binding": "Dibuat oleh koneksi dalam mode hanya baca. Centangan di sini tidak mengubahnya."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Permessi"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Permessi"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Può usare {{count}} tipo di azione",
|
||||
"acts_other": "Può usare {{count}} tipi di azione"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_other": "I token di un'esecuzione si conoscono dopo la prima. Al massimo {{count}} esecuzioni al giorno.",
|
||||
"remoteNote": "{{model}} è un servizio remoto: quello che un'esecuzione legge esce da questo server.",
|
||||
"stopWidget": "Mettilo in pausa dal widget, o eliminalo in Trigger",
|
||||
"stop": "Spegnilo o eliminalo in Trigger"
|
||||
"stop": "Spegnilo o eliminalo in Trigger",
|
||||
"actsLabel": "Permessi"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "Elimina {{count}} file",
|
||||
"cleared_one": "{{count}} file spostato nel Cestino.",
|
||||
"cleared_other": "{{count}} file spostati nel Cestino."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Un'esecuzione parte da sola, quindi nessuno approva un passo. Può leggere e riferire. Spunta ciò che può fare in più. Non ottiene mai più di quanto ha il suo agente.",
|
||||
"noAgent": "Nessun agente scelto, quindi la limita solo ciò che spunti.",
|
||||
"readOnly": "Sola lettura",
|
||||
"canAct": "Può agire da sola",
|
||||
"canActText_one": "Un'esecuzione può usare {{count}} tipo di azione senza chiedere.",
|
||||
"canActText_other": "Un'esecuzione può usare {{count}} tipi di azione senza chiedere.",
|
||||
"sensitive": "da usare con cura",
|
||||
"addonRow": "Un'esecuzione può usare le azioni del componente {{id}}.",
|
||||
"blocked": "Il tuo agente non ha questo permesso, quindi non si può spuntare. Aggiungilo prima all'agente.",
|
||||
"unused": "Il tuo agente non ha questo permesso, quindi un'esecuzione non lo usa.",
|
||||
"unusedList": "In elenco, ma non usato perché l'agente non lo ha: {{names}}.",
|
||||
"writesNote": "Il suo piano nomina il file {{file}}, quindi un'esecuzione può anche scrivere file.",
|
||||
"listCan_one": "Può agire ({{count}})",
|
||||
"listCan_other": "Può agire ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "Nessun permesso di agire, quindi le sue esecuzioni leggono e riferiscono soltanto.",
|
||||
"agent": "Il suo agente non ha i permessi richiesti, quindi le sue esecuzioni leggono e riferiscono soltanto.",
|
||||
"declared": "È stato creato per leggere e riferire soltanto. Nulla di ciò che spunti qui lo cambia.",
|
||||
"binding": "L'ha creato una connessione in sola lettura. Nulla di ciò che spunti qui lo cambia."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "권한"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "권한"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "{{count}}가지 종류의 동작 사용 가능",
|
||||
"acts_other": "{{count}}가지 종류의 동작 사용 가능"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_one": "실행당 토큰은 첫 실행 후에 알 수 있습니다. 하루 최대 {{count}}회.",
|
||||
"remoteNote": "{{model}}은(는) 원격 서비스입니다. 실행이 읽는 내용이 이 서버 밖으로 나갑니다.",
|
||||
"stopWidget": "위젯에서 일시 중지하거나 트리거에서 삭제하세요",
|
||||
"stop": "트리거에서 끄거나 삭제하세요"
|
||||
"stop": "트리거에서 끄거나 삭제하세요",
|
||||
"actsLabel": "권한"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "파일 {{count}}개 삭제",
|
||||
"cleared_one": "파일 {{count}}개를 휴지통으로 옮겼습니다.",
|
||||
"cleared_other": "파일 {{count}}개를 휴지통으로 옮겼습니다."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "실행은 스스로 시작되므로 단계를 승인해 줄 사람이 없습니다. 읽고 보고할 수 있습니다. 추가로 허용할 것을 선택하세요. 에이전트가 가진 것보다 많이 받지는 않습니다.",
|
||||
"noAgent": "에이전트를 고르지 않았으므로 선택한 항목만 제한합니다.",
|
||||
"readOnly": "읽기 전용",
|
||||
"canAct": "스스로 동작 가능",
|
||||
"canActText_one": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.",
|
||||
"canActText_other": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.",
|
||||
"sensitive": "주의 필요",
|
||||
"addonRow": "실행은 애드온 {{id}}의 동작을 쓸 수 있습니다.",
|
||||
"blocked": "에이전트에 이 권한이 없어 선택할 수 없습니다. 먼저 에이전트에 추가하세요.",
|
||||
"unused": "에이전트에 이 권한이 없어 실행에서 쓰지 않습니다.",
|
||||
"unusedList": "목록에 있지만 에이전트에 없어서 쓰지 않음: {{names}}.",
|
||||
"writesNote": "계획에 파일 {{file}}이(가) 있어 실행이 파일도 쓸 수 있습니다.",
|
||||
"listCan_one": "동작 가능 ({{count}})",
|
||||
"listCan_other": "동작 가능 ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "동작 권한이 없어 실행은 읽고 보고만 합니다.",
|
||||
"agent": "에이전트에 요청한 권한이 없어 실행은 읽고 보고만 합니다.",
|
||||
"declared": "읽고 보고만 하도록 만들어졌습니다. 여기서 선택해도 바뀌지 않습니다.",
|
||||
"binding": "읽기 전용 모드의 연결이 만들었습니다. 여기서 선택해도 바뀌지 않습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "Permissões"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "Permissões"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "Pode usar {{count}} tipo de ação",
|
||||
"acts_other": "Pode usar {{count}} tipos de ação"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_other": "Os tokens de uma execução são conhecidos após a primeira. No máximo {{count}} execuções por dia.",
|
||||
"remoteNote": "{{model}} é um serviço remoto: o que uma execução lê sai deste servidor.",
|
||||
"stopWidget": "Pause pelo widget ou exclua em Gatilhos",
|
||||
"stop": "Desligue ou exclua em Gatilhos"
|
||||
"stop": "Desligue ou exclua em Gatilhos",
|
||||
"actsLabel": "Permissões"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "Excluir {{count}} arquivos",
|
||||
"cleared_one": "{{count}} arquivo movido para a Lixeira.",
|
||||
"cleared_other": "{{count}} arquivos movidos para a Lixeira."
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "Uma execução começa sozinha, então ninguém aprova um passo. Ela pode ler e relatar. Marque o que ela também pode fazer. Ela nunca recebe mais do que o seu agente tem.",
|
||||
"noAgent": "Nenhum agente escolhido, então só o que você marcar a limita.",
|
||||
"readOnly": "Somente leitura",
|
||||
"canAct": "Pode agir sozinha",
|
||||
"canActText_one": "Uma execução pode usar {{count}} tipo de ação sem perguntar.",
|
||||
"canActText_other": "Uma execução pode usar {{count}} tipos de ação sem perguntar.",
|
||||
"sensitive": "requer cuidado",
|
||||
"addonRow": "Uma execução pode usar as ações do complemento {{id}}.",
|
||||
"blocked": "Seu agente não tem esta permissão, então ela não pode ser marcada. Adicione-a antes ao agente.",
|
||||
"unused": "Seu agente não tem esta permissão, então uma execução não a usa.",
|
||||
"unusedList": "Listada, mas sem uso porque o agente não a tem: {{names}}.",
|
||||
"writesNote": "O plano cita o arquivo {{file}}, então uma execução também pode gravar arquivos.",
|
||||
"listCan_one": "Pode agir ({{count}})",
|
||||
"listCan_other": "Pode agir ({{count}})",
|
||||
"why": {
|
||||
"no_acts": "Sem permissão para agir, então suas execuções só leem e relatam.",
|
||||
"agent": "Seu agente não tem as permissões pedidas, então suas execuções só leem e relatam.",
|
||||
"declared": "Foi criado para apenas ler e relatar. Nada que você marque aqui muda isso.",
|
||||
"binding": "Foi criado por uma conexão em modo somente leitura. Nada que você marque aqui muda isso."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,8 @@
|
||||
"watches": "Watches the folder {{folder}}",
|
||||
"noRuns": "This trigger has not run yet.",
|
||||
"runMeta": "took {{time}}",
|
||||
"prompt": "What to do"
|
||||
"prompt": "What to do",
|
||||
"acts": "权限"
|
||||
},
|
||||
"edit": {
|
||||
"titleNew": "New trigger",
|
||||
@@ -234,7 +235,8 @@
|
||||
"basics": "Basics",
|
||||
"prompt": "What to do",
|
||||
"schedule": "When",
|
||||
"limits": "Limits"
|
||||
"limits": "Limits",
|
||||
"acts": "权限"
|
||||
},
|
||||
"summary": {
|
||||
"basics": "{{name}}, {{model}}",
|
||||
@@ -242,7 +244,9 @@
|
||||
"promptEmpty": "Nothing to do yet",
|
||||
"folderEmpty": "Needs a folder",
|
||||
"cap": "Up to {{count}} runs a day",
|
||||
"capDefault": "Default limit"
|
||||
"capDefault": "Default limit",
|
||||
"acts_one": "可使用 {{count}} 类操作",
|
||||
"acts_other": "可使用 {{count}} 类操作"
|
||||
},
|
||||
"name": "Name",
|
||||
"namePlaceholder": "daily-digest",
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"costUnknown_one": "每次运行的 token 数在首次运行后可知。每天最多 {{count}} 次。",
|
||||
"remoteNote": "{{model}} 是远程服务:运行读取的内容会离开此服务器。",
|
||||
"stopWidget": "在小组件中暂停,或在触发器中删除",
|
||||
"stop": "在触发器中关闭或删除"
|
||||
"stop": "在触发器中关闭或删除",
|
||||
"actsLabel": "权限"
|
||||
}
|
||||
},
|
||||
"mcpPage": {
|
||||
@@ -730,5 +731,27 @@
|
||||
"clearYes_other": "删除 {{count}} 个文件",
|
||||
"cleared_one": "已将 {{count}} 个文件移到回收站。",
|
||||
"cleared_other": "已将 {{count}} 个文件移到回收站。"
|
||||
},
|
||||
"triggerActs": {
|
||||
"help": "运行会自行开始,没有人批准每一步。它可以读取并汇报。勾选它还可以做什么。它得到的权限不会超过其代理。",
|
||||
"noAgent": "未选择代理,因此只有你勾选的项目起限制作用。",
|
||||
"readOnly": "只读",
|
||||
"canAct": "可自行操作",
|
||||
"canActText_one": "一次运行可在不询问的情况下使用 {{count}} 类操作。",
|
||||
"canActText_other": "一次运行可在不询问的情况下使用 {{count}} 类操作。",
|
||||
"sensitive": "需谨慎",
|
||||
"addonRow": "一次运行可使用附加组件 {{id}} 的操作。",
|
||||
"blocked": "你的代理没有此权限,因此无法勾选。请先将它添加到代理。",
|
||||
"unused": "你的代理没有此权限,因此运行不会使用它。",
|
||||
"unusedList": "已列出,但代理没有,所以不使用:{{names}}。",
|
||||
"writesNote": "其计划指明了文件 {{file}},因此一次运行也可以写入文件。",
|
||||
"listCan_one": "可操作({{count}})",
|
||||
"listCan_other": "可操作({{count}})",
|
||||
"why": {
|
||||
"no_acts": "未设置操作权限,因此其运行只读取并汇报。",
|
||||
"agent": "其代理没有它要求的权限,因此其运行只读取并汇报。",
|
||||
"declared": "它被设置为只读取并汇报。在此勾选不会改变这一点。",
|
||||
"binding": "它由只读模式的连接创建。在此勾选不会改变这一点。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBuiltinGrants } from '../hooks/useBuiltinGrants'
|
||||
import { actNamed, actRows, actsAfter, actsStatus, actsStatusOf, actCatalog } from '../utils/triggerActs'
|
||||
import { Callout } from './ui'
|
||||
import './trigger-acts.css'
|
||||
|
||||
// What the runs of a trigger may do with nobody present. Three views of the
|
||||
// same rules (utils/triggerActs.js):
|
||||
// ActsEditor the checklist of the trigger form, drawn like the grants of an
|
||||
// agent: a plain-words line on each, sensitive ones marked
|
||||
// ActsSummary the read only view of a trigger the server returned (details
|
||||
// page, proposal card)
|
||||
// ReadOnlyTag a short mark for a list row
|
||||
// A run that holds no act only reads and reports. The server enforces this at
|
||||
// each tool call; the interface only shows it.
|
||||
|
||||
function Tag({ children }) {
|
||||
return <span className="ta-tag">{children}</span>
|
||||
}
|
||||
|
||||
/** One short reason why a run only reads, in plain words. */
|
||||
function ReadOnlyWhy({ why, t }) {
|
||||
return <>{t(`triggerActs.why.${why || 'no_acts'}`)}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable list. value is the acts of the form, agentGrants the grants of
|
||||
* the chosen agent (null: no agent chosen), writes the file the plan names,
|
||||
* lock why the trigger is read only whatever is ticked.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
|
||||
export function ActsEditor({ value, onChange, agentGrants, writes = '', lock = '' }) {
|
||||
const { t } = useTranslation('chats')
|
||||
const catalog = actCatalog(useBuiltinGrants())
|
||||
const rows = actRows(catalog, value, agentGrants)
|
||||
const status = actsStatus({ acts: value, agentGrants, writes, lock })
|
||||
return (
|
||||
<div className="ta" data-testid="trigger-acts">
|
||||
<p className="dk-hint">{t('triggerActs.help')}</p>
|
||||
{agentGrants == null && <p className="dk-hint">{t('triggerActs.noAgent')}</p>}
|
||||
<Callout tone={status.readOnly ? 'info' : 'warn'} testId="trigger-acts-status" title={status.readOnly ? t('triggerActs.readOnly') : t('triggerActs.canAct')}>
|
||||
{status.readOnly
|
||||
? <ReadOnlyWhy why={status.why} t={t} />
|
||||
: t('triggerActs.canActText', { count: status.held.length })}
|
||||
</Callout>
|
||||
{writes && !lock && <p className="dk-hint" data-testid="trigger-acts-writes">{t('triggerActs.writesNote', { file: writes })}</p>}
|
||||
<div className="ag-grants" data-testid="trigger-acts-list">
|
||||
{rows.map((g) => (
|
||||
<label key={g.name} className="ag-grants__row" data-testid="trigger-act" data-act={g.name} data-blocked={g.blocked || undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={g.on}
|
||||
disabled={!!lock || (g.blocked && !g.on)}
|
||||
onChange={(e) => onChange(actsAfter(value, g.name, e.target.checked))}
|
||||
/>
|
||||
<span className="ag-grants__text">
|
||||
<span className="ag-grants__title">
|
||||
{g.title}
|
||||
{g.sensitive && <Tag>{t('triggerActs.sensitive')}</Tag>}
|
||||
</span>
|
||||
<span className="ag-note">{g.extra ? t('triggerActs.addonRow', { id: g.name.split(':')[1] }) : g.description}</span>
|
||||
{g.blocked && <span className="ag-note" data-testid="trigger-act-blocked">{g.on ? t('triggerActs.unused') : t('triggerActs.blocked')}</span>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read only view of a trigger as the server returned it. compact leaves out
|
||||
* the explanation of each grant (the proposal card is short).
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
|
||||
export function ActsSummary({ trigger, compact = false }) {
|
||||
const { t } = useTranslation('chats')
|
||||
const catalog = actCatalog(useBuiltinGrants())
|
||||
const status = actsStatusOf(trigger)
|
||||
if (status.readOnly || status.held.length === 0) {
|
||||
const why = status.why || (status.unused.length > 0 ? 'agent' : 'no_acts')
|
||||
return (
|
||||
<div className="ta ta--sum" data-testid="trigger-acts-summary" data-read-only="true">
|
||||
<b>{t('triggerActs.readOnly')}</b>{' '}<span data-testid="trigger-acts-why"><ReadOnlyWhy why={why} t={t} /></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const rows = actNamed(catalog, status.held)
|
||||
const unused = actNamed(catalog, status.unused)
|
||||
return (
|
||||
<div className="ta ta--sum" data-testid="trigger-acts-summary">
|
||||
<ul className="ta-list">
|
||||
{rows.map((g) => (
|
||||
<li key={g.name} data-act={g.name}>
|
||||
<b>{g.title}</b>{g.sensitive && <Tag>{t('triggerActs.sensitive')}</Tag>}
|
||||
{!compact && g.description && <span className="ag-note ta-desc"> {g.description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{unused.length > 0 && (
|
||||
<p className="ag-note" data-testid="trigger-acts-unused">{t('triggerActs.unusedList', { names: unused.map((g) => g.title).join(', ') })}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A mark for a list row: nothing for a run that can act, "Read only" for one that cannot. */
|
||||
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
|
||||
export function ReadOnlyTag({ trigger }) {
|
||||
const { t } = useTranslation('chats')
|
||||
const status = actsStatusOf(trigger)
|
||||
if (!status.readOnly) return <span data-testid="trigger-acts-tag" data-read-only="false">{t('triggerActs.listCan', { count: status.held.length })}</span>
|
||||
return <span data-testid="trigger-acts-tag" data-read-only="true" title={t(`triggerActs.why.${status.why || 'no_acts'}`)}>{t('triggerActs.readOnly')}</span>
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { proposalEdit, proposalView, receiptWhen } from '../../utils/chatProposa
|
||||
import { useWellKnown } from '../../hooks/useWellKnown'
|
||||
import { widgetList } from '../../utils/wellKnown'
|
||||
import { plainSummary } from '../../utils/widgetTrigger'
|
||||
import { ActsSummary } from '../TriggerActs'
|
||||
import Icon from '../Icon'
|
||||
import { Button } from '../ui'
|
||||
|
||||
@@ -49,6 +50,7 @@ function Plan({ p, t }) {
|
||||
? <><code>{p.plan.writes}</code>{t('proposal.plan.writesWidget', { widget: widget.title })}</>
|
||||
: <code>{p.plan.writes}</code>],
|
||||
['cost', costText],
|
||||
['acts', <ActsSummary key="acts" trigger={p} compact />],
|
||||
['stop', t(widget ? 'proposal.plan.stopWidget' : 'proposal.plan.stop')],
|
||||
].filter(Boolean)
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* What the runs of a trigger may do alone: the editor list, the summary and the tag. */
|
||||
.ta { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.ta--sum { margin: 0; }
|
||||
.ta-tag { margin-left: var(--sp-2); font-size: var(--fs-small); color: var(--dk-muted); }
|
||||
.ta-list { margin: 0; padding-left: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.ta-desc { display: block; }
|
||||
@@ -14,6 +14,7 @@ import { JobMark, JobStrip } from '../components/agents/JobBits'
|
||||
import { jobSentence } from '../utils/agentJobText'
|
||||
import { AgentViews, StatusMark } from '../components/agents/AgentBits'
|
||||
import { ClockTime, OutputBlock, RelTime, RunItem } from '../components/agents/RunBits'
|
||||
import { ReadOnlyTag } from '../components/TriggerActs'
|
||||
import Icon from '../components/Icon'
|
||||
import { Button, EmptyState, LoadError, Page, PageHeader, Segmented, SkeletonRows, Switch } from '../components/ui'
|
||||
import './agents.css'
|
||||
@@ -361,6 +362,7 @@ function TriggerRow({ task, jobs, agent, onToggle, onRun, onOpen, onEdit, onDele
|
||||
: <span className="ag-mono"><Icon name="cube" />{task.model || t('jobs.tasks.noAgent')}</span>}
|
||||
{!off && Date.parse(task.next_run || '') > Date.now() && <span><Icon name="calendar" />{t('jobs.tasks.next')} <RelTime iso={task.next_run} /></span>}
|
||||
{off && <span>{t('jobs.task.disabled')}</span>}
|
||||
<ReadOnlyTag trigger={task} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="ag-row__record">
|
||||
|
||||
@@ -21,10 +21,12 @@ import { jobOfRun, STRIP } from '../utils/agentJobs'
|
||||
import { jobSentence } from '../utils/agentJobText'
|
||||
import { formatDuration, firstLine } from '../utils/agentRuns'
|
||||
import { jobDurationMs } from '../utils/agentJobs'
|
||||
import { actsStatus } from '../utils/triggerActs'
|
||||
import {
|
||||
EVERY_HOURS, EVERY_MINUTES, HOURLY_MINUTES, dayCodes, emptyTrigger, formatNext, joinTimes, presetOrder,
|
||||
scheduleText, triggerForm, triggerPayload,
|
||||
} from '../utils/chatTrigger'
|
||||
import { ActsEditor, ActsSummary } from '../components/TriggerActs'
|
||||
import Icon from '../components/Icon'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { Button, LoadError, Page, PageHeader, SkeletonRows, Switch } from '../components/ui'
|
||||
@@ -178,6 +180,8 @@ function TaskView({ id }) {
|
||||
{task.kind === 'file' ? t('jobs.task.watches', { folder: task.folder }) : task.summary}
|
||||
{task.next_run && !off ? <span className="ag-muted"> · {t('chats:triggersPage.next', { time: formatNext(task.next_run, task.time_zone) })}</span> : null}
|
||||
</dd>
|
||||
<dt>{t('jobs.task.acts')}</dt>
|
||||
<dd data-testid="task-acts"><ActsSummary trigger={task} /></dd>
|
||||
<dt>{t('jobs.task.cap')}</dt>
|
||||
<dd className="ag-facts__model">{task.daily_cap || '-'}</dd>
|
||||
<dt>{t('jobs.task.enabledSwitch')}</dt>
|
||||
@@ -243,6 +247,7 @@ const SECTIONS = [
|
||||
{ id: 'basics', keys: ['name', 'agent_id', 'model', 'enabled'] },
|
||||
{ id: 'prompt', keys: ['prompt'] },
|
||||
{ id: 'schedule', keys: ['kind', 'schedule', 'time_zone', 'folder', 'quiet_seconds'] },
|
||||
{ id: 'acts', keys: ['acts'] },
|
||||
{ id: 'limits', keys: ['daily_cap'] },
|
||||
]
|
||||
|
||||
@@ -440,6 +445,9 @@ function TaskForm({ id, isNew }) {
|
||||
})
|
||||
|
||||
const picked = agents.find(a => a.id === task.agentId)
|
||||
// The grants of the chosen agent cap what the trigger can hold; with no agent only the list does.
|
||||
const agentGrants = picked ? (picked.builtins || []) : null
|
||||
const status = actsStatus({ acts: task.acts, agentGrants, writes: task.writes, lock: task.lock })
|
||||
const summary = (sid) => {
|
||||
if (sid === 'basics') {
|
||||
if (!task.name.trim()) return { state: 'needs', text: t('jobs.edit.summary.basicsEmpty') }
|
||||
@@ -452,6 +460,10 @@ function TaskForm({ id, isNew }) {
|
||||
if (task.schedule.preset === 'file') return { state: task.folder.trim() ? 'ready' : 'needs', text: task.folder.trim() ? t('jobs.task.watches', { folder: task.folder }) : t('jobs.edit.summary.folderEmpty') }
|
||||
return { state: 'ready', text: scheduleText(task.schedule) }
|
||||
}
|
||||
if (sid === 'acts') {
|
||||
if (status.readOnly) return { state: 'ready', text: t('chats:triggerActs.readOnly') }
|
||||
return { state: 'ready', text: t('jobs.edit.summary.acts', { count: status.held.length }) }
|
||||
}
|
||||
return { state: 'ready', text: task.dailyCap ? t('jobs.edit.summary.cap', { count: Number(task.dailyCap) }) : t('jobs.edit.summary.capDefault') }
|
||||
}
|
||||
|
||||
@@ -564,6 +576,13 @@ function TaskForm({ id, isNew }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (sid === 'acts') {
|
||||
return (
|
||||
<div className="aj-form">
|
||||
<ActsEditor value={task.acts} onChange={(acts) => set('acts', acts)} agentGrants={agentGrants} writes={task.writes} lock={task.lock} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="aj-form">
|
||||
<div className="dk-field aj-time">
|
||||
|
||||
+7
@@ -126,6 +126,7 @@ export function emptyTrigger() {
|
||||
return {
|
||||
id: '', name: '', prompt: '', enabled: true, schedule: emptyForm(), zone: browserZone(),
|
||||
folder: '', quiet: '', dailyCap: '', agentId: '', model: '', sourceChatId: '',
|
||||
acts: [], writes: '', lock: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +136,10 @@ export function triggerForm(tr) {
|
||||
id: tr.id, name: tr.name, prompt: tr.prompt, enabled: tr.enabled, schedule: presetOf(tr), zone: tr.time_zone || browserZone(),
|
||||
folder: tr.folder || '', quiet: tr.quiet_seconds ? String(tr.quiet_seconds) : '', dailyCap: String(tr.daily_cap || ''),
|
||||
agentId: tr.agent_id || '', model: tr.model || '', sourceChatId: tr.source_chat_id || '',
|
||||
// What a run may do alone. writes (the file its plan names) and lock (why a
|
||||
// run is read only whatever is ticked) come from the server and are not sent.
|
||||
acts: Array.isArray(tr.acts) ? tr.acts : [], writes: tr.plan?.writes || '',
|
||||
lock: tr.read_only && (tr.read_only_why === 'declared' || tr.read_only_why === 'binding') ? tr.read_only_why : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +149,8 @@ export function triggerPayload(v) {
|
||||
const body = {
|
||||
name: v.name.trim(), prompt: v.prompt, kind: file ? 'file' : 'schedule', enabled: v.enabled,
|
||||
daily_cap: v.dailyCap ? Number(v.dailyCap) : 0,
|
||||
// Always sent: an edit that leaves acts out keeps them, an empty list takes them away.
|
||||
acts: v.acts || [],
|
||||
agent_id: v.agentId || undefined, model: v.model || undefined, source_chat_id: v.sourceChatId || undefined,
|
||||
}
|
||||
if (file) {
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// What the runs of a trigger may do with nobody present: the act.* names of a
|
||||
// trigger. A run starts read only; each name adds one class of action, within
|
||||
// the grants of the trigger's agent. These functions are the rules the editor,
|
||||
// the details page and the proposal card share, so they say the same thing.
|
||||
// The server applies the same rules at each tool call. No React, so a node test
|
||||
// drives them.
|
||||
|
||||
const list = (x) => (Array.isArray(x) ? x : [])
|
||||
|
||||
export const ACT_FILES = 'act.files'
|
||||
export const ACT_ADDONS = 'act.addons'
|
||||
|
||||
/** True for the name of an act: act.files, act.addons, act.addons:<id>, act.send, act.spend. */
|
||||
export const isActGrant = (name) => typeof name === 'string' && name.startsWith('act.')
|
||||
|
||||
/** The act.* entries of the builtin grants the server declares. */
|
||||
export function actCatalog(grants) {
|
||||
return list(grants).filter((g) => isActGrant(g?.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an agent's grants cover an act: the name itself, or act.addons for
|
||||
* act.addons:<id>. Without an agent (null) nothing but the trigger's own list
|
||||
* limits it.
|
||||
*/
|
||||
export function withinAgent(name, agentGrants) {
|
||||
if (agentGrants == null) return true
|
||||
const held = list(agentGrants)
|
||||
return held.includes(name) || (name.startsWith(`${ACT_ADDONS}:`) && held.includes(ACT_ADDONS))
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of the permission list: every act the server declares, on when the
|
||||
* trigger holds it, and blocked when the agent does not hold it (a blocked
|
||||
* row can be switched off but not on). An act the trigger holds that the
|
||||
* server does not list, such as act.addons:<id>, stays as an extra row so it
|
||||
* can be taken away.
|
||||
*/
|
||||
export function actRows(catalog, acts, agentGrants) {
|
||||
const on = new Set(list(acts))
|
||||
const known = new Set(list(catalog).map((g) => g.name))
|
||||
const rows = list(catalog).map((g) => ({
|
||||
name: g.name, title: g.title, description: g.description, sensitive: !!g.sensitive,
|
||||
on: on.has(g.name), blocked: !withinAgent(g.name, agentGrants), extra: false,
|
||||
}))
|
||||
for (const name of list(acts)) {
|
||||
if (known.has(name)) continue
|
||||
rows.push({ name, title: name, description: '', sensitive: true, on: true, blocked: !withinAgent(name, agentGrants), extra: true })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The acts after one is switched on or off. */
|
||||
export function actsAfter(acts, name, on) {
|
||||
const rest = list(acts).filter((x) => x !== name)
|
||||
return on ? [...rest, name] : rest
|
||||
}
|
||||
|
||||
/**
|
||||
* What the runs of a trigger can do, from the fields of the form:
|
||||
* { readOnly, why, held, unused }. held are the acts that apply; unused the
|
||||
* ones the trigger lists but its agent does not hold. why is declared or
|
||||
* binding (set by how the trigger was made, and nothing ticked changes it),
|
||||
* agent (it lists acts its agent holds none of) or no_acts. A trigger whose
|
||||
* plan names a file it writes also holds act.files, as the server does.
|
||||
*/
|
||||
export function actsStatus({ acts, agentGrants = null, writes = '', lock = '' }) {
|
||||
const listed = list(acts)
|
||||
if (lock) return { readOnly: true, why: lock, held: [], unused: listed }
|
||||
const held = listed.filter((n) => withinAgent(n, agentGrants))
|
||||
if (writes && !held.includes(ACT_FILES) && withinAgent(ACT_FILES, agentGrants)) held.push(ACT_FILES)
|
||||
const unused = listed.filter((n) => !held.includes(n))
|
||||
if (held.length === 0) return { readOnly: true, why: listed.length > 0 ? 'agent' : 'no_acts', held, unused }
|
||||
return { readOnly: false, why: '', held, unused }
|
||||
}
|
||||
|
||||
/** The same answer from a trigger as the server returns it (acts, acts_held, read_only, read_only_why). */
|
||||
export function actsStatusOf(trigger) {
|
||||
const acts = list(trigger?.acts)
|
||||
const held = list(trigger?.acts_held)
|
||||
return {
|
||||
readOnly: !!trigger?.read_only, why: trigger?.read_only_why || '', held,
|
||||
unused: trigger?.read_only ? acts : acts.filter((n) => !held.includes(n)),
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows of a list of act names, with the title and weight the server declares. */
|
||||
export function actNamed(catalog, names) {
|
||||
const byName = new Map(list(catalog).map((g) => [g.name, g]))
|
||||
return list(names).map((name) => {
|
||||
const g = byName.get(name)
|
||||
return g
|
||||
? { name, title: g.title, description: g.description, sensitive: !!g.sensitive }
|
||||
: { name, title: name, description: '', sensitive: name.startsWith(`${ACT_ADDONS}:`) }
|
||||
})
|
||||
}
|
||||
@@ -49,7 +49,7 @@ test('chat triggers: a stored file trigger is the file preset', () => {
|
||||
test('chat triggers: the request body of a schedule and of a file trigger', () => {
|
||||
const base = { name: ' Digest ', prompt: 'Go.', enabled: true, dailyCap: '', agentId: '', model: 'm', sourceChatId: '', zone: 'Europe/Rome', folder: '', quiet: '' }
|
||||
assert.deepEqual(triggerPayload({ ...base, schedule: form({ preset: 'weekdays', time: '09:00' }) }), {
|
||||
name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, agent_id: undefined, model: 'm', source_chat_id: undefined,
|
||||
name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, acts: [], agent_id: undefined, model: 'm', source_chat_id: undefined,
|
||||
schedule: 'weekdays 09:00', time_zone: 'Europe/Rome',
|
||||
})
|
||||
const file = triggerPayload({ ...base, schedule: form({ preset: 'file' }), folder: ' /Contracts ', quiet: '45', dailyCap: '5', agentId: 'a1' })
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
actCatalog, actNamed, actRows, actsAfter, actsStatus, actsStatusOf, isActGrant, withinAgent,
|
||||
} from '../../src/utils/triggerActs.js'
|
||||
import { emptyTrigger, triggerForm, triggerPayload } from '../../src/utils/chatTrigger.js'
|
||||
|
||||
const grants = [
|
||||
{ name: 'memory.write', title: 'Save memory', description: 'd', sensitive: false },
|
||||
{ name: 'act.files', title: 'Write files on its own', description: 'files', sensitive: true },
|
||||
{ name: 'act.addons', title: 'Use add-on actions on its own', description: 'addons', sensitive: true },
|
||||
{ name: 'act.send', title: 'Send messages on its own', description: 'send', sensitive: true },
|
||||
{ name: 'act.spend', title: 'Use paid or external tools on its own', description: 'spend', sensitive: true },
|
||||
]
|
||||
|
||||
test('trigger acts: the catalog keeps only the act names', () => {
|
||||
assert.deepEqual(actCatalog(grants).map((g) => g.name), ['act.files', 'act.addons', 'act.send', 'act.spend'])
|
||||
assert.deepEqual(actCatalog(undefined), [])
|
||||
assert.equal(isActGrant('act.files'), true)
|
||||
assert.equal(isActGrant('memory.write'), false)
|
||||
})
|
||||
|
||||
test('trigger acts: an agent covers an act by name, or act.addons covers one add-on', () => {
|
||||
assert.equal(withinAgent('act.files', ['act.files']), true)
|
||||
assert.equal(withinAgent('act.spend', ['act.files']), false)
|
||||
assert.equal(withinAgent('act.addons:mail', ['act.addons']), true)
|
||||
assert.equal(withinAgent('act.addons:mail', ['act.files']), false)
|
||||
assert.equal(withinAgent('act.spend', null), true, 'no agent, no ceiling')
|
||||
assert.equal(withinAgent('act.spend', []), false, 'an agent with no grants covers nothing')
|
||||
})
|
||||
|
||||
test('trigger acts: rows are on when held, blocked when the agent lacks them, and keep an unlisted act', () => {
|
||||
const rows = actRows(actCatalog(grants), ['act.files', 'act.addons:mail'], ['act.files'])
|
||||
const by = Object.fromEntries(rows.map((r) => [r.name, r]))
|
||||
assert.equal(by['act.files'].on, true)
|
||||
assert.equal(by['act.files'].blocked, false)
|
||||
assert.equal(by['act.spend'].on, false)
|
||||
assert.equal(by['act.spend'].blocked, true)
|
||||
assert.equal(by['act.files'].sensitive, true)
|
||||
assert.equal(by['act.addons:mail'].extra, true)
|
||||
assert.equal(by['act.addons:mail'].on, true)
|
||||
assert.equal(by['act.addons:mail'].blocked, true, 'the agent holds act.files only')
|
||||
assert.equal(rows.length, 5)
|
||||
})
|
||||
|
||||
test('trigger acts: switching an act on or off', () => {
|
||||
assert.deepEqual(actsAfter(['act.files'], 'act.send', true), ['act.files', 'act.send'])
|
||||
assert.deepEqual(actsAfter(['act.files', 'act.send'], 'act.files', false), ['act.send'])
|
||||
assert.deepEqual(actsAfter(undefined, 'act.send', false), [])
|
||||
})
|
||||
|
||||
test('trigger acts: a trigger with no acts is read only and says so', () => {
|
||||
const s = actsStatus({ acts: [], agentGrants: ['act.files'] })
|
||||
assert.deepEqual([s.readOnly, s.why], [true, 'no_acts'])
|
||||
})
|
||||
|
||||
test('trigger acts: acts the agent does not hold do not apply, and the reason is the agent', () => {
|
||||
const s = actsStatus({ acts: ['act.spend'], agentGrants: ['act.files'] })
|
||||
assert.deepEqual([s.readOnly, s.why, s.held, s.unused], [true, 'agent', [], ['act.spend']])
|
||||
const mixed = actsStatus({ acts: ['act.spend', 'act.files'], agentGrants: ['act.files'] })
|
||||
assert.deepEqual([mixed.readOnly, mixed.held, mixed.unused], [false, ['act.files'], ['act.spend']])
|
||||
})
|
||||
|
||||
test('trigger acts: a plan that names a file it writes holds act.files', () => {
|
||||
const s = actsStatus({ acts: [], agentGrants: ['act.files'], writes: 'data/out.json' })
|
||||
assert.deepEqual([s.readOnly, s.held], [false, ['act.files']])
|
||||
assert.equal(actsStatus({ acts: [], agentGrants: [], writes: 'data/out.json' }).readOnly, true, 'the agent must hold it too')
|
||||
})
|
||||
|
||||
test('trigger acts: a lock from the way it was made wins over every act', () => {
|
||||
for (const lock of ['declared', 'binding']) {
|
||||
const s = actsStatus({ acts: ['act.files'], agentGrants: ['act.files'], lock })
|
||||
assert.deepEqual([s.readOnly, s.why, s.held], [true, lock, []])
|
||||
}
|
||||
})
|
||||
|
||||
test('trigger acts: the status of a trigger as the server returns it', () => {
|
||||
const t = { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false }
|
||||
assert.deepEqual(actsStatusOf(t), { readOnly: false, why: '', held: ['act.files'], unused: ['act.spend'] })
|
||||
const ro = actsStatusOf({ acts: ['act.files'], read_only: true, read_only_why: 'binding' })
|
||||
assert.deepEqual([ro.readOnly, ro.why, ro.unused], [true, 'binding', ['act.files']])
|
||||
assert.equal(actsStatusOf(null).readOnly, false)
|
||||
})
|
||||
|
||||
test('trigger acts: names get the title and weight of the catalog', () => {
|
||||
const rows = actNamed(grants, ['act.send', 'act.addons:mail'])
|
||||
assert.deepEqual(rows.map((r) => [r.name, r.title, r.sensitive]), [
|
||||
['act.send', 'Send messages on its own', true],
|
||||
['act.addons:mail', 'act.addons:mail', true],
|
||||
])
|
||||
})
|
||||
|
||||
test('trigger acts: the editor sends the acts it loaded, and an empty list when none', () => {
|
||||
const loaded = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', acts: ['act.files'], read_only: false,
|
||||
plan: { writes: 'data/out.json' } })
|
||||
assert.deepEqual(loaded.acts, ['act.files'])
|
||||
assert.equal(loaded.writes, 'data/out.json')
|
||||
assert.deepEqual(triggerPayload(loaded).acts, ['act.files'])
|
||||
assert.deepEqual(triggerPayload(emptyTrigger()).acts, [], 'a new trigger sends an empty list: read only')
|
||||
const locked = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', read_only: true, read_only_why: 'declared' })
|
||||
assert.equal(locked.lock, 'declared')
|
||||
assert.deepEqual(triggerPayload(locked).acts, [])
|
||||
})
|
||||
Reference in new issue
Block a user