diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js
new file mode 100644
index 000000000..d360681c7
--- /dev/null
+++ b/core/http/react-ui/e2e/addons-backend-library.spec.js
@@ -0,0 +1,114 @@
+import { test, expect } from '@playwright/test'
+import { ADMIN, release, sessionFor, mockAddons } from './addons-fixtures.js'
+
+// Backends in the library (phase 1): an entry has a kind, the Browse tab shows
+// it, and a backend installs through the backend route, not the add-on
+// lifecycle. The server is the authority, so these specs drive the page
+// against a stand-in and assert what the page sent.
+
+const POLICY = { requireSignature: true, issuer: 'https://token.actions.githubusercontent.com', identityRegex: '^https://github\\.com/o/r/\\.github/workflows/w\\.yml@refs/(heads/master|tags/.+)$' }
+const backend = (over = {}) => release('cpu-local-store', {
+ kind: 'backend', name: 'cpu-local-store', alias: 'local-store', description: 'A vector store.', version: '0.0.0-latest.0a1056fd8633',
+ sourceId: 'official-backends', artifact: 'oci://registry.example/backends:latest-cpu-local-store', policy: POLICY, ...over,
+})
+
+async function signIn(page) {
+ await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
+}
+
+// The backend routes of the host. `installed` is the mutable list GET /backends answers.
+async function mockBackends(page, { installed = [], install } = {}) {
+ const state = { installed: [...installed], installs: [] }
+ await page.route(/\/backends$/, route => route.fulfill({ json: state.installed }))
+ await page.route(/\/backends\/library\/install$/, route => {
+ const body = route.request().postDataJSON()
+ state.installs.push(body)
+ if (install) {
+ const custom = install(body)
+ if (custom) return route.fulfill(custom)
+ }
+ state.installed.push({ Name: body.name, Metadata: { library: body.sourceId } })
+ return route.fulfill({ json: { status: 'installed', name: body.name, sourceId: body.sourceId } })
+ })
+ return state
+}
+
+test.describe('Library backends', () => {
+ test('browse: an entry shows its kind, and add-ons are still add-ons', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, { installed: [], releases: [release('tables'), backend()] })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ await expect(page.locator('.cat-item')).toHaveCount(2)
+ await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-kind')).toHaveText('Add-on')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await expect(row.getByTestId('entry-kind')).toHaveText('Backend')
+ await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend')
+ // The page asks for every kind this host reads.
+ expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('all')
+ })
+
+ test('browse: a backend installs through the backend route and never through the add-on lifecycle', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, { installed: [], releases: [backend()] })
+ const backends = await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await row.getByRole('button', { name: 'Install' }).click()
+ const dialog = page.getByRole('alertdialog')
+ await expect(dialog).toContainText('Install cpu-local-store?')
+ await expect(dialog).toContainText('backend gallery is not changed')
+ await expect(dialog).toContainText('native code')
+ expect(backends.installs).toHaveLength(0)
+ await dialog.getByRole('button', { name: 'Install' }).click()
+ await expect(row.getByText('Installed as a backend on this node')).toBeVisible()
+ expect(backends.installs).toEqual([{ sourceId: 'official-backends', name: 'cpu-local-store', version: '0.0.0-latest.0a1056fd8633' }])
+ expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
+ // Once installed the entry says so and offers no second install.
+ await expect(row.getByText('Installed', { exact: true })).toBeVisible()
+ await expect(row.getByRole('button', { name: 'Install' })).toBeDisabled()
+ })
+
+ test('browse: a refusal of the host is shown with its own reason', async ({ page }) => {
+ await signIn(page)
+ await mockAddons(page, { installed: [], releases: [backend()] })
+ await mockBackends(page, {
+ install: () => ({ status: 409, json: { error: { type: 'backend_library', code: 409, message: 'backend is already installed from another origin: "cpu-local-store"' } } }),
+ })
+ await page.goto('/app/addons?tab=browse')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await row.getByRole('button', { name: 'Install' }).click()
+ await page.getByRole('alertdialog').getByRole('button', { name: 'Install' }).click()
+ await expect(row.getByRole('alert')).toContainText('The backend was not installed.')
+ await expect(row.getByRole('alert')).toContainText('already installed from another origin')
+ await expect(row.getByRole('button', { name: 'Install' })).toBeEnabled()
+ })
+
+ test('browse: the details give the kind, the alias and the signer pattern', async ({ page }) => {
+ await signIn(page)
+ await mockAddons(page, { installed: [], releases: [backend()] })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByRole('button', { name: 'Details' }).click()
+ await expect(page.getByTestId('details-kind')).toHaveText('Backend')
+ await expect(page.getByText('local-store', { exact: true })).toBeVisible()
+ await expect(page.getByText('workflows/w', { exact: false })).toBeVisible()
+ })
+
+ test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, {
+ sources: [{ id: 'official-backends', name: 'Official backends', url: 'https://catalog.example/backends.yaml', enabled: true, policy: POLICY, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 1, skipped: 2, skippedKinds: ['model'], failed: false } }],
+ })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=libraries')
+ await expect(page.getByTestId('skipped-kinds')).toContainText('2 entries skipped: kind model')
+ await page.getByRole('button', { name: 'Edit' }).click()
+ const pattern = page.getByLabel('Signer identity pattern (optional)')
+ await expect(pattern).toHaveValue(POLICY.identityRegex)
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect.poll(() => state.log.dispatch.filter(d => d.action === 'catalog-source-save').length).toBe(1)
+ const saved = state.log.dispatch.find(d => d.action === 'catalog-source-save').arguments
+ expect(saved.policy).toEqual(POLICY)
+ })
+})
diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json
index 043de95a3..15574ba6c 100644
--- a/core/http/react-ui/public/locales/en/addons.json
+++ b/core/http/react-ui/public/locales/en/addons.json
@@ -67,7 +67,7 @@
"details": "Details",
"detailsLead": "Library entry for {{id}}",
"emptyBody": "Add a library on the Libraries tab, then refresh it.",
- "emptyTitle": "No add-ons found",
+ "emptyTitle": "No library entries found",
"fromLibrary": "from {{library}}",
"install": "Install",
"installedDetail": "Version {{version}}, generation {{generation}}",
@@ -76,7 +76,18 @@
"installedSame": "Installed {{version}}",
"installedState": "Installed",
"installing": "Installing",
- "search": "Search the libraries"
+ "search": "Search the libraries",
+ "kind": {
+ "addon": "Add-on",
+ "backend": "Backend"
+ },
+ "installedBackend": "Installed",
+ "installedBackendNote": "Installed as a backend on this node. Models can use it now.",
+ "confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.",
+ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
+ "backendFailed": "The backend was not installed.",
+ "alias": "Alias",
+ "kindLabel": "Kind"
},
"catalog": {
"allLibraries": "All libraries",
@@ -156,7 +167,8 @@
"required": "Required when a signature is required.",
"url": "Enter an http or https address.",
"urlCredentials": "The address must not contain a user name or password.",
- "urlQuery": "The address must not contain a query string or fragment."
+ "urlQuery": "The address must not contain a query string or fragment.",
+ "either": "Use the exact identity or the pattern, not both."
},
"fields": {
"enabled": "Enabled",
@@ -166,12 +178,14 @@
"name": "Name",
"reference": "Credential reference (optional)",
"requireSignature": "Require a signature",
- "url": "Catalog URL"
+ "url": "Catalog URL",
+ "identityRegex": "Signer identity pattern (optional)"
},
"hints": {
"id": "Lowercase letters, digits and hyphens. It cannot change later.",
"reference": "The name of a credential the owner configured. Never paste a token or password here.",
- "url": "An https address without credentials or a query string."
+ "url": "An https address without credentials or a query string.",
+ "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify."
},
"lastRefresh": "Refreshed {{when}}, {{count}} entries",
"lead": "A library is a catalog document that lists add-ons. Credentials are never typed here: a private library names a credential the owner configured.",
@@ -187,7 +201,9 @@
"removeTitle": "Remove {{name}}?",
"removed": "Removed {{name}}.",
"save": "Save",
- "saved": "Saved {{name}}."
+ "saved": "Saved {{name}}.",
+ "skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version",
+ "skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version"
},
"manage": {
"api": "Add-on API",
diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx
index 8f70f38fb..d921a1fea 100644
--- a/core/http/react-ui/src/components/addons/BrowseView.jsx
+++ b/core/http/react-ui/src/components/addons/BrowseView.jsx
@@ -20,6 +20,10 @@ export default function BrowseView({ api, adapter }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
const [installed, setInstalled] = useState({})
+ // Backends are installed by the backend code of the host, not by the add-on
+ // owner, so they have their own installed list and their own install state.
+ const [installedBackends, setInstalledBackends] = useState({})
+ const [backendWork, setBackendWork] = useState({})
const [reload, setReload] = useState(0)
const [confirm, setConfirm] = useState(null)
const [details, setDetails] = useState(null)
@@ -43,9 +47,31 @@ export default function BrowseView({ api, adapter }) {
return () => ctl.abort()
}, [api, reload])
+ useEffect(() => {
+ const ctl = new AbortController()
+ api.backends.installed(ctl.signal)
+ .then(list => setInstalledBackends(Object.fromEntries(list.map(b => [b.id, b]))))
+ .catch(() => { if (!ctl.signal.aborted) setInstalledBackends({}) })
+ return () => ctl.abort()
+ }, [api, reload])
+
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1) })
+ const installBackend = async (entry) => {
+ setConfirm(null)
+ const key = `install:${entry.key}`
+ setBackendWork(w => ({ ...w, [key]: { phase: 'working' } }))
+ try {
+ await api.backends.install({ sourceId: entry.sourceId, name: entry.id, version: entry.version })
+ setBackendWork(w => ({ ...w, [key]: { phase: 'done' } }))
+ } catch (error) {
+ setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error } }))
+ }
+ setReload(n => n + 1)
+ }
+
const install = (entry) => {
+ if (entry.kind === 'backend') return installBackend(entry)
setConfirm(null)
actions.run(`install:${entry.key}`, {
addonId: entry.id,
@@ -57,11 +83,13 @@ export default function BrowseView({ api, adapter }) {
}
const renderEntry = (entry) => {
- const have = installed[entry.id]
+ const isBackend = entry.kind === 'backend'
+ const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
const key = `install:${entry.key}`
- const st = actions.state[key]
- const pending = Boolean(actions.unknown[entry.id])
- const working = st?.phase === 'working'
+ const bw = isBackend ? backendWork[key] : null
+ const st = isBackend ? null : actions.state[key]
+ const pending = !isBackend && Boolean(actions.unknown[entry.id])
+ const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working'
return (
{entry.description}
}{describeOutcome(st.outcome, t, st.error)}
)} {st && st.outcome === 'completed' &&{t('browse.installedNotEnabled')}
} + {bw?.phase === 'failed' && ( ++ {t('browse.backendFailed')} {bw.error?.status >= 400 && bw.error.message && bw.error.message !== bw.error.kind ? bw.error.message : describeError(bw.error, t)} +
+ )} + {bw?.phase === 'done' &&{t('browse.installedBackendNote')}
}