diff --git a/addons/internal/connector-ui/src/CapabilitiesPanel.jsx b/addons/internal/connector-ui/src/CapabilitiesPanel.jsx index 2ca2f90242..8d91c05eb5 100644 --- a/addons/internal/connector-ui/src/CapabilitiesPanel.jsx +++ b/addons/internal/connector-ui/src/CapabilitiesPanel.jsx @@ -8,7 +8,7 @@ import Icon from '../../host-compat/src/components/Icon' import { basePath } from '../../host-compat/src/utils/basePath.js' import { FixNotice, Select } from './controls' import { connectorsApi } from './api' -import { THEN_CHECK, fixAction, fixLabelKey, pickSubject, previewBody, previewId, previewKey, senderOptions } from './access' +import { fixAction, remainingChecks, sentenceCase, fixLabelKey, pickSubject, previewBody, previewId, previewKey, senderOptions } from './access' export const CAN_TEXT = { 'can.title': 'What this bot can do', @@ -152,8 +152,8 @@ function Row({ d, title, s, ctx }) { {title(d.capability)} {d.allowed ? (via && {via}) : (<> {d.message || d.reason || s('can.noMessage')} - {(d.then_check || []).length > 0 && ( - {s('can.then', { list: d.then_check.filter((x) => THEN_CHECK.includes(x)).map((x) => s(`can.then.${x}`)).join(', ') })} + {remainingChecks(d.then_check).length > 0 && ( + {s('can.then', { list: remainingChecks(d.then_check).map((x) => s(`can.then.${x}`)).join(', ') })} )} )} @@ -166,7 +166,7 @@ export default function CapabilitiesPanel({ form, kindInfo, capabilities, lists, const options = senderOptions(form, kindInfo, lists) const [choice, setChoice] = useState('listed') const selected = options.some((o) => o.id === choice) ? choice : options[0].id - const title = (name) => (capabilities || []).find((c) => c.name === name)?.title || name + const title = (name) => sentenceCase((capabilities || []).find((c) => c.name === name)?.title || name) const label = (o) => (o.key === 'role' ? s('can.sender.role', { title: o.title }) : s(`can.sender.${o.key}`)) const subject = pickSubject(preview.view, selected) const decisions = subject?.decisions || [] diff --git a/addons/internal/connector-ui/src/access.js b/addons/internal/connector-ui/src/access.js index 412ab0b3c9..8e53b3765e 100644 --- a/addons/internal/connector-ui/src/access.js +++ b/addons/internal/connector-ui/src/access.js @@ -156,6 +156,16 @@ export function fixLabelKey(fix) { return `fix.${fix.kind}` } +// A title as a sentence, with the product name right. +export function sentenceCase(text) { + const t = String(text || '').replace(/\blocalai\b/gi, 'LocalAI') + return t.charAt(0).toUpperCase() + t.slice(1) +} + +// The links still to check for a blocked row. The sender is known (it is the +// one chosen in the selector), so "sender" is never shown as unchecked. +export const remainingChecks = (thenCheck) => (thenCheck || []).filter((x) => x !== 'sender' && THEN_CHECK.includes(x)) + // The links after the first problem that the check did not look at, as the // words of the page. export const THEN_CHECK = ['server', 'tools', 'agent', 'connection', 'sender', 'approval'] @@ -233,7 +243,7 @@ export function readinessRows(ctx) { const on = (n) => caps.includes(n) const view = preview.status === 'ready' ? preview.view : null const pending = preview.status === 'loading' || preview.status === undefined ? 'checking' : preview.status === 'error' ? 'unknown' : '' - const titleOf = (n) => capabilities.find((c) => c.name === n)?.title || n + const titleOf = (n) => sentenceCase(capabilities.find((c) => c.name === n)?.title || n) const afterSave = (row, ...fields) => (row.state === 'ok' && fields.some((f) => changed.has(f)) ? { ...row, state: 'after_save', fix: { kind: 'save', label: 'fix.save' } } : row) const rows = [] diff --git a/addons/telegram/ui/test/access.test.mjs b/addons/telegram/ui/test/access.test.mjs index e2f58d71b7..b422b2c393 100644 --- a/addons/telegram/ui/test/access.test.mjs +++ b/addons/telegram/ui/test/access.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - changedFields, fixAction, isProblem, pickSubject, previewBody, previewId, readinessRows, reachable, senderOptions, unusable, visibleRows, + changedFields, fixAction, remainingChecks, sentenceCase, isProblem, pickSubject, previewBody, previewId, readinessRows, reachable, senderOptions, unusable, visibleRows, } from '../../../internal/connector-ui/src/access.js' import { connectorForm, emptyConnector } from '../../../internal/connector-ui/src/connector.js' @@ -190,3 +190,16 @@ test('access: changed fields compare the form with the saved binding', () => { assert.deepEqual([...changedFields({ ...saved, agent_id: 'a2', notify_chat: '1' }, saved)].sort(), ['agent_id', 'notify_chat']) assert.ok(changedFields(saved, null).has('caps')) }) + +test('access: the unchecked links never list the sender, which the selector already names', () => { + assert.deepEqual(remainingChecks(['connection', 'sender']), ['connection']) + assert.deepEqual(remainingChecks(['sender']), []) + assert.deepEqual(remainingChecks(['sender', 'approval']), ['approval']) + assert.deepEqual(remainingChecks(undefined), []) +}) + +test('access: a title is a sentence with the product name right', () => { + assert.equal(sentenceCase('change things outside localai'), 'Change things outside LocalAI') + assert.equal(sentenceCase('Post to approved channels'), 'Post to approved channels') + assert.equal(sentenceCase(''), '') +}) diff --git a/core/http/react-ui/e2e/connector-fixtures.js b/core/http/react-ui/e2e/connector-fixtures.js index 42396d10cf..7ea5393f7b 100644 --- a/core/http/react-ui/e2e/connector-fixtures.js +++ b/core/http/react-ui/e2e/connector-fixtures.js @@ -50,24 +50,32 @@ const AGENT_GRANTS = ['memory.write', 'files.upload', 'triggers.propose', 'trigg // A stand-in for the host's access preview: the same shape and the same order // of checks (agent permission, connection permission, sender trust), written // from the form values of the request. +// The words the host uses for each capability in its sentences. +const WORDS = { + 'proposals.confirm': 'confirming proposals', 'memory.write': 'saving to memory', 'files.upload': 'receiving files', + 'triggers.propose': 'reminders and scheduled tasks', 'triggers.run': 'running scheduled tasks', + 'talkback.channels': 'writing to other channels', 'talkback.new_channel': 'starting conversations in new channels', + 'tools.write': 'changes made by add-on tools', +} + export function previewFor(body, roles = []) { const agent = AGENTS.find(a => a.id === body.agent_id) const decide = (c, who) => { if (!body.agent_id) return { capability: c.name, allowed: false, code: 'no_agent', blocked_by: 'connection', message: 'This connection has no agent.', fix: { kind: 'open_connection', target: 'agent_id' }, then_check: ['sender'] } if (agent && AGENT_GRANTS.includes(c.name) && !agent.builtins.includes(c.name)) { - return { capability: c.name, allowed: false, code: 'agent_permission_off', blocked_by: 'agent', message: `The agent ${agent.name} does not have permission for ${c.title.toLowerCase()}.`, fix: { kind: 'open_agent', target: agent.id, field: c.name }, then_check: ['connection', 'sender'] } + return { capability: c.name, allowed: false, code: 'agent_permission_off', blocked_by: 'agent', message: `The agent has not been given permission for ${WORDS[c.name]}.`, fix: { kind: 'open_agent', target: agent.id, field: c.name }, then_check: ['connection', 'sender'] } } if (!(body.caps || []).includes(c.name)) { - return { capability: c.name, allowed: false, code: 'connection_permission_off', blocked_by: 'connection', message: `This connection does not allow ${c.title.toLowerCase()}.`, fix: { kind: 'open_connection', target: 'caps', field: c.name }, then_check: ['sender'] } + return { capability: c.name, allowed: false, code: 'connection_permission_off', blocked_by: 'connection', message: `This connection does not allow ${WORDS[c.name]}.`, fix: { kind: 'open_connection', target: 'caps', field: c.name }, then_check: ['sender'] } } - if (who === 'anyone') return { capability: c.name, allowed: false, code: 'channel_not_trusted', blocked_by: 'sender', message: 'A member of a channel is not trusted one by one. Add the person to the allowed people.', fix: { kind: 'add_person', target: 'allow' } } + if (who === 'anyone') return { capability: c.name, allowed: false, code: 'channel_not_trusted', blocked_by: 'sender', message: `This person is let in through a channel, or because the bot accepts anyone, so they are not trusted for ${WORDS[c.name]}. Add them by name or pair them.`, fix: { kind: 'add_person', target: 'allow' } } if (who.startsWith('role:')) { const id = who.slice(5) return (body.grants || []).some(g => g.role === id && g.caps.includes(c.name)) && !c.sensitive ? { capability: c.name, allowed: true, via: who } - : { capability: c.name, allowed: false, code: 'role_not_granted', blocked_by: 'sender', message: 'This role has not been given this permission.', fix: { kind: 'open_connection', target: 'grants' } } + : { capability: c.name, allowed: false, code: 'role_not_granted', blocked_by: 'sender', message: `The role of this person was not given ${WORDS[c.name]}.`, fix: { kind: 'open_connection', target: 'grants' } } } - if (c.sensitive && who !== 'paired') return { capability: c.name, allowed: false, code: 'sender_needs_pairing', blocked_by: 'sender', message: 'This needs a person you paired with the bot.', fix: { kind: 'pair_sender', target: 'pairing' } } + if (c.sensitive && who !== 'paired') return { capability: c.name, allowed: false, code: 'sender_needs_pairing', blocked_by: 'sender', message: `This person has to be paired with the bot by the owner before ${WORDS[c.name]}.`, fix: { kind: 'pair_sender', target: 'pairing' } } return { capability: c.name, allowed: true, via: who === 'paired' ? 'paired' : 'allowlist' } } const subjects = [ diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js index fd4a0b8639..cdcca89bb3 100644 --- a/core/http/react-ui/e2e/connector-permissions.spec.js +++ b/core/http/react-ui/e2e/connector-permissions.spec.js @@ -325,14 +325,17 @@ test.describe('Connector permissions', () => { // The agent lacks the permission: the first blocker is the agent, and the fix opens the agent in a new tab. const trig = can.getByTestId('connector-can-triggers.propose') await expect(trig).toHaveAttribute('data-allowed', 'false') - await expect(trig.getByTestId('connector-can-why')).toContainText('does not have permission') + await expect(trig.getByTestId('connector-can-why')).toContainText('has not been given permission') await expect(trig.getByTestId('connector-fix')).toHaveAttribute('href', '/app/agents/a3/edit') await expect(trig.getByTestId('connector-fix')).toHaveAttribute('target', '_blank') await expect(trig.getByTestId('connector-fix')).toHaveCount(1) // Not ticked: the fix scrolls to the switch and focuses it. const files = can.getByTestId('connector-can-files.upload') await expect(files.getByTestId('connector-can-why')).toContainText('does not allow') - await expect(files).toContainText('Not checked yet') + // The sender is known (the selector), so only the other links are listed; none remain here. + await expect(files).not.toContainText('Not checked yet') + await expect(trig).toContainText('Not checked yet: the permissions of this connection.') + await expect(trig).not.toContainText('who is sending') await files.getByRole('button', { name: 'Show the setting' }).click() await expect(page.getByTestId('connector-cap-switch-files.upload')).toBeFocused() await shot(page, 'can-1-listed') @@ -357,7 +360,7 @@ test.describe('Connector permissions', () => { await expect(page.getByTestId('connector-pair-start')).toBeFocused() // A member of the listed channel is not trusted one by one: add the person. await can.getByTestId('connector-can-sender').selectOption('channel_member') - await expect(can.getByTestId('connector-can-memory.write').getByTestId('connector-can-why')).toContainText('not trusted one by one') + await expect(can.getByTestId('connector-can-memory.write').getByTestId('connector-can-why')).toContainText('not trusted for saving to memory') await can.getByTestId('connector-can-memory.write').getByRole('button', { name: 'Add a person' }).click() await expect(page.getByTestId('connector-allow').getByRole('textbox')).toBeFocused() }) @@ -483,7 +486,7 @@ test.describe('Connector permissions', () => { // The agent lacks Propose triggers: the row shows the host sentence and a link to the agent. const trig = ready.getByTestId('connector-ready-triggers') await expect(trig).toHaveAttribute('data-state', 'attention') - await expect(trig).toContainText('does not have permission') + await expect(trig).toContainText('has not been given permission') await expect(trig.getByTestId('connector-fix')).toHaveAttribute('href', '/app/agents/a3/edit') // Choosing an agent that has it changes the row without a save; the row then works after a save. await page.getByLabel('Agent', { exact: true }).selectOption('a1')