diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index eebd4c31d9..4cb7038b7b 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -4,7 +4,7 @@ // log live in core; this page edits them through the core API. An add-on // gives its own words (an i18n namespace), the fields of its token, and its // allowlists; the rest is the same for every platform. -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog' import Icon from '../../host-compat/src/components/Icon' @@ -126,6 +126,20 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo }, [kind]) useEffect(() => { agentsApi.list().then((r) => setAgents(r.agents || [])).catch(() => {}) }, []) + // A link with ?binding= (from the Tools list of an agent) opens that + // binding and brings its permissions into view, once. + const linked = useRef(false) + useEffect(() => { + if (linked.current || items.length === 0) return + const id = new URLSearchParams(window.location.search).get('binding') + const c = id && items.find((x) => x.id === id) + if (!c) return + linked.current = true + open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) }) + setTimeout(() => document.querySelector('[data-testid="connector-permissions"]')?.scrollIntoView?.({ block: 'start' }), 50) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]) + const agentName = (id) => agents.find((a) => a.id === id)?.name || id const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote('') } const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) }) @@ -244,7 +258,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo - + a.id === form.agent_id)} /> 0 && !(form.caps || []).includes('triggers.propose')} /> {capabilities.length > 0 && } )} diff --git a/addons/internal/connector-ui/src/PairingFields.jsx b/addons/internal/connector-ui/src/PairingFields.jsx index 94eb42e5c4..b3cc1aef6e 100644 --- a/addons/internal/connector-ui/src/PairingFields.jsx +++ b/addons/internal/connector-ui/src/PairingFields.jsx @@ -60,7 +60,7 @@ export default function PairingFields({ binding, s, onPaired }) { setPaired(list) setCode(null) setDone(fresh.sender_id) - onPaired?.() + onPaired?.(list) stop() } } catch (_e) { /* the next tick tries again */ } @@ -88,7 +88,7 @@ export default function PairingFields({ binding, s, onPaired }) { const list = b?.paired || paired.filter((p) => p.sender_id !== sender) known.current = new Set(list.map((p) => p.sender_id)) setPaired(list) - onPaired?.() + onPaired?.(list) } catch (e) { setError(e?.message || '') } } const full = paired.length >= MAX_PAIRED diff --git a/addons/internal/connector-ui/src/PermissionFields.jsx b/addons/internal/connector-ui/src/PermissionFields.jsx index 574edb567b..139f96e117 100644 --- a/addons/internal/connector-ui/src/PermissionFields.jsx +++ b/addons/internal/connector-ui/src/PermissionFields.jsx @@ -5,9 +5,10 @@ // approved channels for talk-back, and the paired senders. A binding that was // saved before the host enforced permissions has none switched on, so it denies // everything gated; the notice and the preset below say so and fix it in a click. +import { useCallback, useState } from 'react' import { Field, Hint, Notice, Switch, TextInput } from './controls' import Icon from '../../host-compat/src/components/Icon' -import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, channelsProblem, grantableFor, hasNoCaps, roleHas, setCap, setGrant, usualCaps } from './connector' +import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unusableCaps, usualFor, withAddedCaps, withCopiedCaps } from './connector' import PairingFields from './PairingFields' export const PERMISSION_TEXT = { @@ -19,6 +20,25 @@ export const PERMISSION_TEXT = { 'perm.usualHelp': 'Switches on the everyday permissions that stay inside LocalAI: {{names}}. Sensitive ones are never included.', 'perm.usualDone': 'The usual permissions are on. Save to keep them.', 'perm.usualSaved': 'The usual permissions are saved for {{name}}.', + 'perm.usualSet': 'Allow the usual set', + 'perm.usualSetHelp': 'Ticks the everyday permissions that stay inside LocalAI, for what the agent has. Sensitive ones are never included.', + 'perm.sameAs': 'Same as the agent', + 'perm.sameAsHelp': 'Copies the permissions the agent has now, sensitive ones too. It is a copy of today and does not follow later changes to the agent.', + 'perm.block.noAgent': 'Choose an agent first.', + 'perm.block.unreadable': 'The permissions of this agent could not be read.', + 'perm.block.nothing': 'This agent has no permission that a connection can use.', + 'perm.usualTicked': 'Ticked {{count}}: {{names}}. Save to keep them.', + 'perm.usualNone': 'Nothing new to tick: the usual permissions the agent has are already on.', + 'perm.usualSkipped': 'Not ticked, because the agent does not have them: {{names}}.', + 'perm.sameTicked': 'Copied {{count}} permissions of {{agent}}: {{names}}. This is a copy of today and does not follow later changes to the agent. Save to keep them.', + 'perm.agentLacks': 'The agent does not have this.', + 'perm.confirmTitle': 'Copy the sensitive permissions too?', + 'perm.confirmText': 'The agent has these sensitive permissions. Once they are on, a sender you paired could use them through this bot:', + 'perm.confirmYes': 'Copy them too', + 'perm.confirmNo': 'Cancel', + 'perm.stuckTitle': 'You ticked these, but no sender can use them yet', + 'perm.stuckText': 'A sender can use a permission only when you paired them with this bot, or when a role that you gave the permission to applies. Nobody is paired yet and no role has these: {{names}}.', + 'perm.stuckPair': 'Pair your account', 'perm.sensitive': 'Paired senders only', 'perm.sensitiveHelp': 'Sensitive. Never given to a role. Only a sender you paired can use it, and every use is recorded.', 'perm.capsLegend': 'Permissions of this bot', @@ -52,14 +72,18 @@ export function permissionT(t) { return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts }) } -function Cap({ cap, on, onChange, s }) { +function Cap({ cap, on, onChange, s, lacks }) { + // A permission the agent does not have cannot be switched on; one that is + // already on can still be switched off. + const blocked = lacks && !on return ( -
  • - +
  • + {cap.title} {cap.sensitive && {s('perm.sensitive')}} {cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''} + {lacks &&

    {s('perm.agentLacks')}

    }
  • ) } @@ -152,46 +176,128 @@ function Channels({ form, setForm, s }) { * It stands alone so the list of bindings can show it too. */ export function NoCapsNotice({ capabilities, onUsual, busy, s, name }) { + // Inside the form the presets below do the same job, limited to the agent. const usual = (capabilities || []).filter((c) => c.usual && !c.sensitive) return (

    {s('perm.noCapsTitle')}

    {s('perm.noCapsText')}

    {usual.length > 0 &&

    {s('perm.usualHelp', { names: usual.map((c) => c.title).join(', ') })}

    } - + {onUsual && ( + + )}
    ) } + +/** + * Two buttons that fill the permissions of the form from the agent of the + * binding: the usual set (what the agent has), and a copy of everything the + * agent has. They only change the form; the owner saves as for any change. + */ +function Presets({ form, setForm, capabilities, agent, s }) { + const [done, setDone] = useState(null) + const [pending, setPending] = useState(null) + const held = agentHeld(agent) + const block = presetBlock(form.agent_id, held, capabilities) + const title = (name) => capabilities.find((c) => c.name === name)?.title || name + const names = (list) => list.map(title).join(', ') + const reasonId = 'cn-preset-reason' + + const usual = () => { + const { caps, skipped } = usualFor(capabilities, held) + const fresh = caps.filter((n) => !(form.caps || []).includes(n)) + setPending(null) + setForm(withAddedCaps(form, caps)) + setDone({ kind: 'usual', text: [fresh.length ? s('perm.usualTicked', { count: fresh.length, names: names(fresh) }) : s('perm.usualNone'), skipped.length ? s('perm.usualSkipped', { names: names(skipped) }) : ''].filter(Boolean).join(' ') }) + } + const copy = (caps) => { + setPending(null) + setForm(withCopiedCaps(form, caps)) + setDone({ kind: 'same', text: s('perm.sameTicked', { count: caps.length, agent: agent?.name || '', names: names(caps) }) }) + } + const same = () => { + const { caps, sensitive } = copyFromAgent(capabilities, held) + if (sensitive.length > 0) { setDone(null); setPending({ caps, sensitive }); return } + copy(caps) + } + + return ( +
    +
    + + +
    + {block ?

    {s(`perm.block.${block}`)}

    + :

    {s('perm.usualSetHelp')} {s('perm.sameAsHelp')}

    } + {pending && ( + +

    {s('perm.confirmTitle')}

    +

    {s('perm.confirmText')}

    +
      + {pending.sensitive.map((n) =>
    • {title(n)}: {capabilities.find((c) => c.name === n)?.description}
    • )} +
    +
    + + +
    +
    + )} + {done &&

    {done.text}

    } +
    + ) +} + /** * @param {object} props * @param {object} props.form the form of the binding + * @param {object} props.agent the saved agent of the binding, when it is known * @param {Function} props.setForm * @param {object} props.kind the kind from the kinds call (roles, permissions) * @param {Array} props.capabilities the closed list from the kinds call * @param {Function} props.s the translation function with the defaults * @param {Function} props.onPaired called when pairing changed the binding */ -export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired }) { +export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired, agent }) { const caps = capabilities || [] if (caps.length === 0) return null const on = (name) => (form.caps || []).includes(name) - const usual = usualCaps(caps) + const held = agentHeld(agent) + // What the pairing list is now: the saved one, and what pairing here changes. + const [paired, setPaired] = useState(form.paired || []) + const pairedHere = useCallback((list) => { if (Array.isArray(list)) setPaired(list); onPaired?.() }, [onPaired]) + const stuck = unusableCaps(form, paired) + const title = (name) => caps.find((c) => c.name === name)?.title || name + const toPairing = () => { + const el = document.querySelector('[data-testid="connector-pair-start"]') || document.querySelector('[data-testid="connector-pairing"]') + el?.scrollIntoView?.({ block: 'center' }) + el?.focus?.() + } return (
    {s('perm.title')}

    {s('perm.help')}

    {hasNoCaps(form) && ( - setForm({ ...form, caps: [...new Set([...(form.caps || []), ...usual])] })} /> + )} + {stuck.length > 0 && ( + +

    {s('perm.stuckTitle')}

    +

    {s('perm.stuckText', { names: stuck.map(title).join(', ') })}

    + +
    + )} +
      - {caps.map((c) => setForm(setCap(form, c.name, v))} />)} + {caps.map((c) => setForm(setCap(form, c.name, v))} />)}
    - +
    ) } diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css index 2a91cac242..850b6b13ea 100644 --- a/addons/internal/connector-ui/src/connector.css +++ b/addons/internal/connector-ui/src/connector.css @@ -191,3 +191,11 @@ fieldset.cn-group { .cn-access__no summary { cursor: pointer; color: var(--color-text-muted); } .cn-access__no ul { margin: var(--dk-space-1) 0 0; padding-left: var(--dk-space-5); } .cn-row:has(.cn-nocaps) { align-items: flex-start; } + +/* The presets of the permissions: fill the form from the agent of the binding. */ +.cn-presets { display: flex; flex-direction: column; gap: var(--dk-space-2); margin-block: var(--dk-space-2); } +.cn-presets__row { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); } +.cn-presets__done { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-primary); } +.cn-confirm__list { margin: 0; padding-left: var(--dk-space-5); font-size: var(--fs-small); line-height: var(--lh-small); } +.cn-cap__lacks { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-muted); } +.cn-cap[data-lacks="true"] .cn-cap__title { color: var(--color-text-muted); } diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index e13322f9ff..50a03c3934 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -70,6 +70,82 @@ export function usualCaps(capabilities) { return (capabilities || []).filter((c) => c.usual && !c.sensitive).map((c) => c.name) } +// The binding capabilities that are also grants of a saved agent. A sender can +// only use one of them when the agent holds the grant too, so the presets only +// tick what the agent holds. proposals.confirm and tools.write are not grants: +// they belong to the binding alone. +export const AGENT_GRANT_CAPS = [ + 'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write', + 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel', +] + +// What an agent without a grant list holds for its messaging connections (the +// server rule is core/services/studio/connectorgrants.go). As soon as the list +// names any grant, only the named ones count. +export const DEFAULT_CONNECTOR_GRANTS = ['triggers.propose', 'triggers.run', 'talkback.channels', 'files.upload'] + +// The grants the agent holds for a connection, or null when its permissions +// cannot be read (no agent, an agent that is not in the list, a host that does +// not send them). +export function agentHeld(agent) { + if (!agent || !Array.isArray(agent.builtins)) return null + return agent.builtins.length > 0 ? agent.builtins : DEFAULT_CONNECTOR_GRANTS +} + +// Whether the agent lets a capability be switched on: a capability that is not +// a grant of the agent is not gated. held is agentHeld(agent); null gates nothing. +export const agentHas = (held, name) => held === null || !AGENT_GRANT_CAPS.includes(name) || held.includes(name) + +// Why a preset cannot be used: 'noAgent', 'unreadable', 'nothing' or ''. +export function presetBlock(agentId, held, capabilities) { + if (!agentId) return 'noAgent' + if (held === null) return 'unreadable' + if (!(capabilities || []).some((c) => AGENT_GRANT_CAPS.includes(c.name) && held.includes(c.name))) return 'nothing' + return '' +} + +// "Allow the usual set": the caps the host marks usual (never a sensitive one) +// that the agent holds. skipped are the usual ones the agent does not hold. +export function usualFor(capabilities, held) { + const usual = usualCaps(capabilities) + return { caps: usual.filter((n) => agentHas(held, n)), skipped: usual.filter((n) => !agentHas(held, n)) } +} + +// "Same as the agent": every cap the agent holds that is a capability of the +// host's list, sensitive ones too. sensitive names the ones that are. +export function copyFromAgent(capabilities, held) { + const list = (capabilities || []).filter((c) => AGENT_GRANT_CAPS.includes(c.name) && (held || []).includes(c.name)) + return { caps: list.map((c) => c.name), sensitive: list.filter((c) => c.sensitive).map((c) => c.name) } +} + +// The form after the caps of the agent were copied in. The caps that are not +// grants of an agent stay as they were; the others become exactly the copy, and +// a role never keeps a cap the ceiling lost. +export function withCopiedCaps(form, caps) { + const keep = (form.caps || []).filter((n) => !AGENT_GRANT_CAPS.includes(n)) + const next = [...new Set([...keep, ...caps])] + const grants = (form.grants || []).map((g) => ({ ...g, caps: g.caps.filter((x) => next.includes(x)) })).filter((g) => g.caps.length > 0) + return { ...form, caps: next, grants } +} + +// The form with these caps added to the ones it has. +export function withAddedCaps(form, caps) { + return { ...form, caps: [...new Set([...(form.caps || []), ...caps])] } +} + +// The ticked caps that no sender can use yet. The host lets a sender use a cap +// only when the sender is paired, or has a role the owner granted the cap to +// (a sensitive cap needs a pairing always; see Authorize in +// core/services/studio/connectoraccess.go). So with nobody paired, a ticked +// cap that no role holds is dead, however it is ticked. +export function unusableCaps(form, paired, now = Date.now()) { + const caps = form?.caps || [] + const active = (paired || []).some((p) => !p.expires_at || new Date(p.expires_at).getTime() > now) + if (caps.length === 0 || active) return [] + const granted = new Set((form.grants || []).flatMap((g) => g.caps || [])) + return caps.filter((c) => !granted.has(c)) +} + // The form after cap name was turned on or off. Turning one off also takes it // from every role grant, so a role never holds more than the ceiling. export function setCap(form, name, on) { diff --git a/addons/internal/connector-ui/src/controls.jsx b/addons/internal/connector-ui/src/controls.jsx index 03c798e298..3112cace04 100644 --- a/addons/internal/connector-ui/src/controls.jsx +++ b/addons/internal/connector-ui/src/controls.jsx @@ -72,11 +72,11 @@ export function Select({ children, className = '', ...rest }) { // A switch for an on/off setting. `label` names it for assistive technology; // when `children` is set, it is drawn as the visible label next to the switch. -export function Switch({ checked, onChange, label, children, testId, disabled }) { +export function Switch({ checked, onChange, label, children, testId, disabled, describedBy }) { const id = useId() const button = (