mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(settings): set the host ceiling of connector files
The owner policy key connector_files_ceiling_bytes gets a field on the Settings page, next to the add-on upgrade setting. It states the ceiling in effect and that larger files need the add-on to support streaming. The host settings client accepts the new key as a number. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
7 files changed
+159
-7
No files matched your search
+1
-1
@@ -73,7 +73,7 @@ export async function stubAddonRoutes(page) {
|
||||
const HOST_POLICY_DEFAULTS = {
|
||||
chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false,
|
||||
chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '',
|
||||
chats_tool_providers: {}, auto_upgrade_addons: false,
|
||||
chats_tool_providers: {}, auto_upgrade_addons: false, connector_files_ceiling_bytes: 0,
|
||||
}
|
||||
export function hostPolicy(over = {}) {
|
||||
const policy = structuredClone(HOST_POLICY_DEFAULTS)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
import { mockSettings } from './settings-fixtures.js'
|
||||
import { mockAddons } from './addons-fixtures.js'
|
||||
|
||||
// The host ceiling of connector files is an owner policy key. The Settings page
|
||||
// writes it through host-settings-update, like the add-on upgrade setting, and
|
||||
// states the numbers in effect that the connector list reports.
|
||||
|
||||
const MIB = 1024 * 1024
|
||||
const SHOTS = process.env.UI_SHOTS_DIR
|
||||
|
||||
async function open(page, ceiling = 0) {
|
||||
await mockSettings(page, { addon_host_settings_authority: 'owner_required' })
|
||||
const state = await mockAddons(page, { hostSettings: { connector_files_ceiling_bytes: ceiling } })
|
||||
await page.route(/\/api\/chats\/connectors\/kinds$/, route => route.fulfill({ json: { kinds: [{
|
||||
addon: 'telegram', kind: 'telegram', title: 'Telegram',
|
||||
files: { inline_bytes: MIB, host_ceiling_bytes: state.hostSettings.policy.connector_files_ceiling_bytes || 100 * MIB, receive: {}, send: {} },
|
||||
}] } }))
|
||||
await page.goto('/app/settings')
|
||||
await page.getByRole('button', { name: /^Backends and galleries/ }).click()
|
||||
return state
|
||||
}
|
||||
|
||||
test.describe('Settings: the host ceiling of connector files', () => {
|
||||
test('says what is in effect and that larger files need streaming', async ({ page }) => {
|
||||
await open(page)
|
||||
const box = page.getByTestId('owner-connector-files')
|
||||
await expect(box).toContainText('Now: 100 MiB.')
|
||||
await expect(page.getByTestId('connector-files-inline')).toHaveText('Files up to 1 MiB are sent now; larger files need the add-on to support streaming.')
|
||||
await expect(box.getByRole('button', { name: 'Apply file ceiling' })).toBeDisabled()
|
||||
if (SHOTS) await box.scrollIntoViewIfNeeded().then(() => page.screenshot({ path: `${SHOTS}/settings-ceiling-${process.env.UI_SHOT_NAME || 'light'}.png` }))
|
||||
})
|
||||
|
||||
test('writes the ceiling in bytes as its own owner key, and refuses a size that is too small', async ({ page }) => {
|
||||
const state = await open(page)
|
||||
const input = page.getByTestId('connector-files-ceiling')
|
||||
await input.fill('0.0001')
|
||||
await expect(page.getByTestId('owner-connector-files')).toContainText('Use a size from 1 KiB to 1 TiB')
|
||||
await expect(page.getByRole('button', { name: 'Apply file ceiling' })).toBeDisabled()
|
||||
await input.fill('5')
|
||||
await page.getByRole('button', { name: 'Apply file ceiling' }).click()
|
||||
await expect.poll(() => state.log.hostSettings).toEqual([{ connector_files_ceiling_bytes: 5 * MIB }])
|
||||
await expect(input).toHaveValue('5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createHostSettingsClient } from '../../utils/hostSettingsClient'
|
||||
import { createAddonsApi } from '../../utils/addonsApi'
|
||||
import { chatConnectorsApi } from '../../utils/api'
|
||||
import { apiUrl } from '../../utils/basePath'
|
||||
import { bytesToMib, formatFileSize, mibToBytes } from '../../utils/fileSize'
|
||||
import { useHostSettings } from '../../hooks/useHostSettings'
|
||||
|
||||
const MIN_BYTES = 1024 // mirrors hostsettings.MinFilesCeiling
|
||||
const MAX_BYTES = 2 ** 40
|
||||
|
||||
// The host ceiling of the files that connector add-ons move: the owner policy
|
||||
// key connector_files_ceiling_bytes. No bot can go above it. The numbers in
|
||||
// effect come from the server, so the page names no limit of its own.
|
||||
export default function ConnectorFilesSettings() {
|
||||
const client = useMemo(() => createHostSettingsClient({ api: createAddonsApi({ base: apiUrl }) }), [])
|
||||
const owner = useHostSettings(client, 'connector-files')
|
||||
const [files, setFiles] = useState(null)
|
||||
const [text, setText] = useState('')
|
||||
const saved = owner.snapshot?.policy.connector_files_ceiling_bytes
|
||||
|
||||
useEffect(() => {
|
||||
chatConnectorsApi.kinds().then((r) => setFiles(r.kinds?.[0]?.files || null)).catch(() => {})
|
||||
}, [saved])
|
||||
useEffect(() => { if (saved !== undefined) setText(bytesToMib(saved)) }, [saved])
|
||||
|
||||
const bytes = mibToBytes(text)
|
||||
const bad = text.trim() !== '' && (bytes < MIN_BYTES || bytes > MAX_BYTES)
|
||||
return <section aria-label="Connector files" data-testid="owner-connector-files">
|
||||
<label>Largest file a connector may move, in MiB{' '}
|
||||
<input className="dk-input" type="number" min="0" step="any" inputMode="decimal" value={text} data-testid="connector-files-ceiling"
|
||||
placeholder="Default" disabled={!owner.snapshot || owner.busy || !!owner.error} onChange={e => setText(e.target.value)} />
|
||||
</label>
|
||||
<p className="st-note">No bot can send or read a larger file, whatever its add-on or its owner setting says. Leave it empty for the default.
|
||||
{files?.host_ceiling_bytes > 0 && <> Now: {formatFileSize(files.host_ceiling_bytes)}.</>}</p>
|
||||
{files?.inline_bytes > 0 && <p className="st-note" data-testid="connector-files-inline">Files up to {formatFileSize(files.inline_bytes)} are sent now; larger files need the add-on to support streaming.</p>}
|
||||
{bad && <p className="st-note" role="alert">Use a size from {formatFileSize(MIN_BYTES)} to {formatFileSize(MAX_BYTES)}, or leave it empty.</p>}
|
||||
{owner.error && <div role="alert">Owner settings unavailable or changed: {owner.error.message}
|
||||
<button type="button" disabled={owner.busy} onClick={owner.reload}>Reload and rebase edits</button>
|
||||
{owner.uncertain && <button type="button" disabled={owner.busy} onClick={() => owner.save({})}>Retry same operation</button>}
|
||||
</div>}
|
||||
<button type="button" disabled={!owner.snapshot || owner.busy || !!owner.error || bad || bytes === saved}
|
||||
onClick={() => owner.save({ connector_files_ceiling_bytes: bytes })}>Apply file ceiling</button>
|
||||
</section>
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import HomeUndoToast from '../components/home/HomeUndoToast'
|
||||
import SettingField from '../components/settings/SettingField'
|
||||
import BuiltinToolsSection from '../components/settings/BuiltinToolsSection'
|
||||
import AddonUpgradeSettings from '../components/settings/AddonUpgradeSettings'
|
||||
import ConnectorFilesSettings from '../components/settings/ConnectorFilesSettings'
|
||||
import OwnerBuiltinToolsSection from '../components/settings/OwnerBuiltinToolsSection'
|
||||
import PendingBar from '../components/settings/PendingBar'
|
||||
import HistorySheet from '../components/settings/HistorySheet'
|
||||
@@ -422,6 +423,7 @@ export default function Settings() {
|
||||
<section aria-labelledby="st-group-title">
|
||||
<h2 className="st-h2" id="st-group-title">{active.label}<span className="st-sub">{active.hint}</span></h2>
|
||||
{group === 'backends' && settings.addon_host_settings_authority && <AddonUpgradeSettings />}
|
||||
{group === 'backends' && settings.addon_host_settings_authority && <ConnectorFilesSettings />}
|
||||
{group === 'memory' && <MemoryNow resources={resources} settings={settings} onRefresh={fetchResources} />}
|
||||
{group === 'access' && (
|
||||
<p className="st-note">Sign-in, users, invites and per-user API keys are under Users and keys when authentication is on.</p>
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
const MIB = 1024 * 1024
|
||||
|
||||
// A size as the connector sentences write it: bytes, KiB, MiB, GiB or TiB.
|
||||
export function formatFileSize(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 ceiling is typed in MiB and stored in bytes. Empty means the default.
|
||||
export const bytesToMib = (bytes) => (bytes > 0 ? String(bytes / MIB) : '')
|
||||
export function mibToBytes(text) {
|
||||
const t = String(text ?? '').trim()
|
||||
const n = Number(t)
|
||||
return t === '' || !Number.isFinite(n) ? 0 : Math.round(n * MIB)
|
||||
}
|
||||
+5
-2
@@ -8,19 +8,22 @@ export class HostSettingsError extends Error {
|
||||
|
||||
const POLICY_STRINGS = ['chats_egress_ceiling', 'chats_egress_default', 'chats_egress_ports', 'chats_egress_extra_registries', 'chats_egress_allowed_hosts']
|
||||
const POLICY_BOOLEANS = ['chats_egress_allow_private', 'auto_upgrade_addons']
|
||||
const POLICY_KEYS = [...POLICY_STRINGS, ...POLICY_BOOLEANS, 'chats_tool_providers']
|
||||
const POLICY_NUMBERS = ['connector_files_ceiling_bytes']
|
||||
const MAX_FILES_CEILING = 2 ** 40 // mirrors contract.MaxDeclaredFileBytes
|
||||
const POLICY_KEYS = [...POLICY_STRINGS, ...POLICY_BOOLEANS, ...POLICY_NUMBERS, 'chats_tool_providers']
|
||||
const DEFINITE_ERRORS = ['invalid', 'revision_conflict', 'operation_mismatch']
|
||||
const plain = value => value !== null && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
||||
const exact = (value, keys) => plain(value) && Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
|
||||
const byteLength = value => new TextEncoder().encode(value).length
|
||||
|
||||
function validPolicy(policy) {
|
||||
// Policy's Go JSON tags have no omitempty; MarshalJSON emits {} for pins.
|
||||
// Policy.MarshalJSON writes every key (zero means the default) and emits {} for pins.
|
||||
// Do not turn missing/unknown fields into frontend defaults. A future owner
|
||||
// schema needs an explicit client migration rather than a partial snapshot.
|
||||
if (!exact(policy, POLICY_KEYS)) return false
|
||||
if (!POLICY_STRINGS.every(key => typeof policy[key] === 'string' && policy[key].length <= 8192 && byteLength(policy[key]) <= 8192)) return false
|
||||
if (!POLICY_BOOLEANS.every(key => typeof policy[key] === 'boolean')) return false
|
||||
if (!POLICY_NUMBERS.every(key => Number.isSafeInteger(policy[key]) && policy[key] >= 0 && policy[key] <= MAX_FILES_CEILING)) return false
|
||||
if (!['chats_egress_ceiling', 'chats_egress_default'].every(key => ['', 'off', 'registries', 'all'].includes(policy[key].trim()))) return false
|
||||
const pins = policy.chats_tool_providers
|
||||
if (!plain(pins) || Reflect.ownKeys(pins).length > 128) return false
|
||||
|
||||
@@ -16,8 +16,8 @@ globalThis.document = dom.window.document
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
// React DOM detects input event support at import time, after JSDOM exists.
|
||||
const { createRoot } = await import('react-dom/client')
|
||||
// All eight fields are emitted by Go Policy.MarshalJSON (no omitempty).
|
||||
const defaults = { chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false, chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '', chats_tool_providers: {}, auto_upgrade_addons: false }
|
||||
// All nine fields are emitted by Go Policy.MarshalJSON.
|
||||
const defaults = { chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false, chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '', chats_tool_providers: {}, auto_upgrade_addons: false, connector_files_ceiling_bytes: 0 }
|
||||
const dto = (revision = 1, policy = {}) => ({ schema_version: 1, revision, policy: { ...defaults, ...policy } })
|
||||
const reply = data => new Response(JSON.stringify({ payload: { data } }), { status: 200 })
|
||||
|
||||
@@ -95,6 +95,7 @@ test('owner client rejects partial, mistyped, unknown and oversized owner polici
|
||||
...Object.keys(defaults).map(key => { const value = dto(); delete value.policy[key]; return value }),
|
||||
...Object.keys(defaults).map(key => dto(1, { [key]: null })),
|
||||
dto(1, { chats_egress_allow_private: 'false' }), dto(1, { auto_upgrade_addons: 0 }),
|
||||
dto(1, { connector_files_ceiling_bytes: '5' }), dto(1, { connector_files_ceiling_bytes: -1 }), dto(1, { connector_files_ceiling_bytes: 1.5 }), dto(1, { connector_files_ceiling_bytes: 2 ** 40 + 1 }),
|
||||
dto(1, { chats_egress_ports: 443 }), dto(1, { chats_egress_ceiling: 'future' }),
|
||||
dto(1, { chats_egress_allowed_hosts: 'é'.repeat(4097) }),
|
||||
dto(1, { chats_tool_providers: new Date() }), dto(1, { future_owner_guard: false }),
|
||||
@@ -262,15 +263,16 @@ async function providerRender() {
|
||||
if (!id.startsWith('\0provider-stub:')) return
|
||||
if (id.endsWith('/UnsavedChangesGuard')) return 'export default function Guard() { return null }'
|
||||
if (id.endsWith('react-i18next')) return 'export const useTranslation = () => ({ t: key => key, i18n: { language: "en" } })'
|
||||
return 'export const chatBuiltinToolsApi = { list: () => globalThis.providerCatalog() }; export const settingsApi = { get: () => fetch("/api/settings"), save: () => fetch("/api/settings") }'
|
||||
return 'export const chatBuiltinToolsApi = { list: () => globalThis.providerCatalog() }; export const chatConnectorsApi = { kinds: () => globalThis.connectorKinds() }; export const settingsApi = { get: () => fetch("/api/settings"), save: () => fetch("/api/settings") }'
|
||||
},
|
||||
}] })
|
||||
const { default: Form } = await server.ssrLoadModule('/src/components/hostcontrols/HostControlsForm.jsx')
|
||||
await server.ssrLoadModule('/src/components/hostcontrols/ToolProvidersSettings.jsx')
|
||||
const { default: Skills } = await server.ssrLoadModule('/src/components/addons/AddonSkills.jsx')
|
||||
const { default: Upgrades } = await server.ssrLoadModule('/src/components/settings/AddonUpgradeSettings.jsx')
|
||||
const { default: ConnectorFiles } = await server.ssrLoadModule('/src/components/settings/ConnectorFilesSettings.jsx')
|
||||
const root = createRoot(document.getElementById('root'))
|
||||
return { Form, Skills, Upgrades, async render(Component, props) { await act(async () => root.render(React.createElement(Component, props))) }, async close() { await act(async () => root.unmount()); await server.close() } }
|
||||
return { Form, Skills, Upgrades, ConnectorFiles, async render(Component, props) { await act(async () => root.render(React.createElement(Component, props))) }, async close() { await act(async () => root.unmount()); await server.close() } }
|
||||
}
|
||||
const buttonNamed = text => [...document.querySelectorAll('button')].find(b => b.textContent === text)
|
||||
const skillSwitch = name => document.querySelector(`[data-testid="app-skill-${name}-switch"]`)
|
||||
@@ -382,3 +384,38 @@ test('mounted skills retain conflict edits, require visible rebase and retry unc
|
||||
assert.equal(calls.filter(c => c.action === 'host-settings-update').length, 2)
|
||||
} finally { await h.close(); globalThis.fetch = previous }
|
||||
})
|
||||
|
||||
test('mounted connector file ceiling states the size in effect, validates, and writes only its own owner key', async () => {
|
||||
const previous = globalThis.fetch, calls = []
|
||||
let revision = 3, ceiling = 0
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
assert.equal(url, '/api/addons/dispatch')
|
||||
const body = JSON.parse(opts.body); calls.push(body)
|
||||
if (body.action === 'host-settings-update') {
|
||||
assert.deepEqual(body.arguments, { expectedRevision: 3, patch: { connector_files_ceiling_bytes: 5 * 1024 * 1024 } })
|
||||
ceiling = body.arguments.patch.connector_files_ceiling_bytes; revision++
|
||||
}
|
||||
return reply(dto(revision, { connector_files_ceiling_bytes: ceiling }))
|
||||
}
|
||||
globalThis.connectorKinds = async () => ({ kinds: [{ kind: 'x', files: { inline_bytes: 1024 * 1024, host_ceiling_bytes: ceiling || 100 * 1024 * 1024 } }] })
|
||||
const h = await providerRender()
|
||||
try {
|
||||
await h.render(h.ConnectorFiles, {})
|
||||
const input = () => document.querySelector('[data-testid="connector-files-ceiling"]')
|
||||
const setValue = async value => {
|
||||
const setter = Object.getOwnPropertyDescriptor(globalThis.window.HTMLInputElement.prototype, 'value').set
|
||||
await act(async () => { setter.call(input(), value); input().dispatchEvent(new globalThis.window.Event('input', { bubbles: true })) })
|
||||
}
|
||||
assert.match(document.body.textContent, /Now: 100 MiB\./)
|
||||
assert.match(document.querySelector('[data-testid="connector-files-inline"]').textContent, /Files up to 1 MiB are sent now; larger files need the add-on to support streaming\./)
|
||||
assert.equal(buttonNamed('Apply file ceiling').disabled, true, 'nothing changed')
|
||||
await setValue('0.0001')
|
||||
assert.match(document.body.textContent, /Use a size from 1 KiB to 1 TiB/)
|
||||
assert.equal(buttonNamed('Apply file ceiling').disabled, true)
|
||||
await setValue('5')
|
||||
await act(async () => buttonNamed('Apply file ceiling').click())
|
||||
assert.equal(ceiling, 5 * 1024 * 1024)
|
||||
assert.match(document.body.textContent, /Now: 5 MiB\./)
|
||||
assert.equal(input().value, '5')
|
||||
} finally { await h.close(); globalThis.fetch = previous; delete globalThis.connectorKinds }
|
||||
})
|
||||
Reference in new issue
Block a user