mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(connectors): let the owner choose how chat triggers are approved
A connector binding gets an approval setting: ask, auto or auto_readonly, with four limits for the two automatic modes (live triggers, minutes between runs of a recurring trigger, runs a day, lifetime in days). The setting is generic, so every connector add-on can show it, and the shared connector page of the add-ons now draws it. A binding that sets nothing asks, as before. This change stores, validates and exposes the setting. The next change acts on it. Assisted-by: Claude:claude-sonnet-5-5 go swag node
This commit is contained in:
17 files changed
+477
-6
No files matched your search
@@ -4,6 +4,7 @@
|
||||
// files of a turn go out. The words are the same on every platform, so they
|
||||
// live here as defaults; an add-on can still change one in its namespace.
|
||||
import { Field, Hint, Select, Switch, TextInput } from './controls'
|
||||
import { APPROVAL_TEXT } from './ApprovalFields'
|
||||
import { FILES_TEXT } from './FilesFields'
|
||||
import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector'
|
||||
|
||||
@@ -44,7 +45,7 @@ export const ANSWER_TEXT = {
|
||||
|
||||
// text reads a string of the add-on's namespace, with the default above.
|
||||
export function answerT(t) {
|
||||
return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key], ...opts })
|
||||
return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key] ?? APPROVAL_TEXT[key], ...opts })
|
||||
}
|
||||
|
||||
// answerSummary says in a few words how a binding answers, for its row.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
// How the triggers that the agent proposes in a chat are approved. The same on
|
||||
// every platform, so the words live here as defaults, like the other shared
|
||||
// fields. The server enforces the limits; the page only bounds what is typed.
|
||||
import { Field, RadioCards, TextInput } from './controls'
|
||||
import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector'
|
||||
|
||||
export const APPROVAL_TEXT = {
|
||||
'approval.title': 'Timers and triggers from the chat',
|
||||
'approval.help': 'When the agent sets up something that runs later ("in 10 minutes", "every day"), every run is one model run.',
|
||||
'approval.mode.ask': 'Ask me: the bot proposes it in the chat and the person answers yes or no',
|
||||
'approval.mode.auto': 'Set it up at once and tell the person how to cancel it',
|
||||
'approval.mode.auto_readonly': 'At once when the runs only read and report; otherwise ask',
|
||||
'approval.limitsHelp': 'These limits bound what is set up without asking. Leave a field empty for its default. Anything over a limit is asked instead.',
|
||||
'approval.max_pending': 'Most timers and triggers at once',
|
||||
'approval.min_gap_minutes': 'Fewest minutes between runs of a recurring trigger',
|
||||
'approval.max_runs_per_day': 'Most runs a day, all together',
|
||||
'approval.max_lifetime_days': 'Longest lifetime, in days',
|
||||
'approval.invalid': 'Use a whole number from 1 to {{max}}, or leave it empty.',
|
||||
'approval.default': 'Default: {{value}}.',
|
||||
'approval.summary': 'Triggers: {{mode}}',
|
||||
'approval.short.ask': 'asks first',
|
||||
'approval.short.auto': 'set up at once',
|
||||
'approval.short.auto_readonly': 'at once if read-only',
|
||||
}
|
||||
|
||||
export default function ApprovalFields({ form, setForm, s }) {
|
||||
const a = form.approval
|
||||
const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } })
|
||||
const auto = a.mode !== 'ask'
|
||||
const bad = approvalProblem(a)
|
||||
return (
|
||||
<fieldset className="cn-group" data-testid="connector-approval">
|
||||
<legend>{s('approval.title')}</legend>
|
||||
<p className="dk-hint">{s('approval.help')}</p>
|
||||
<RadioCards name="approval" legend={s('approval.title')} value={a.mode} onChange={(mode) => set({ mode })}
|
||||
options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} />
|
||||
{auto && (<>
|
||||
<p className="dk-hint">{s('approval.limitsHelp')}</p>
|
||||
{APPROVAL_LIMITS.map(({ key, max, fallback }) => (
|
||||
<Field key={key} label={s(`approval.${key}`)} hint={s('approval.default', { value: fallback })} error={bad === key ? s('approval.invalid', { max }) : undefined}>
|
||||
{(p) => <TextInput {...p} type="number" inputMode="numeric" min="1" max={max} step="1" data-testid={`connector-approval-${key}`}
|
||||
value={a[key]} onChange={(e) => set({ [key]: e.target.value })} />}
|
||||
</Field>
|
||||
))}
|
||||
</>)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog'
|
||||
import Icon from '../../host-compat/src/components/Icon'
|
||||
import AnswerFields, { answerSummary, answerT } from './AnswerFields'
|
||||
import ApprovalFields from './ApprovalFields'
|
||||
import FilesFields from './FilesFields'
|
||||
import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls'
|
||||
import NameList from './NameList'
|
||||
@@ -215,6 +216,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
</Field>
|
||||
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
|
||||
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
|
||||
<ApprovalFields form={form} setForm={setForm} s={answerT(t)} />
|
||||
</>)}
|
||||
<div className="cn-actions">
|
||||
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
|
||||
|
||||
@@ -6,12 +6,56 @@ export const REPLY_MODES = ['off', 'progress', 'stream']
|
||||
export const CONVERSATIONS = ['sender', 'chat', 'window', 'thread']
|
||||
export const MAX_WINDOW_MINUTES = 7 * 24 * 60
|
||||
|
||||
// How the triggers that the agent proposes in a chat are approved, and the
|
||||
// limits of the two modes that make one without asking. The server has the
|
||||
// same names, defaults and bounds (connectors.Approval).
|
||||
export const APPROVAL_MODES = ['ask', 'auto', 'auto_readonly']
|
||||
export const APPROVAL_LIMITS = [
|
||||
{ key: 'max_pending', max: 50, fallback: 5 },
|
||||
{ key: 'min_gap_minutes', max: 1440, fallback: 60 },
|
||||
{ key: 'max_runs_per_day', max: 200, fallback: 10 },
|
||||
{ key: 'max_lifetime_days', max: 365, fallback: 30 },
|
||||
]
|
||||
|
||||
export function emptyApproval() {
|
||||
return { mode: 'ask', max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }
|
||||
}
|
||||
|
||||
// The approval of a saved binding as a form: a limit of zero is the default
|
||||
// and shows empty.
|
||||
export function approvalForm(a) {
|
||||
const f = { ...emptyApproval(), mode: a?.mode || 'ask' }
|
||||
for (const { key } of APPROVAL_LIMITS) f[key] = a?.[key] > 0 ? String(a[key]) : ''
|
||||
return f
|
||||
}
|
||||
|
||||
// Why a typed limit cannot be saved: not a whole number from 1 to its bound.
|
||||
// Empty is fine (the default).
|
||||
export function approvalProblem(a) {
|
||||
if (!a) return ''
|
||||
if (!APPROVAL_MODES.includes(a.mode)) return 'mode'
|
||||
for (const { key, max } of APPROVAL_LIMITS) {
|
||||
const v = String(a[key] ?? '').trim()
|
||||
if (v === '') continue
|
||||
const n = Number(v)
|
||||
if (!Number.isInteger(n) || n < 1 || n > max) return key
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function approvalPayload(a) {
|
||||
const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask' }
|
||||
for (const { key } of APPROVAL_LIMITS) body[key] = Number(String(a?.[key] ?? '').trim()) || 0
|
||||
return body
|
||||
}
|
||||
|
||||
export function emptyConnector() {
|
||||
return {
|
||||
id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '',
|
||||
reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true,
|
||||
options: {},
|
||||
file_receive_mib: '', file_send_mib: '',
|
||||
approval: emptyApproval(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +77,7 @@ export function connectorForm(c) {
|
||||
return {
|
||||
...emptyConnector(), ...c, token: '',
|
||||
file_receive_mib: bytesToMib(c.file_receive_max_bytes), file_send_mib: bytesToMib(c.file_send_max_bytes),
|
||||
approval: approvalForm(c.approval),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +131,7 @@ export function connectorPayload(form, kind, token = form.token, extra = {}) {
|
||||
options: Object.fromEntries(Object.entries(form.options || {}).filter(([, v]) => v)),
|
||||
file_receive_max_bytes: mibToBytes(form.file_receive_mib),
|
||||
file_send_max_bytes: mibToBytes(form.file_send_mib),
|
||||
approval: approvalPayload(form.approval),
|
||||
}
|
||||
if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0
|
||||
const secret = (token || '').trim()
|
||||
@@ -113,6 +159,7 @@ export function canSave(form, confirmed, hasToken = Boolean((form.token || '').t
|
||||
if (!(form.name || '').trim() || !form.agent_id) return false
|
||||
if (!form.id && !hasToken) return false
|
||||
if (form.accept_any && !confirmed) return false
|
||||
if (approvalProblem(form.approval)) return false
|
||||
if (fileLimitProblem(form.file_receive_mib, files?.receive?.allowed_bytes) || fileLimitProblem(form.file_send_mib, files?.send?.allowed_bytes)) return false
|
||||
if (form.conversation === 'window') {
|
||||
const m = Number(form.window_minutes)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
|
||||
import { approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
|
||||
|
||||
test('chat connectors: the token goes only when it was typed', () => {
|
||||
const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram')
|
||||
@@ -132,3 +132,27 @@ test('chat connectors: sizes are written in KiB and MiB', () => {
|
||||
test('chat connectors: a saved limit reads back to the same number of bytes', () => {
|
||||
for (const bytes of [1, 1000, 100 * 1024, 256 * 1024, 5 * 1024 * 1024 + 7]) assert.equal(mibToBytes(bytesToMib(bytes)), bytes)
|
||||
})
|
||||
|
||||
test('chat connectors: the approval of chat triggers asks by default and sends its limits as numbers', () => {
|
||||
const base = { ...emptyConnector(), name: 'B', agent_id: 'a' }
|
||||
assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
|
||||
const auto = connectorPayload({ ...base, approval: { mode: 'auto', max_pending: '3', min_gap_minutes: '', max_runs_per_day: ' 8 ', max_lifetime_days: '14' } }, 'telegram')
|
||||
assert.deepEqual(auto.approval, { mode: 'auto', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 })
|
||||
assert.equal(connectorPayload({ ...base, approval: { mode: 'sometimes' } }, 'telegram').approval.mode, 'ask', 'an unknown mode falls back to ask')
|
||||
})
|
||||
|
||||
test('chat connectors: a saved approval reads back, with a zero limit shown as empty', () => {
|
||||
assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' })
|
||||
assert.equal(connectorForm({ id: 'b' }).approval.mode, 'ask')
|
||||
})
|
||||
|
||||
test('chat connectors: an approval limit is a whole number within its bound, or empty', () => {
|
||||
assert.equal(approvalProblem({ mode: 'auto', max_pending: '5' }), '')
|
||||
assert.equal(approvalProblem({ mode: 'auto', max_pending: '51' }), 'max_pending')
|
||||
assert.equal(approvalProblem({ mode: 'auto', max_runs_per_day: '2.5' }), 'max_runs_per_day')
|
||||
assert.equal(approvalProblem({ mode: 'auto', max_lifetime_days: '0' }), 'max_lifetime_days')
|
||||
assert.equal(approvalProblem({ mode: 'bad' }), 'mode')
|
||||
const ok = { ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }
|
||||
assert.equal(canSave({ ...ok, approval: { mode: 'auto', max_pending: '500' } }, false), false)
|
||||
assert.equal(canSave(ok, false), true)
|
||||
})
|
||||
@@ -25,7 +25,20 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
|
||||
return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny,
|
||||
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
|
||||
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), Options: i.Options,
|
||||
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes()}
|
||||
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)}
|
||||
}
|
||||
|
||||
func approvalOut(a connectors.Approval) schema.ChatsConnectorApproval {
|
||||
return schema.ChatsConnectorApproval{Mode: a.ModeOrDefault(), MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
|
||||
MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
|
||||
}
|
||||
|
||||
func approvalIn(a *schema.ChatsConnectorApproval) *connectors.Approval {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &connectors.Approval{Mode: a.Mode, MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
|
||||
MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
|
||||
}
|
||||
|
||||
func capsOut(in []string) []string {
|
||||
@@ -116,7 +129,7 @@ func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput {
|
||||
return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled,
|
||||
AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat,
|
||||
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, Options: req.Options,
|
||||
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode}
|
||||
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
|
||||
}
|
||||
|
||||
var connectorRules = []errRule{
|
||||
|
||||
@@ -263,6 +263,25 @@ var _ = Describe("Chats connectors endpoints", func() {
|
||||
Expect(rec.Code).To(Equal(http.StatusOK), "an update that keeps the token is not checked: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
It("keeps the approval of chat triggers: ask by default, kept on an update that omits it, refused when out of bounds", func() {
|
||||
ag := agent("alice", "Approval", "test-model")
|
||||
rec := create("alice", map[string]any{"name": "Approval bot", "token": specToken, "agent_id": ag})
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
|
||||
b := decodeJSON(rec)
|
||||
id := b["id"].(string)
|
||||
Expect(b).To(HaveKeyWithValue("approval", HaveKeyWithValue("mode", "ask")))
|
||||
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag,
|
||||
"approval": map[string]any{"mode": "auto", "max_pending": 3, "max_runs_per_day": 6}})
|
||||
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
|
||||
Expect(decodeJSON(rec)["approval"]).To(And(HaveKeyWithValue("mode", "auto"), HaveKeyWithValue("max_pending", BeNumerically("==", 3)), HaveKeyWithValue("max_runs_per_day", BeNumerically("==", 6))))
|
||||
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag})
|
||||
Expect(decodeJSON(rec)["approval"]).To(HaveKeyWithValue("mode", "auto"))
|
||||
for _, bad := range []map[string]any{{"mode": "always"}, {"mode": "auto", "max_pending": 51}, {"mode": "auto", "max_lifetime_days": -1}} {
|
||||
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag, "approval": bad})
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
It("keeps the capabilities of a binding, refuses unknown ones and issues a pairing code to its owner only", func() {
|
||||
ag := agent("alice", "Caps", "test-model")
|
||||
rec := create("alice", map[string]any{"name": "Caps bot", "token": specToken, "agent_id": ag, "caps": []string{"memory.write", "profile.write"}})
|
||||
|
||||
@@ -1256,6 +1256,25 @@ type ChatsConnector struct {
|
||||
// Paired are the senders you proved with a pairing code. A pairing ends
|
||||
// at expires_at.
|
||||
Paired []ChatsConnectorPaired `json:"paired"`
|
||||
// Approval is what the host does with a trigger that the agent proposes
|
||||
// from a chat of this binding.
|
||||
Approval ChatsConnectorApproval `json:"approval"`
|
||||
}
|
||||
|
||||
// ChatsConnectorApproval is the owner's choice for the triggers that the agent
|
||||
// proposes from a chat of a binding. mode is ask (the person answers the
|
||||
// proposal in the chat in plain words; the default), auto (the trigger is
|
||||
// made at once and the person is told how to cancel it) or auto_readonly (a
|
||||
// trigger whose runs only read and report is made at once; any other
|
||||
// proposal asks). The limits bound the modes that make a trigger without
|
||||
// asking; a zero limit means its default (5 live triggers, one hour between
|
||||
// runs of a recurring trigger, 10 runs a day, 30 days).
|
||||
type ChatsConnectorApproval struct {
|
||||
Mode string `json:"mode"`
|
||||
MaxPending int `json:"max_pending"`
|
||||
MinGapMinutes int `json:"min_gap_minutes"`
|
||||
MaxRunsPerDay int `json:"max_runs_per_day"`
|
||||
MaxLifetimeDays int `json:"max_lifetime_days"`
|
||||
}
|
||||
|
||||
// ChatsConnectorRoleGrant gives the capabilities caps to the senders that the
|
||||
@@ -1464,6 +1483,10 @@ type ChatsConnectorWrite struct {
|
||||
// WriteMode is off, propose or auto (see the connector record). Absent
|
||||
// means off for a new binding and unchanged for an update.
|
||||
WriteMode *string `json:"write_mode,omitempty" enums:"off,propose,auto"`
|
||||
// Approval sets how triggers that the agent proposes from a chat of the
|
||||
// binding are approved, and the limits of the automatic modes. Absent
|
||||
// means ask for a new binding and unchanged for an update.
|
||||
Approval *ChatsConnectorApproval `json:"approval,omitempty"`
|
||||
}
|
||||
|
||||
// ChatsConnectorChannel is a chat of the platform where the agent may write.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The approval modes of a binding: what the host does with a trigger that the
|
||||
// agent proposes from a chat of the binding. The names are the same for every
|
||||
// connector add-on.
|
||||
const (
|
||||
// ApprovalAsk posts the proposal in the chat and waits for a plain answer
|
||||
// from the person. It is the default.
|
||||
ApprovalAsk = "ask"
|
||||
// ApprovalAuto creates the trigger at once and tells the person how to
|
||||
// cancel it, within the limits of the owner.
|
||||
ApprovalAuto = "auto"
|
||||
// ApprovalAutoReadonly creates at once a trigger whose runs only read and
|
||||
// report; any other proposal still asks.
|
||||
ApprovalAutoReadonly = "auto_readonly"
|
||||
)
|
||||
|
||||
// Defaults and bounds of the limits that apply when a mode creates a trigger
|
||||
// without asking. A zero field of Approval means the default.
|
||||
const (
|
||||
DefaultMaxPending = 5
|
||||
MaxMaxPending = 50
|
||||
DefaultMinGapMinutes = 60
|
||||
MaxMinGapMinutes = 24 * 60
|
||||
DefaultMaxRunsPerDay = 10
|
||||
MaxMaxRunsPerDay = 200
|
||||
DefaultMaxLifetimeDays = 30
|
||||
MaxMaxLifetimeDays = 365
|
||||
)
|
||||
|
||||
// Approval is the choice of the owner for the triggers that an agent proposes
|
||||
// from a chat of one binding, and the limits that bound the modes which create
|
||||
// a trigger without asking. The zero value is ApprovalAsk with the default
|
||||
// limits.
|
||||
type Approval struct {
|
||||
// Mode is one of the Approval constants; empty means ApprovalAsk.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// MaxPending is how many triggers made without asking may be live at once
|
||||
// for the binding, timers and recurring ones together.
|
||||
MaxPending int `json:"max_pending,omitempty"`
|
||||
// MinGapMinutes is the shortest time between two runs of a recurring
|
||||
// trigger made without asking.
|
||||
MinGapMinutes int `json:"min_gap_minutes,omitempty"`
|
||||
// MaxRunsPerDay is how many runs a day all the live triggers made without
|
||||
// asking may have together.
|
||||
MaxRunsPerDay int `json:"max_runs_per_day,omitempty"`
|
||||
// MaxLifetimeDays is how far ahead a trigger made without asking may run,
|
||||
// and how long a recurring one lives before it is removed.
|
||||
MaxLifetimeDays int `json:"max_lifetime_days,omitempty"`
|
||||
}
|
||||
|
||||
// ModeOrDefault gives Mode, or ApprovalAsk when it is empty.
|
||||
func (a Approval) ModeOrDefault() string {
|
||||
if a.Mode == "" {
|
||||
return ApprovalAsk
|
||||
}
|
||||
return a.Mode
|
||||
}
|
||||
|
||||
// Auto says whether the mode may create a trigger without asking.
|
||||
func (a Approval) Auto() bool {
|
||||
m := a.ModeOrDefault()
|
||||
return m == ApprovalAuto || m == ApprovalAutoReadonly
|
||||
}
|
||||
|
||||
// Limits are the limits of an Approval with the defaults filled in.
|
||||
type Limits struct {
|
||||
MaxPending, MinGapMinutes, MaxRunsPerDay, MaxLifetimeDays int
|
||||
}
|
||||
|
||||
// Limits gives the limits with the default for each field that is zero.
|
||||
func (a Approval) Limits() Limits {
|
||||
pick := func(v, def int) int {
|
||||
if v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
return Limits{
|
||||
MaxPending: pick(a.MaxPending, DefaultMaxPending),
|
||||
MinGapMinutes: pick(a.MinGapMinutes, DefaultMinGapMinutes),
|
||||
MaxRunsPerDay: pick(a.MaxRunsPerDay, DefaultMaxRunsPerDay),
|
||||
MaxLifetimeDays: pick(a.MaxLifetimeDays, DefaultMaxLifetimeDays),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the mode and the bounds of the limits.
|
||||
func (a Approval) Validate() error {
|
||||
switch a.Mode {
|
||||
case "", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly:
|
||||
default:
|
||||
return fmt.Errorf("the approval mode must be %s, %s or %s", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly)
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
v, max int
|
||||
}{{"max pending", a.MaxPending, MaxMaxPending}, {"minimum gap in minutes", a.MinGapMinutes, MaxMinGapMinutes},
|
||||
{"runs per day", a.MaxRunsPerDay, MaxMaxRunsPerDay}, {"lifetime in days", a.MaxLifetimeDays, MaxMaxLifetimeDays}} {
|
||||
if f.v < 0 || f.v > f.max {
|
||||
return fmt.Errorf("the approval limit %s must be 0 to %d", f.name, f.max)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package connectors_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("the approval of a binding", func() {
|
||||
It("asks by default, with the default limits", func() {
|
||||
var a connectors.Approval
|
||||
Expect(a.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
|
||||
Expect(a.Auto()).To(BeFalse())
|
||||
Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 5, MinGapMinutes: 60, MaxRunsPerDay: 10, MaxLifetimeDays: 30}))
|
||||
Expect(a.Validate()).To(Succeed())
|
||||
})
|
||||
|
||||
It("knows which modes create without asking", func() {
|
||||
Expect(connectors.Approval{Mode: connectors.ApprovalAuto}.Auto()).To(BeTrue())
|
||||
Expect(connectors.Approval{Mode: connectors.ApprovalAutoReadonly}.Auto()).To(BeTrue())
|
||||
Expect(connectors.Approval{Mode: connectors.ApprovalAsk}.Auto()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("keeps the limits that the owner set", func() {
|
||||
a := connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}
|
||||
Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}))
|
||||
})
|
||||
|
||||
It("refuses an unknown mode and a limit out of bounds", func() {
|
||||
Expect(connectors.Approval{Mode: "always"}.Validate()).NotTo(Succeed())
|
||||
Expect(connectors.Approval{MaxPending: -1}.Validate()).NotTo(Succeed())
|
||||
Expect(connectors.Approval{MaxPending: connectors.MaxMaxPending + 1}.Validate()).NotTo(Succeed())
|
||||
Expect(connectors.Approval{MaxLifetimeDays: connectors.MaxMaxLifetimeDays + 1}.Validate()).NotTo(Succeed())
|
||||
Expect(connectors.Approval{MaxRunsPerDay: connectors.MaxMaxRunsPerDay + 1}.Validate()).NotTo(Succeed())
|
||||
Expect(connectors.Approval{MinGapMinutes: connectors.MaxMinGapMinutes + 1}.Validate()).NotTo(Succeed())
|
||||
})
|
||||
})
|
||||
@@ -160,6 +160,19 @@ func Run(newStore NewStore) {
|
||||
Expect(bare.Paired).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps the approval mode and limits", func() {
|
||||
s := newStore()
|
||||
b := binding("b1", "u1", "Bot")
|
||||
b.Approval = connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 3, MaxRunsPerDay: 7}
|
||||
Expect(s.PutBinding(ctx, b)).To(Succeed())
|
||||
got, err := s.GetBinding(ctx, "b1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.Approval).To(Equal(b.Approval))
|
||||
Expect(s.PutBinding(ctx, binding("b2", "u1", "Bare"))).To(Succeed())
|
||||
bare, _ := s.GetBinding(ctx, "b2")
|
||||
Expect(bare.Approval.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
|
||||
})
|
||||
|
||||
It("uses a pairing code once, before it expires, and only with the right hash", func() {
|
||||
s := newStore()
|
||||
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h1", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
|
||||
|
||||
@@ -39,6 +39,8 @@ type bindingRow struct {
|
||||
Channels string
|
||||
// WriteMode is added by AutoMigrate; a row stored before it reads as off.
|
||||
WriteMode string `gorm:"size:16"`
|
||||
// Approval is JSON, added by AutoMigrate; a row stored before it asks.
|
||||
Approval string
|
||||
// The columns of the file limits are added by AutoMigrate; a row stored
|
||||
// before them has no limit of the owner.
|
||||
FileReceiveMax int64
|
||||
@@ -128,7 +130,7 @@ func (r bindingRow) binding() (Binding, error) {
|
||||
for _, f := range []struct {
|
||||
raw string
|
||||
dst any
|
||||
}{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}} {
|
||||
}{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}, {r.Approval, &b.Approval}} {
|
||||
if f.raw == "" {
|
||||
continue
|
||||
}
|
||||
@@ -157,7 +159,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&bindingRow{WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
|
||||
return s.db.WithContext(ctx).Save(&bindingRow{Approval: enc(b.Approval, b.Approval == Approval{}), WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
|
||||
AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes,
|
||||
FilesOff: b.FilesOff, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
|
||||
Token: b.Token, Created: b.Created, Updated: b.Updated}).Error
|
||||
|
||||
@@ -192,6 +192,10 @@ type Binding struct {
|
||||
// WriteMode is one of WriteOff, WritePropose and WriteAuto. Empty means
|
||||
// WriteOff.
|
||||
WriteMode string
|
||||
// Approval is what the host does with a trigger that the agent proposes
|
||||
// from a chat of the binding, and the limits of the modes that create one
|
||||
// without asking. The zero value asks.
|
||||
Approval Approval
|
||||
// Token is the bot token. In a Store it is sealed bytes; the Store returns
|
||||
// what it was given.
|
||||
Token []byte
|
||||
@@ -359,6 +363,9 @@ func Validate(b Binding) error {
|
||||
if !ValidWriteMode(b.WriteMode) {
|
||||
return bad("the write mode must be %s, %s or %s", WriteOff, WritePropose, WriteAuto)
|
||||
}
|
||||
if err := b.Approval.Validate(); err != nil {
|
||||
return bad("%v", err)
|
||||
}
|
||||
if len(b.Options) > MaxOptions {
|
||||
return bad("a binding has at most %d options", MaxOptions)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ type ConnectorInput struct {
|
||||
// WriteMode is one of connectors.WriteOff, WritePropose and WriteAuto. Nil:
|
||||
// new binding off, update unchanged.
|
||||
WriteMode *string
|
||||
// Approval is the owner's choice for the triggers the agent proposes from
|
||||
// a chat of the binding. Nil: new binding asks, update unchanged.
|
||||
Approval *connectors.Approval
|
||||
}
|
||||
|
||||
// apply sets the fields of the input that an owner writes on a binding.
|
||||
@@ -98,6 +101,9 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
|
||||
if in.WriteMode != nil {
|
||||
b.WriteMode = *in.WriteMode
|
||||
}
|
||||
if in.Approval != nil {
|
||||
b.Approval = *in.Approval
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTokenRefused means the platform refused the bot token of a binding.
|
||||
|
||||
@@ -18642,6 +18642,14 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
|
||||
"type": "array",
|
||||
@@ -19629,6 +19637,14 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
|
||||
"type": "array",
|
||||
@@ -19837,6 +19853,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.ChatsConnectorApproval": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_lifetime_days": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_pending": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_runs_per_day": {
|
||||
"type": "integer"
|
||||
},
|
||||
"min_gap_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.ChatsConnectorFileLimit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20123,6 +20159,14 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
|
||||
"type": "array",
|
||||
|
||||
@@ -18639,6 +18639,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
|
||||
"type": "array",
|
||||
@@ -19626,6 +19634,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
|
||||
"type": "array",
|
||||
@@ -19834,6 +19850,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.ChatsConnectorApproval": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_lifetime_days": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_pending": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_runs_per_day": {
|
||||
"type": "integer"
|
||||
},
|
||||
"min_gap_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.ChatsConnectorFileLimit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20120,6 +20156,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/schema.ChatsConnectorApproval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
|
||||
"type": "array",
|
||||
|
||||
@@ -3026,6 +3026,12 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
approval:
|
||||
allOf:
|
||||
- $ref: '#/definitions/schema.ChatsConnectorApproval'
|
||||
description: |-
|
||||
Approval is what the host does with a trigger that the agent proposes
|
||||
from a chat of this binding.
|
||||
caps:
|
||||
description: |-
|
||||
Caps are the capabilities you enabled for this binding: the ceiling of
|
||||
@@ -3847,6 +3853,12 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
approval:
|
||||
allOf:
|
||||
- $ref: '#/definitions/schema.ChatsConnectorApproval'
|
||||
description: |-
|
||||
Approval is what the host does with a trigger that the agent proposes
|
||||
from a chat of this binding.
|
||||
caps:
|
||||
description: |-
|
||||
Caps are the capabilities you enabled for this binding: the ceiling of
|
||||
@@ -4017,6 +4029,19 @@ definitions:
|
||||
label:
|
||||
type: string
|
||||
type: object
|
||||
schema.ChatsConnectorApproval:
|
||||
properties:
|
||||
max_lifetime_days:
|
||||
type: integer
|
||||
max_pending:
|
||||
type: integer
|
||||
max_runs_per_day:
|
||||
type: integer
|
||||
min_gap_minutes:
|
||||
type: integer
|
||||
mode:
|
||||
type: string
|
||||
type: object
|
||||
schema.ChatsConnectorFileLimit:
|
||||
properties:
|
||||
allowed_bytes:
|
||||
@@ -4221,6 +4246,13 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
approval:
|
||||
allOf:
|
||||
- $ref: '#/definitions/schema.ChatsConnectorApproval'
|
||||
description: |-
|
||||
Approval sets how triggers that the agent proposes from a chat of the
|
||||
binding are approved, and the limits of the automatic modes. Absent
|
||||
means ask for a new binding and unchanged for an update.
|
||||
caps:
|
||||
description: |-
|
||||
Caps are the capabilities you enable for the binding (a closed list,
|
||||
|
||||
Reference in new issue
Block a user