From 2249c51c4cdb3e4a4d40ee0d59df7d81ae4f433f Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:32:47 +0000 Subject: [PATCH] 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 --- .../connector-ui/src/AnswerFields.jsx | 5 +- .../connector-ui/src/ConnectorPage.jsx | 9 ++- .../internal/connector-ui/src/FilesFields.jsx | 68 +++++++++++++++++++ addons/internal/connector-ui/src/connector.js | 58 +++++++++++++++- addons/telegram/ui/src/i18n/telegram.en.json | 2 +- addons/telegram/ui/test/connector.test.mjs | 57 +++++++++++++++- core/http/react-ui/e2e/connector-fixtures.js | 20 +++++- core/http/react-ui/e2e/telegram-addon.spec.js | 57 +++++++++++++++- 8 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 addons/internal/connector-ui/src/FilesFields.jsx diff --git a/addons/internal/connector-ui/src/AnswerFields.jsx b/addons/internal/connector-ui/src/AnswerFields.jsx index af2f2a527a..4fb22a82b3 100644 --- a/addons/internal/connector-ui/src/AnswerFields.jsx +++ b/addons/internal/connector-ui/src/AnswerFields.jsx @@ -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. diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index 204b810bbe..4c2d90fea5 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -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) => setForm({ ...form, notify_chat: e.target.value })} />} +
- +
@@ -238,7 +241,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {t('test')} - + diff --git a/addons/internal/connector-ui/src/FilesFields.jsx b/addons/internal/connector-ui/src/FilesFields.jsx new file mode 100644 index 0000000000..f4fe106f33 --- /dev/null +++ b/addons/internal/connector-ui/src/FilesFields.jsx @@ -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 ( + + {(p) => ( + onChange(e.target.value)} /> + )} + + ) +} + +/** + * @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 ( +
+ {s('files.title')} +

{s('files.help')}

+ setForm({ ...form, file_receive_mib: v })} /> + setForm({ ...form, file_send_mib: v })} /> + {inlineBinds && kind.inline_bytes > 0 &&

{s('files.inline', { limit: formatBytes(kind.inline_bytes) })}

} +
+ ) +} diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index 6fd588bb88..d3f2dc5181 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -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 diff --git a/addons/telegram/ui/src/i18n/telegram.en.json b/addons/telegram/ui/src/i18n/telegram.en.json index 2f3eced2cb..3bfae8c6e5 100644 --- a/addons/telegram/ui/src/i18n/telegram.en.json +++ b/addons/telegram/ui/src/i18n/telegram.en.json @@ -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", diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs index 6709137e2f..a12dcafc83 100644 --- a/addons/telegram/ui/test/connector.test.mjs +++ b/addons/telegram/ui/test/connector.test.mjs @@ -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) +}) diff --git a/core/http/react-ui/e2e/connector-fixtures.js b/core/http/react-ui/e2e/connector-fixtures.js index 9dff274702..b3e9b53210 100644 --- a/core/http/react-ui/e2e/connector-fixtures.js +++ b/core/http/react-ui/e2e/connector-fixtures.js @@ -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 }] } })) }, } } diff --git a/core/http/react-ui/e2e/telegram-addon.spec.js b/core/http/react-ui/e2e/telegram-addon.spec.js index 866ff91a98..ba7ce3efbf 100644 --- a/core/http/react-ui/e2e/telegram-addon.spec.js +++ b/core/http/react-ui/e2e/telegram-addon.spec.js @@ -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 }) + }) })