mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(connector-ui): add a Files section to the connector page
The section has a field for the largest file the bot reads and one for the largest it sends. A value can only lower the limit of the add-on. Each field shows the limit in effect and the layer that sets it, and the page says that larger files need the add-on to support streaming while the inline cap is the limit. The fixed 1 MB in the intro is gone. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
8 files changed
+266
-10
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 { FILES_TEXT } from './FilesFields'
|
||||
import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector'
|
||||
|
||||
export const ANSWER_TEXT = {
|
||||
@@ -32,7 +33,7 @@ export const ANSWER_TEXT = {
|
||||
'less': 'Less',
|
||||
'answer.threadContext': 'Read the earlier messages of a thread',
|
||||
'answer.threadContextHelp': 'When the bot is mentioned in a thread, it reads the messages of that thread, and only that thread, to answer with the context.',
|
||||
'answer.filesHelp': 'After the answer, up to 5 files of at most 1 MB each. Programs and scripts are not sent.',
|
||||
'answer.filesHelp': 'After the answer, up to 5 files. Programs and scripts are not sent. The size limit is under Files.',
|
||||
'answer.summary': 'Answers: {{reply}} · {{conversation}}',
|
||||
'answer.short.off': 'typing',
|
||||
'answer.short.progress': 'progress',
|
||||
@@ -45,7 +46,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], ...opts })
|
||||
return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key], ...opts })
|
||||
}
|
||||
|
||||
// answerSummary says in a few words how a binding answers, for its row.
|
||||
|
||||
@@ -9,10 +9,11 @@ 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 FilesFields from './FilesFields'
|
||||
import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls'
|
||||
import NameList from './NameList'
|
||||
import { agentsApi, connectorsApi } from './api'
|
||||
import { canSave, connectorPayload, emptyConnector, listEntries, replaceList, senderLine } from './connector'
|
||||
import { canSave, connectorForm, connectorPayload, emptyConnector, listEntries, replaceList, senderLine } from './connector'
|
||||
import './connector.css'
|
||||
|
||||
function Senders({ id, onAllowed, t }) {
|
||||
@@ -82,6 +83,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
const [toDelete, setToDelete] = useState(null)
|
||||
const [openSenders, setOpenSenders] = useState('')
|
||||
const [kindInfo, setKindInfo] = useState(null)
|
||||
const kindFiles = kindInfo?.files || null
|
||||
|
||||
const fail = useCallback((e) => {
|
||||
if (e?.status === 409 && e.body?.error?.type === 'connectors_off') { setOff(e.body.error.message); return }
|
||||
@@ -195,8 +197,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{(p) => <TextInput {...p} mono inputMode={notifyMode} value={form.notify_chat} onChange={(e) => setForm({ ...form, notify_chat: e.target.value })} />}
|
||||
</Field>
|
||||
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
|
||||
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} 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()))}>{t('save')}</button>
|
||||
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles)}>{t('save')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setForm(null)}>{t('cancel')}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -238,7 +241,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{t('test')}
|
||||
</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setOpenSenders(openSenders === c.id ? '' : c.id)}>{t('senders')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => open({ ...emptyConnector(), ...c, token: '' })}>{t('editButton')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => open(connectorForm(c))}>{t('editButton')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setToDelete(c)}>{t('delete')}</button>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
// The limits of the files a bot reads and sends. The owner can only lower the
|
||||
// limit of the connector kind. The page shows the limit in effect with the
|
||||
// layer that sets it, and says so when the cap of the inline path is the one
|
||||
// that applies, because then a higher limit changes nothing yet. The numbers
|
||||
// come from the server; the words live here as defaults, like the rest of the
|
||||
// shared fields.
|
||||
import { Field, TextInput } from './controls'
|
||||
import { fileLimitProblem, formatBytes, previewLimit } from './connector'
|
||||
|
||||
export const FILES_TEXT = {
|
||||
'files.title': 'Files',
|
||||
'files.help': 'Leave a field empty to use the limit of the add-on. Your limit can only be lower than that.',
|
||||
'files.receiveLabel': 'Largest file it reads, in MiB',
|
||||
'files.sendLabel': 'Largest file it sends, in MiB',
|
||||
'files.invalid': 'Use a number greater than 0.',
|
||||
'files.tooHigh': 'The add-on allows at most {{max}} here.',
|
||||
'files.now': 'Now: {{limit}} ({{layer}}).',
|
||||
'files.layer.owner': 'limit set for this bot',
|
||||
'files.layer.addon': 'limit of the add-on',
|
||||
'files.layer.host': 'limit of the platform',
|
||||
'files.layer.inline': 'limit of this add-on version',
|
||||
'files.inline': 'Files up to {{limit}} are sent now; larger files need the add-on to support streaming.',
|
||||
}
|
||||
|
||||
// The limit that a field shows: what the typed value gives over the limit of
|
||||
// the kind, or the saved limit of the binding when the kind is not known.
|
||||
function shown(base, saved, mib) {
|
||||
return previewLimit(base, mib) || (saved ? { bytes: saved.effective_bytes, layer: saved.layer } : null)
|
||||
}
|
||||
|
||||
function Limit({ label, mib, onChange, base, saved, s, testId }) {
|
||||
const problem = fileLimitProblem(mib, base?.allowed_bytes)
|
||||
const limit = shown(base, saved, mib)
|
||||
const error = problem === 'invalid' ? s('files.invalid') : problem === 'tooHigh' ? s('files.tooHigh', { max: formatBytes(base.allowed_bytes) }) : undefined
|
||||
return (
|
||||
<Field label={label} error={error}
|
||||
hint={limit ? s('files.now', { limit: formatBytes(limit.bytes), layer: s(`files.layer.${limit.layer}`) }) : undefined}>
|
||||
{(p) => (
|
||||
<TextInput {...p} type="number" inputMode="decimal" min="0" step="any" data-testid={testId}
|
||||
value={mib} onChange={(e) => onChange(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {object} props.form the form of the binding
|
||||
* @param {Function} props.setForm
|
||||
* @param {object} [props.kind] the files of the kind, from the kind list
|
||||
* @param {object} [props.saved] the files of the saved binding, when there is one
|
||||
* @param {Function} props.s the translation function with the defaults
|
||||
*/
|
||||
export default function FilesFields({ form, setForm, kind, saved, s }) {
|
||||
const inlineBinds = [kind?.receive && shown(kind.receive, null, form.file_receive_mib), kind?.send && shown(kind.send, null, form.file_send_mib)].some((l) => l?.layer === 'inline')
|
||||
return (
|
||||
<fieldset className="cn-group" data-testid="connector-filelimits">
|
||||
<legend>{s('files.title')}</legend>
|
||||
<p className="dk-hint">{s('files.help')}</p>
|
||||
<Limit label={s('files.receiveLabel')} mib={form.file_receive_mib} base={kind?.receive} saved={saved?.receive} s={s} testId="connector-file-receive"
|
||||
onChange={(v) => setForm({ ...form, file_receive_mib: v })} />
|
||||
<Limit label={s('files.sendLabel')} mib={form.file_send_mib} base={kind?.send} saved={saved?.send} s={s} testId="connector-file-send"
|
||||
onChange={(v) => setForm({ ...form, file_send_mib: v })} />
|
||||
{inlineBinds && kind.inline_bytes > 0 && <p className="cn-muted" data-testid="connector-file-inline">{s('files.inline', { limit: formatBytes(kind.inline_bytes) })}</p>}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,62 @@ export function emptyConnector() {
|
||||
id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '',
|
||||
reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true,
|
||||
thread_context: true, options: {},
|
||||
file_receive_mib: '', file_send_mib: '',
|
||||
}
|
||||
}
|
||||
|
||||
const MIB = 1024 * 1024
|
||||
|
||||
// The file limits of a binding are typed in MiB and stored in bytes. Empty
|
||||
// means no limit of the owner: the limit of the kind applies.
|
||||
export function bytesToMib(bytes) {
|
||||
return bytes > 0 ? String(bytes / MIB) : '' // a power of two divides exactly, so a saved limit reads back unchanged
|
||||
}
|
||||
|
||||
export function mibToBytes(mib) {
|
||||
const n = Number(String(mib ?? '').trim())
|
||||
return String(mib ?? '').trim() === '' || !Number.isFinite(n) ? 0 : Math.round(n * MIB)
|
||||
}
|
||||
|
||||
// The form of a saved binding, with its limits in MiB and no token.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// Why a typed limit cannot be saved: 'invalid' (not a positive number) or
|
||||
// 'tooHigh' (above what the kind allows), else ''. allowedBytes may be 0 when
|
||||
// the page does not know it; the server checks then.
|
||||
export function fileLimitProblem(mib, allowedBytes) {
|
||||
if (String(mib ?? '').trim() === '') return ''
|
||||
const n = Number(mib)
|
||||
if (!Number.isFinite(n) || n <= 0 || mibToBytes(mib) < 1) return 'invalid'
|
||||
return allowedBytes > 0 && mibToBytes(mib) > allowedBytes ? 'tooHigh' : ''
|
||||
}
|
||||
|
||||
// The limit that a typed value gives, for the line "now" under the field. base
|
||||
// is the limit of the kind without a limit of the owner (the server's number),
|
||||
// so a typed value can only lower it.
|
||||
export function previewLimit(base, mib) {
|
||||
const bytes = mibToBytes(mib)
|
||||
if (!base) return null
|
||||
if (bytes > 0 && bytes <= base.effective_bytes) return { bytes, layer: 'owner' }
|
||||
return { bytes: base.effective_bytes, layer: base.layer }
|
||||
}
|
||||
|
||||
// A size as a person reads it: bytes, KiB, MiB, GiB or TiB, one decimal at most.
|
||||
export function formatBytes(n) {
|
||||
if (n === 1) return '1 byte'
|
||||
if (n < 1024) return `${n} bytes`
|
||||
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
||||
let v = n / 1024
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return `${Number(v.toFixed(1))} ${units[i]}`
|
||||
}
|
||||
|
||||
// The body of a create or replace request for a binding of kind. token is the
|
||||
// secret the add-on's fields make; it goes only when it was typed: an update
|
||||
// without one keeps the stored token.
|
||||
@@ -32,6 +85,8 @@ export function connectorPayload(form, kind, token = form.token) {
|
||||
thread_context: form.thread_context !== false,
|
||||
// The choices for the options of the kind; a missing one has its default.
|
||||
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),
|
||||
}
|
||||
if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0
|
||||
const secret = (token || '').trim()
|
||||
@@ -53,10 +108,11 @@ export function senderLine(s) {
|
||||
|
||||
// "Accept any message" is only saved after the person confirms the warning.
|
||||
// hasToken says whether the add-on's fields hold a complete secret.
|
||||
export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim())) {
|
||||
export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim()), files = null) {
|
||||
if (!(form.name || '').trim() || !form.agent_id) return false
|
||||
if (!form.id && !hasToken) return false
|
||||
if (form.accept_any && !confirmed) 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)
|
||||
if (!Number.isInteger(m) || m < 1 || m > MAX_WINDOW_MINUTES) return false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Telegram",
|
||||
"intro": "Bind a Telegram bot to one of your saved agents. A person who messages the bot talks to that agent, in a conversation of their own. They can send text, photos (up to 1 MB) and voice messages; a voice message is transcribed with the Chat transcription model of the settings.",
|
||||
"intro": "Bind a Telegram bot to one of your saved agents. A person who messages the bot talks to that agent, in a conversation of their own. They can send text, photos, files and voice messages (the Files section shows how large); a voice message is transcribed with the Chat transcription model of the settings.",
|
||||
"new": "New Telegram bot",
|
||||
"edit": "Edit Telegram bot",
|
||||
"name": "Name",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { canSave, connectorPayload, emptyConnector, modeText, senderLine } from '../../../internal/connector-ui/src/connector.js'
|
||||
import { 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')
|
||||
@@ -78,3 +78,58 @@ test('chat connectors: a new binding reads threads, and the request carries it w
|
||||
assert.equal(connectorPayload(base, 'slack').thread_context, true)
|
||||
assert.deepEqual(connectorPayload({ ...base, options: undefined }, 'slack').options, {})
|
||||
})
|
||||
|
||||
test('chat connectors: file limits are typed in MiB and sent in bytes, and empty means none', () => {
|
||||
const base = { ...emptyConnector(), name: 'B', agent_id: 'a' }
|
||||
const none = connectorPayload(base, 'telegram')
|
||||
assert.equal(none.file_receive_max_bytes, 0)
|
||||
assert.equal(none.file_send_max_bytes, 0)
|
||||
const set = connectorPayload({ ...base, file_receive_mib: '0.5', file_send_mib: '2' }, 'telegram')
|
||||
assert.equal(set.file_receive_max_bytes, 512 * 1024)
|
||||
assert.equal(set.file_send_max_bytes, 2 * 1024 * 1024)
|
||||
assert.equal(mibToBytes(' '), 0)
|
||||
assert.equal(mibToBytes('abc'), 0)
|
||||
})
|
||||
|
||||
test('chat connectors: a saved binding opens with its limits in MiB', () => {
|
||||
const form = connectorForm({ id: 'b1', name: 'B', file_receive_max_bytes: 0, file_send_max_bytes: 262144, token: 'x' })
|
||||
assert.equal(form.file_receive_mib, '')
|
||||
assert.equal(form.file_send_mib, '0.25')
|
||||
assert.equal(form.token, '')
|
||||
assert.equal(bytesToMib(1536 * 1024), '1.5')
|
||||
})
|
||||
|
||||
test('chat connectors: a limit above what the kind allows, or not a positive number, cannot be saved', () => {
|
||||
const allowed = 1024 * 1024
|
||||
assert.equal(fileLimitProblem('', allowed), '')
|
||||
assert.equal(fileLimitProblem('1', allowed), '')
|
||||
assert.equal(fileLimitProblem('1.5', allowed), 'tooHigh')
|
||||
assert.equal(fileLimitProblem('0', allowed), 'invalid')
|
||||
assert.equal(fileLimitProblem('-2', allowed), 'invalid')
|
||||
assert.equal(fileLimitProblem('x', allowed), 'invalid')
|
||||
assert.equal(fileLimitProblem('50', 0), '', 'the server checks when the page does not know the limit')
|
||||
const files = { receive: { allowed_bytes: allowed }, send: { allowed_bytes: allowed } }
|
||||
const form = { ...emptyConnector(), name: 'B', agent_id: 'a', token: 't' }
|
||||
assert.equal(canSave({ ...form, file_send_mib: '0.5' }, false, true, files), true)
|
||||
assert.equal(canSave({ ...form, file_send_mib: '2' }, false, true, files), false)
|
||||
})
|
||||
|
||||
test('chat connectors: a typed limit can only lower the limit in effect, and names its layer', () => {
|
||||
const base = { effective_bytes: 1024 * 1024, layer: 'inline' }
|
||||
assert.deepEqual(previewLimit(base, ''), { bytes: 1024 * 1024, layer: 'inline' })
|
||||
assert.deepEqual(previewLimit(base, '0.25'), { bytes: 256 * 1024, layer: 'owner' })
|
||||
assert.deepEqual(previewLimit(base, '5'), { bytes: 1024 * 1024, layer: 'inline' }, 'a higher number changes nothing yet')
|
||||
assert.equal(previewLimit(null, '1'), null)
|
||||
})
|
||||
|
||||
test('chat connectors: sizes are written in KiB and MiB', () => {
|
||||
assert.equal(formatBytes(900), '900 bytes')
|
||||
assert.equal(formatBytes(1), '1 byte')
|
||||
assert.equal(formatBytes(512 * 1024), '512 KiB')
|
||||
assert.equal(formatBytes(1024 * 1024), '1 MiB')
|
||||
assert.equal(formatBytes(5.5 * 1024 * 1024), '5.5 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)
|
||||
})
|
||||
+19
-1
@@ -9,7 +9,23 @@ export const AGENTS = [
|
||||
|
||||
const NEWCOMER = { id: '42', name: 'Newcomer', count: 2, allowed: false, first: '2026-10-27T08:00:00Z', last: '2026-10-27T08:05:00Z' }
|
||||
|
||||
export function connectorApi(page, initial = [], kind = 'telegram', senders = [NEWCOMER]) {
|
||||
const MIB = 1024 * 1024
|
||||
|
||||
// The files of a kind that declares nothing: the inline cap is the limit.
|
||||
export const INLINE_ONLY = {
|
||||
receive: { effective_bytes: MIB, layer: 'inline', declared_bytes: 0, allowed_bytes: MIB },
|
||||
send: { effective_bytes: MIB, layer: 'inline', declared_bytes: 0, allowed_bytes: MIB },
|
||||
inline_bytes: MIB,
|
||||
}
|
||||
|
||||
// The files of a kind that declares 20 MiB to read and 256 KiB to send.
|
||||
export const DECLARED = {
|
||||
receive: { effective_bytes: MIB, layer: 'inline', declared_bytes: 20 * MIB, allowed_bytes: 20 * MIB },
|
||||
send: { effective_bytes: 256 * 1024, layer: 'addon', declared_bytes: 256 * 1024, allowed_bytes: 256 * 1024 },
|
||||
inline_bytes: MIB,
|
||||
}
|
||||
|
||||
export function connectorApi(page, initial = [], kind = 'telegram', senders = [NEWCOMER], files = INLINE_ONLY) {
|
||||
const s = { items: structuredClone(initial), calls: [], senders: structuredClone(senders) }
|
||||
const rec = (method, url, body) => s.calls.push({ method, url: new URL(url).pathname, body })
|
||||
return {
|
||||
@@ -48,6 +64,8 @@ export function connectorApi(page, initial = [], kind = 'telegram', senders = [N
|
||||
})
|
||||
await page.route(/\/api\/chats(\?.*)?$/, r => r.fulfill({ json: { conversations: [], persistent: true, files: true } }))
|
||||
await page.route('**/api/chats/notifications*', r => r.fulfill({ json: { notifications: [], unread: 0 } }))
|
||||
// Registered last, so it answers before the route of one binding.
|
||||
await page.route(/\/api\/chats\/connectors\/kinds$/, r => r.fulfill({ json: { kinds: [{ addon: kind, kind, title: kind, files }] } }))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
import { mockChat } from './chat-fixtures.js'
|
||||
import { installStudioAddons } from './studio-addon.js'
|
||||
import { connectorApi as api } from './connector-fixtures.js'
|
||||
import { connectorApi as api, DECLARED } from './connector-fixtures.js'
|
||||
|
||||
// The page of the Telegram add-on (the real built bundle, inside the add-on
|
||||
// shell) with the core API stubbed: the token only goes in, "anyone"
|
||||
@@ -166,4 +166,59 @@ test.describe('Telegram add-on', () => {
|
||||
await page.getByTestId('connector-window').fill('30')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('the Files section says which limit applies and that the inline cap is the one in effect now', async ({ page }) => {
|
||||
const a = api(page)
|
||||
await a.install()
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'New Telegram bot' }).click()
|
||||
const files = page.getByTestId('connector-filelimits')
|
||||
await expect(files).toContainText('Now: 1 MiB (limit of this add-on version).')
|
||||
await expect(page.getByTestId('connector-file-inline')).toHaveText('Files up to 1 MiB are sent now; larger files need the add-on to support streaming.')
|
||||
// The owner can only lower the limit: 2 MiB is above the 1 MiB the kind allows.
|
||||
await page.getByTestId('connector-file-send').fill('2')
|
||||
await expect(files).toContainText('The add-on allows at most 1 MiB here.')
|
||||
await page.getByLabel('Name', { exact: true }).fill('Files bot')
|
||||
await page.getByLabel('Bot token').fill('555:secret-token')
|
||||
await page.getByLabel('Agent', { exact: true }).selectOption('a1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByTestId('connector-file-send').fill('0.25')
|
||||
await expect(files).toContainText('Now: 256 KiB (limit set for this bot).')
|
||||
await expect(page.getByTestId('connector-file-inline')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
const post = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body
|
||||
expect(post).toMatchObject({ file_send_max_bytes: 256 * 1024, file_receive_max_bytes: 0 })
|
||||
})
|
||||
|
||||
test('a kind that declares abilities shows its own limits, and the note about streaming only where the inline cap applies', async ({ page }) => {
|
||||
const a = api(page, [], 'telegram', [], DECLARED)
|
||||
await a.install()
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'New Telegram bot' }).click()
|
||||
const files = page.getByTestId('connector-filelimits')
|
||||
await expect(files).toContainText('Now: 1 MiB (limit of this add-on version).')
|
||||
await expect(files).toContainText('Now: 256 KiB (limit of the add-on).')
|
||||
await expect(page.getByTestId('connector-file-inline')).toBeVisible()
|
||||
// Reading is allowed up to 20 MiB, so 5 MiB is accepted, and it does not change what is read now.
|
||||
await page.getByTestId('connector-file-receive').fill('5')
|
||||
await expect(files).not.toContainText('allows at most')
|
||||
await expect(files).toContainText('Now: 1 MiB (limit of this add-on version).')
|
||||
// Lowering both below the inline cap takes the note away: the numbers are now the owner's.
|
||||
await page.getByTestId('connector-file-receive').fill('0.5')
|
||||
await page.getByTestId('connector-file-send').fill('0.1')
|
||||
await expect(page.getByTestId('connector-file-inline')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('an edit shows the limit the owner set and a save with it empty clears it', async ({ page }) => {
|
||||
const a = api(page, [{ id: 'b1', name: 'Support bot', kind: 'telegram', agent_id: 'a1', enabled: true, accept_any: false, allow: ['42'], has_token: true, file_send_max_bytes: 100 * 1024 }])
|
||||
await a.install()
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(page.getByTestId('connector-file-send')).toHaveValue('0.09765625')
|
||||
await expect(page.getByTestId('connector-file-receive')).toHaveValue('')
|
||||
await page.getByTestId('connector-file-send').fill('')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
const put = a.s.calls.find(c => c.method === 'PUT').body
|
||||
expect(put).toMatchObject({ file_send_max_bytes: 0, file_receive_max_bytes: 0 })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user