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')