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:
Ettore Di Giacinto committed 2026-11-03 11:47:50 +00:00
1 parent 86a93259ce
commit 2249c51c4c
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>
)
}
+57 -1
View File
@@ -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 -1
View File
@@ -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",
+56 -1
View File
@@ -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
View File
@@ -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 }] } }))
},
}
}
+56 -1
View File
@@ -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 })
})
})