From 6e3222922eae9c3cf5ab0c604842c7a5e7309c0d Mon Sep 17 00:00:00 2001
From: mudler-agent
Date: Sun, 25 Oct 2026 19:13:30 +0100
Subject: [PATCH] feat(addons): show the tool count of each add-on before
install (#12898)
The add-on cards, the Find add-ons tab and the install dialog said
"Adds a page" for a library entry but nothing about chat tools, so the
tools only showed once the add-on was installed.
The library index now carries toolCount and tools on each entry, with
the same names as the installed view. They come from capabilities.tools
of the manifest; admin tools are not chat tools and are not counted.
Tools is capped at 32 names and toolCount stays the full number. The
catalog reader validates the hint and refuses it on a backend entry.
addonpkg catalog writes the fields. The new catalog-tools subcommand adds
them to an existing index from addons//manifest.json, and leaves
every digest and artifact as it was.
The UI reads the fields through the same helper as the installed view and
shows "Adds N tools" when N is above zero.
Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto
Co-authored-by: Ettore Di Giacinto
---
.../react-ui/public/locales/de/addons.json | 2 +
.../react-ui/public/locales/en/addons.json | 2 +
.../react-ui/public/locales/es/addons.json | 2 +
.../react-ui/public/locales/fr/addons.json | 2 +
.../react-ui/public/locales/id/addons.json | 2 +
.../react-ui/public/locales/it/addons.json | 2 +
.../react-ui/public/locales/ko/addons.json | 2 +
.../react-ui/public/locales/pt-BR/addons.json | 2 +
.../react-ui/public/locales/zh-CN/addons.json | 2 +
.../src/components/addons/AddonBits.jsx | 17 +++-
.../src/components/addons/BrowseView.jsx | 6 +-
.../src/components/addons/FirstRun.jsx | 4 +-
.../src/components/addons/InstallDialog.jsx | 3 +-
.../src/components/addons/addonState.js | 8 ++
core/http/react-ui/src/utils/addonsApi.js | 3 +
core/http/react-ui/tests/unit/addons.test.mjs | 32 ++++++-
core/services/addons/catalog/backend_entry.go | 2 +-
core/services/addons/catalog/catalog.go | 28 ++++++
.../addons/catalog/index_yaml_test.go | 49 ++++++++++
core/services/addons/catalog/sources.go | 6 +-
docs/content/features/addons.md | 8 ++
gallery/addons/README.md | 11 +++
gallery/addons/index.yaml | 36 +++++++
pkg/addons/packaging/catalog.go | 96 ++++++++++++++++++-
pkg/addons/packaging/packaging_test.go | 62 ++++++++++++
pkg/addons/packaging/run.go | 25 ++---
26 files changed, 390 insertions(+), 24 deletions(-)
diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json
index c840f1096..fb7b613cc 100644
--- a/core/http/react-ui/public/locales/de/addons.json
+++ b/core/http/react-ui/public/locales/de/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Fügt eine Seite hinzu",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json
index 27e17d0c2..453a02575 100644
--- a/core/http/react-ui/public/locales/en/addons.json
+++ b/core/http/react-ui/public/locales/en/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Adds a page",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json
index a88124c7e..f418a8bfa 100644
--- a/core/http/react-ui/public/locales/es/addons.json
+++ b/core/http/react-ui/public/locales/es/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Añade una página",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json
index 33f4d828d..5b67cd50f 100644
--- a/core/http/react-ui/public/locales/fr/addons.json
+++ b/core/http/react-ui/public/locales/fr/addons.json
@@ -629,6 +629,8 @@
"addsPage": "Ajoute une page",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json
index e42492047..2be63fa16 100644
--- a/core/http/react-ui/public/locales/id/addons.json
+++ b/core/http/react-ui/public/locales/id/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Menambah satu halaman",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json
index f31a146ea..5b2236dd8 100644
--- a/core/http/react-ui/public/locales/it/addons.json
+++ b/core/http/react-ui/public/locales/it/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Aggiunge una pagina",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json
index 61965e907..45930827b 100644
--- a/core/http/react-ui/public/locales/ko/addons.json
+++ b/core/http/react-ui/public/locales/ko/addons.json
@@ -625,6 +625,8 @@
"addsPage": "페이지를 추가합니다",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json
index 2f187eb8d..af59df8c5 100644
--- a/core/http/react-ui/public/locales/pt-BR/addons.json
+++ b/core/http/react-ui/public/locales/pt-BR/addons.json
@@ -625,6 +625,8 @@
"addsPage": "Adiciona uma página",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json
index 8b21f8da8..0fe146c1f 100644
--- a/core/http/react-ui/public/locales/zh-CN/addons.json
+++ b/core/http/react-ui/public/locales/zh-CN/addons.json
@@ -625,6 +625,8 @@
"addsPage": "添加一个页面",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
+ "addsTools_one": "Adds {{count}} tool",
+ "addsTools_other": "Adds {{count}} tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
diff --git a/core/http/react-ui/src/components/addons/AddonBits.jsx b/core/http/react-ui/src/components/addons/AddonBits.jsx
index 08b2added..570861095 100644
--- a/core/http/react-ui/src/components/addons/AddonBits.jsx
+++ b/core/http/react-ui/src/components/addons/AddonBits.jsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
-import { stateChip } from './addonState'
+import { addedByEntry, stateChip } from './addonState'
import { installSteps } from './installSteps'
import './addons.css'
@@ -44,6 +44,21 @@ export function AppIcon({ src, size = 40 }) {
)
}
+// What a library entry adds, before it is installed: "Adds a page" when it has
+// a main page, "Adds N tools" when it declares chat tools. It shows nothing
+// for an entry that says neither.
+export function EntryAdds({ entry }) {
+ const { t } = useTranslation('addons')
+ const adds = addedByEntry(entry)
+ if (!adds.page && adds.tools === 0) return null
+ return (
+
+ {adds.page && {t('card.addsPage')} }
+ {adds.tools > 0 && {t('card.addsTools', { count: adds.tools })} }
+
+ )
+}
+
// A small fact chip: platform, signature policy and so on.
export function Fact({ icon, children, tone }) {
return (
diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx
index 5ae8ad25e..341708139 100644
--- a/core/http/react-ui/src/components/addons/BrowseView.jsx
+++ b/core/http/react-ui/src/components/addons/BrowseView.jsx
@@ -8,6 +8,7 @@ import { paramsToQuery, queryToParams } from '../catalog/catalogState'
import { AppIcon, Fact, UnknownNotice } from './AddonBits'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
+import { addedByEntry } from './addonState'
import { updateView } from './updateState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
@@ -51,7 +52,7 @@ export default function BrowseView({ api, adapter }) {
const flow = actions.flows[`flow:${entry.key}`]
const pending = Boolean(actions.unknown[entry.id])
const working = ['install', 'turn-on'].includes(flow?.step)
- const addsPage = Boolean(entry.ui && entry.ui.mainPage)
+ const adds = addedByEntry(entry)
const off = have && have.enabled === false
return (
@@ -67,7 +68,8 @@ export default function BrowseView({ api, adapter }) {
{entry.description && {entry.description}
}
- {addsPage && {t('card.addsPage')} }
+ {adds.page && {t('card.addsPage')} }
+ {adds.tools > 0 && {t('card.addsTools', { count: adds.tools })} }
{have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })} }
{have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })} }
diff --git a/core/http/react-ui/src/components/addons/FirstRun.jsx b/core/http/react-ui/src/components/addons/FirstRun.jsx
index 843e71ec7..52b4d064e 100644
--- a/core/http/react-ui/src/components/addons/FirstRun.jsx
+++ b/core/http/react-ui/src/components/addons/FirstRun.jsx
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import LoadingSpinner from '../LoadingSpinner'
-import { AppIcon } from './AddonBits'
+import { AppIcon, EntryAdds } from './AddonBits'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
import { featuredEntries } from './addonState'
@@ -48,7 +48,7 @@ export default function FirstRun({ adapter, actions }) {
{entry.description && {entry.description}
}
- {entry.ui && entry.ui.mainPage && {t('card.addsPage')}
}
+
actions.installAndTurnOn(entry, { skipInstall: flowOf(entry).failedStep === 'turn-on' }) : undefined} />
setConfirm(entry)}>
diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx
index 06d24abc6..e4ef68317 100644
--- a/core/http/react-ui/src/components/addons/InstallDialog.jsx
+++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { pushLayer } from '../../utils/modalLayers'
-import { UnknownNotice } from './AddonBits'
+import { EntryAdds, UnknownNotice } from './AddonBits'
import { shortDigest } from './addonState'
import { describeOutcome } from './errors'
@@ -67,6 +67,7 @@ export default function InstallDialog({ entry, flow, actions, onClose }) {
{step === 'ask' && (
<>
{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}
+
{t('trust.digest')} {shortDigest(entry.digest)}
{t('trust.verification')} {entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js
index 553932dce..b1edf67e3 100644
--- a/core/http/react-ui/src/components/addons/addonState.js
+++ b/core/http/react-ui/src/components/addons/addonState.js
@@ -102,6 +102,14 @@ export function toolSummary(app, limit = 4) {
return { shown: tools.slice(0, limit), more: Math.max(0, count - Math.min(limit, tools.length)), count }
}
+// What a library entry says the add-on will add, before it is installed. It
+// reads the same fields as an installed add-on (toolCount, tools), so the
+// count goes through the same code path. The page comes from ui.mainPage; the
+// tools are the chat tools of the manifest, never its admin tools.
+export function addedByEntry(entry) {
+ return addedByAddon({ hasUI: Boolean(entry && entry.ui && entry.ui.mainPage), toolCount: entry?.toolCount, tools: entry?.tools })
+}
+
// What an add-on adds to LocalAI, from the facts the list already carries: a
// page (an add-on with a UI) and chat tools (the tools it offers the model).
// Nothing here is guessed: no field, no line.
diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js
index 364fb3816..31128ff97 100644
--- a/core/http/react-ui/src/utils/addonsApi.js
+++ b/core/http/react-ui/src/utils/addonsApi.js
@@ -260,6 +260,9 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
platform: { os: release.os, arch: release.arch },
policy: release.policy || { requireSignature: false },
ui: release.ui || null,
+ // What the add-on brings to chat, with the names of the installed view.
+ toolCount: Number.isFinite(release.toolCount) ? release.toolCount : 0,
+ tools: Array.isArray(release.tools) ? release.tools : [],
})
return {
kind: 'addons',
diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs
index 1b6267fea..529a02adc 100644
--- a/core/http/react-ui/tests/unit/addons.test.mjs
+++ b/core/http/react-ui/tests/unit/addons.test.mjs
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
+import i18next from 'i18next'
import {
normalizeQuery, queryKey, queryToParams, paramsToQuery, totalPages, queryToOffset,
createOperationBook, newOperationId, classifyResponse, pollDelay, slugify,
@@ -10,7 +11,7 @@ import {
} from '../../src/components/catalog/catalogState.js'
import {
resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary,
- shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, screenshotsOf, listState, featuredEntries,
+ shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, addedByEntry, screenshotsOf, listState, featuredEntries,
} from '../../src/components/addons/addonState.js'
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
import { runLifecycle, settleOperation } from '../../src/components/addons/lifecycleFlow.js'
@@ -531,3 +532,32 @@ test('install and turn on stops at a failed install and retries only the turn on
assert.deepEqual(calls, ['enable:tables:0'])
assert.deepEqual(marks.map(m => m.step), ['turn-on', 'done'])
})
+
+test('a library entry shows its tool count only when it is above zero, from the installed-view fields', () => {
+ assert.deepEqual(addedByEntry({ ui: { mainPage: 'databases' }, toolCount: 16, tools: ['a', 'b'] }), { page: true, tools: 16, toolNames: ['a', 'b'], toolsMore: 14 })
+ assert.equal(addedByEntry({ ui: { mainPage: 'jobs' }, toolCount: 0, tools: [] }).tools, 0)
+ assert.equal(addedByEntry({ ui: { mainPage: 'jobs' } }).tools, 0)
+ assert.equal(addedByEntry({ ui: null, toolCount: 3, tools: ['a', 'b', 'c'] }).page, false)
+ assert.equal(addedByEntry(null).tools, 0)
+ // A count that is not a number is no count.
+ assert.equal(addedByEntry({ toolCount: 'many', tools: [] }).tools, 0)
+})
+
+test('the catalog adapter carries toolCount and tools of a release, and defaults them to none', async () => {
+ const release = (extra) => ({ id: 'tables', name: 'Tables', version: '1.0.0', sourceId: 'main', ui: { mainPage: 'databases' }, ...extra })
+ const adapterFor = (releases) => createAddonsCatalogAdapter({ dispatch: async () => ({ releases, total: releases.length }) })
+ const { entries } = await adapterFor([release({ toolCount: 16, tools: ['resource-exists'] }), release({ id: 'voice' })]).search({})
+ assert.equal(entries[0].toolCount, 16)
+ assert.deepEqual(entries[0].tools, ['resource-exists'])
+ assert.equal(entries[1].toolCount, 0)
+ assert.deepEqual(entries[1].tools, [])
+ assert.equal(addedByEntry(entries[1]).tools, 0)
+})
+
+test('the English wording is "Adds 16 tools" and "Adds 1 tool"', async () => {
+ const en = JSON.parse(readFileSync(join(localesDir, 'en', 'addons.json'), 'utf8'))
+ const i18n = i18next.createInstance()
+ await i18n.init({ lng: 'en', resources: { en: { addons: en } }, ns: ['addons'], defaultNS: 'addons', interpolation: { escapeValue: false } })
+ assert.equal(i18n.t('card.addsTools', { count: 16 }), 'Adds 16 tools')
+ assert.equal(i18n.t('card.addsTools', { count: 1 }), 'Adds 1 tool')
+})
diff --git a/core/services/addons/catalog/backend_entry.go b/core/services/addons/catalog/backend_entry.go
index aac4a943c..c70ba0605 100644
--- a/core/services/addons/catalog/backend_entry.go
+++ b/core/services/addons/catalog/backend_entry.go
@@ -161,7 +161,7 @@ var ErrInvalidEntry = errors.New("library entry is not valid for this host")
// will keep what it writes. The loopback allowance is a per-source setting and
// is off here.
func ValidateEntry(r Release) error {
- if !validFetchedRelease(r, false) || !validKindFields(r) || !validEntryUI(r.UI) || !validRequirements(r.Requires) {
+ if !validFetchedRelease(r, false) || !validKindFields(r) || !validEntryUI(r.UI) || !validRequirements(r.Requires) || !validEntryTools(r) {
return fmt.Errorf("%w: %s %q", ErrInvalidEntry, r.EntryKind(), r.ID)
}
return nil
diff --git a/core/services/addons/catalog/catalog.go b/core/services/addons/catalog/catalog.go
index c4ea7bc88..60ad0619f 100644
--- a/core/services/addons/catalog/catalog.go
+++ b/core/services/addons/catalog/catalog.go
@@ -31,6 +31,27 @@ type VerificationPolicy struct {
IdentityRegex string `json:"identityRegex,omitempty" yaml:"identityRegex,omitempty"`
}
+// MaxEntryTools bounds the tool names of one entry, as apps.MaxTools bounds
+// them in the installed view.
+const MaxEntryTools = 32
+
+// validEntryTools applies to the tool hint the rules of the manifest: names
+// are tool names, there are at most MaxEntryTools of them, and the count is the
+// full number, so it is never below the names listed.
+func validEntryTools(r Release) bool {
+ if r.ToolCount < 0 || len(r.Tools) > MaxEntryTools || len(r.Tools) > r.ToolCount || (r.ToolCount > len(r.Tools) && len(r.Tools) != MaxEntryTools) {
+ return false
+ }
+ seen := map[string]bool{}
+ for _, t := range r.Tools {
+ if !contract.ValidName(t) || seen[t] {
+ return false
+ }
+ seen[t] = true
+ }
+ return true
+}
+
// EntryUI is optional display metadata a catalog may offer for an entry. It is
// a hint for browsing before install, never authority: the manifest inside the
// verified package decides what an installed addon shows and can open. Every
@@ -98,6 +119,13 @@ type Release struct {
UI EntryUI `json:"ui,omitzero"`
// Requires is the optional compatibility hint of the entry.
Requires Requirements `json:"requires,omitzero"`
+ // ToolCount and Tools say what the add-on brings to chat before it is
+ // installed. They use the names of the installed view (apps.App). Tools are
+ // the chat tools of the manifest (never its admin tools), at most
+ // MaxEntryTools; ToolCount is the full number. Like UI they are a hint for
+ // browsing, never authority: the manifest in the verified package decides.
+ ToolCount int `json:"toolCount,omitempty"`
+ Tools []string `json:"tools,omitempty"`
// The fields below describe a backend and are refused on any other kind.
// They carry what the backend gallery carries beyond the image, so the
diff --git a/core/services/addons/catalog/index_yaml_test.go b/core/services/addons/catalog/index_yaml_test.go
index 58c7d307d..589db2718 100644
--- a/core/services/addons/catalog/index_yaml_test.go
+++ b/core/services/addons/catalog/index_yaml_test.go
@@ -2,6 +2,7 @@
package catalog
import (
+ "encoding/json"
"fmt"
"net/http"
"strings"
@@ -110,4 +111,52 @@ var _ = Describe("library index document (YAML)", func() {
_, e, _ = refreshBody(yamlEntry + "# " + strings.Repeat("x", MaxIndexBytes) + "\n")
Expect(e).To(MatchError(ErrRefresh))
})
+ Context("tool hint", func() {
+ It("carries toolCount and tools from the index to the listing", func() {
+ body := yamlEntry + " toolCount: 2\n tools:\n - resource-exists\n - tables-create\n"
+ _, e, s := refreshBody(body)
+ Expect(e).NotTo(HaveOccurred())
+ page, e := (Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}).Search(ctx, Query{})
+ Expect(e).NotTo(HaveOccurred())
+ Expect(page.Releases).To(HaveLen(1))
+ Expect(page.Releases[0].ToolCount).To(Equal(2))
+ Expect(page.Releases[0].Tools).To(Equal([]string{"resource-exists", "tables-create"}))
+ out, e := json.Marshal(page.Releases[0])
+ Expect(e).NotTo(HaveOccurred())
+ Expect(string(out)).To(ContainSubstring(`"toolCount":2`))
+ Expect(string(out)).To(ContainSubstring(`"tools":["resource-exists","tables-create"]`))
+ })
+ It("leaves both fields out for an entry without chat tools", func() {
+ _, e, s := refreshBody(yamlEntry)
+ Expect(e).NotTo(HaveOccurred())
+ page, e := (Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}).Search(ctx, Query{})
+ Expect(e).NotTo(HaveOccurred())
+ out, _ := json.Marshal(page.Releases[0])
+ Expect(string(out)).NotTo(ContainSubstring("toolCount"))
+ Expect(string(out)).NotTo(ContainSubstring(`"tools"`))
+ })
+ It("keeps a count above the listed names when the names are capped", func() {
+ var names strings.Builder
+ for i := 0; i < MaxEntryTools; i++ {
+ fmt.Fprintf(&names, " - tool-%c%c\n", 'a'+i/26, 'a'+i%26)
+ }
+ _, e, s := refreshBody(yamlEntry + " toolCount: 40\n tools:\n" + names.String())
+ Expect(e).NotTo(HaveOccurred())
+ page, _ := (Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}).Search(ctx, Query{})
+ Expect(page.Releases[0].ToolCount).To(Equal(40))
+ Expect(page.Releases[0].Tools).To(HaveLen(MaxEntryTools))
+ })
+ It("refuses a malformed hint", func() {
+ for name, extra := range map[string]string{
+ "negative count": " toolCount: -1\n",
+ "names above count": " toolCount: 1\n tools:\n - aa\n - bb\n",
+ "count above short list": " toolCount: 5\n tools:\n - aa\n",
+ "bad name": " toolCount: 1\n tools:\n - Bad_Name\n",
+ "duplicate name": " toolCount: 2\n tools:\n - aa\n - aa\n",
+ } {
+ _, e, _ := refreshBody(yamlEntry + extra)
+ Expect(e).To(MatchError(ErrRefresh), name)
+ }
+ })
+ })
})
diff --git a/core/services/addons/catalog/sources.go b/core/services/addons/catalog/sources.go
index 269eea4b2..100e6916e 100644
--- a/core/services/addons/catalog/sources.go
+++ b/core/services/addons/catalog/sources.go
@@ -552,7 +552,7 @@ func (s *Sources) fetch(ctx context.Context, src Source) ([]Release, IndexResult
seen := map[string]bool{}
for i := range releases {
r := &releases[i]
- if !validFetchedRelease(*r, src.AllowLoopbackHTTP) || !validKindFields(*r) || !validEntryUI(r.UI) || !validRequirements(r.Requires) {
+ if !validFetchedRelease(*r, src.AllowLoopbackHTTP) || !validKindFields(*r) || !validEntryUI(r.UI) || !validRequirements(r.Requires) || !validEntryTools(*r) {
return nil, IndexResult{}, ErrRefresh
}
r.Selection = ""
@@ -594,9 +594,9 @@ func validKindFields(r Release) bool {
if r.IsMeta() {
// A meta backend has no image, so it has no UI path or requirements
// either; its alias follows the same rule as any backend.
- return r.UI == (EntryUI{}) && r.Requires == (Requirements{}) && (r.Alias == "" || (backendNamePattern.MatchString(r.Alias) && !strings.Contains(r.Alias, "..")))
+ return r.UI == (EntryUI{}) && r.Requires == (Requirements{}) && r.ToolCount == 0 && len(r.Tools) == 0 && (r.Alias == "" || (backendNamePattern.MatchString(r.Alias) && !strings.Contains(r.Alias, "..")))
}
- if !strings.HasPrefix(r.Artifact, "oci://") || r.UI.MainPage != "" || r.UI.Icon != "" || r.UI.LaunchMode != "" || r.Requires != (Requirements{}) {
+ if !strings.HasPrefix(r.Artifact, "oci://") || r.UI.MainPage != "" || r.UI.Icon != "" || r.UI.LaunchMode != "" || r.Requires != (Requirements{}) || r.ToolCount != 0 || len(r.Tools) > 0 {
return false
}
return r.Alias == "" || (backendNamePattern.MatchString(r.Alias) && !strings.Contains(r.Alias, ".."))
diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md
index 0523f76ed..db6c78aa3 100644
--- a/docs/content/features/addons.md
+++ b/docs/content/features/addons.md
@@ -969,6 +969,14 @@ launch mode, and a catalog that breaks them fails refresh as a whole. They only
help browsing before install: the manifest inside the verified package is what an
installed addon shows, and the host serves an icon only after install.
+A release may also carry `toolCount` and `tools`, the same names the installed view
+uses: the chat tools the package declares in `capabilities.tools` (admin tools are
+not chat tools and are not counted). `tools` holds at most 32 names and `toolCount`
+is the full number. The add-on cards, the Find add-ons tab and the details panel show
+"Adds N tools" from them before install. `addonpkg catalog` writes both fields, and
+`addonpkg catalog-tools` adds them to an existing index from the manifests in
+`addons//manifest.json`. They are a hint like `ui`, and only an add-on entry may carry them.
+
Refresh accepts at most 1 MiB and 1,000 releases. Duplicate release identities
within a source fail. Duplicate names across sources remain distinct. Search
matches names, IDs and descriptions, sorts by source/release identity and uses
diff --git a/gallery/addons/README.md b/gallery/addons/README.md
index 58572b623..e83594ff5 100644
--- a/gallery/addons/README.md
+++ b/gallery/addons/README.md
@@ -50,6 +50,17 @@ The command fails when the repository is empty or a backend image repository, wh
channel is not a channel alias, when a platform of the matrix has no image, or when the
template holds an unknown placeholder or weakens the policy.
+Every entry also carries `toolCount` and `tools`: the chat tools the package declares in
+`capabilities.tools` (admin tools are not counted), at most 32 names, and the full count.
+An add-on without chat tools has neither field. To add them to an existing index without a
+merged layout, read the manifests of the repository:
+
+```sh
+go run ./scripts/addons/cmd/addonpkg catalog-tools --index gallery/addons/index.yaml --addons addons
+```
+
+It changes no other field, so no digest or artifact moves.
+
## Placeholders
`{{addon}}`, `{{version}}`, `{{tag}}`, `{{channel}}`, `{{repository}}`, `{{digest}}`,
diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml
index c518b1786..5bcd1bcec 100644
--- a/gallery/addons/index.yaml
+++ b/gallery/addons/index.yaml
@@ -24,6 +24,16 @@
description: "Train an adapter on your own data, then export it as a model you can chat with."
mainPage: "jobs"
icon: "ui/icon.png"
+ toolCount: 8
+ tools:
+ - "resource-exists"
+ - "fine-tune-list"
+ - "fine-tune-start"
+ - "fine-tune-dataset"
+ - "fine-tune-status"
+ - "fine-tune-checkpoints"
+ - "fine-tune-stop"
+ - "fine-tune-export"
- id: "quantization"
name: Quantization
description: "Shrink a model to a smaller quantized file, then import it so you can chat with it."
@@ -42,6 +52,14 @@
description: "Shrink a model to a smaller quantized file, then import it so you can chat with it."
mainPage: "jobs"
icon: "ui/icon.png"
+ toolCount: 6
+ tools:
+ - "resource-exists"
+ - "quantize-list"
+ - "quantize-start"
+ - "quantize-status"
+ - "quantize-stop"
+ - "quantize-import"
- id: "voice"
name: Voice
description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them."
@@ -132,3 +150,21 @@
description: "Databases of tables you own or share: typed rows, filters, schema and sharing."
mainPage: "databases"
icon: "ui/icon.png"
+ toolCount: 16
+ tools:
+ - "resource-exists"
+ - "databases-create"
+ - "databases-rename"
+ - "databases-inspect"
+ - "tables-create"
+ - "tables-rename"
+ - "tables-drop"
+ - "columns-add"
+ - "columns-rename"
+ - "columns-drop"
+ - "rows-query"
+ - "rows-aggregate"
+ - "rows-export"
+ - "rows-insert"
+ - "rows-update"
+ - "rows-delete"
diff --git a/pkg/addons/packaging/catalog.go b/pkg/addons/packaging/catalog.go
index ef265cc95..a942a8f6d 100644
--- a/pkg/addons/packaging/catalog.go
+++ b/pkg/addons/packaging/catalog.go
@@ -14,6 +14,7 @@ import (
"strings"
"github.com/google/go-containerregistry/pkg/v1"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
"gopkg.in/yaml.v3"
)
@@ -38,7 +39,12 @@ type catalogRelease struct {
Issuer string `json:"issuer"`
Identity string `json:"identity"`
} `json:"policy"`
- UI struct {
+ // ToolCount and Tools repeat the names of the installed view: the chat
+ // tools the package declares (capabilities.tools, never adminTools). Tools
+ // is capped at MaxEntryTools; ToolCount is the full number.
+ ToolCount int `json:"toolCount,omitempty"`
+ Tools []string `json:"tools,omitempty"`
+ UI struct {
Title string `json:"title"`
Description string `json:"description"`
MainPage string `json:"mainPage"`
@@ -59,6 +65,93 @@ func signerIdentity(ownerRepo, ref string) (string, error) {
return "https://github.com/" + ownerRepo + "/.github/workflows/" + SignerWorkflow + "@" + ref, nil
}
+// MaxEntryTools bounds the tool names of one catalog entry. It is the bound
+// the installed view uses (apps.MaxTools); the count stays the full number.
+const MaxEntryTools = 32
+
+// toolFieldsYAML renders the toolCount and tools fields of a catalog entry
+// from the chat tools of a manifest. An add-on without chat tools has neither
+// field. Admin tools are not passed in: they are not chat tools.
+func toolFieldsYAML(tools []string, indent string) string {
+ if len(tools) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "%stoolCount: %d\n%stools:\n", indent, len(tools), indent)
+ for _, t := range tools[:min(len(tools), MaxEntryTools)] {
+ b.WriteString(indent + " - \"" + jsonString(t) + "\"\n")
+ }
+ return b.String()
+}
+
+// cmdCatalogTools adds the toolCount and tools fields to the entries of an
+// existing library index, reading each manifest from addons//manifest.json
+// of the repository. It touches no other field, so the digests and artifacts
+// stay as they are. Running it twice gives the same file.
+func cmdCatalogTools(args []string) error {
+ fs := flag.NewFlagSet("catalog-tools", flag.ContinueOnError)
+ index := fs.String("index", "gallery/addons/index.yaml", "library index to update")
+ addons := fs.String("addons", "addons", "directory that holds addons//manifest.json")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ raw, err := os.ReadFile(*index)
+ if err != nil {
+ return err
+ }
+ entries, err := readCatalogIndex(raw)
+ if err != nil {
+ return err
+ }
+ var blocks []string
+ for _, line := range strings.SplitAfter(string(raw), "\n") {
+ if strings.HasPrefix(line, "- ") || len(blocks) == 0 {
+ blocks = append(blocks, "")
+ }
+ blocks[len(blocks)-1] += line
+ }
+ // blocks[0] is the header comment when the file starts with one.
+ if len(blocks) == len(entries)+1 {
+ blocks = blocks[1:]
+ blocks[0] = string(raw[:len(raw)-len(strings.Join(blocks, ""))]) + blocks[0]
+ }
+ if len(blocks) != len(entries) {
+ return errors.New("index entries do not match their blocks")
+ }
+ for i, e := range entries {
+ data, err := os.ReadFile(filepath.Join(*addons, filepath.Base(e.ID), "manifest.json"))
+ if err != nil {
+ return err
+ }
+ m, err := contract.ParseManifest(data)
+ if err != nil {
+ return fmt.Errorf("manifest of %s: %w", e.ID, err)
+ }
+ var kept []string
+ skip := false
+ for _, line := range strings.SplitAfter(strings.TrimRight(blocks[i], "\n"), "\n") {
+ switch {
+ case strings.HasPrefix(line, " toolCount:"):
+ continue
+ case strings.HasPrefix(line, " tools:"):
+ skip = true
+ continue
+ case skip && strings.HasPrefix(line, " - "):
+ continue
+ }
+ skip = false
+ kept = append(kept, line)
+ }
+ text := strings.TrimRight(strings.Join(kept, ""), "\n") + "\n"
+ blocks[i] = text + toolFieldsYAML(m.Capabilities.Tools, " ")
+ }
+ out := strings.Join(blocks, "")
+ if _, err := readCatalogIndex([]byte(out)); err != nil {
+ return fmt.Errorf("updated index: %w", err)
+ }
+ return os.WriteFile(*index, []byte(out), 0o600)
+}
+
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
@@ -167,6 +260,7 @@ func cmdCatalog(args []string) error {
"title": title, "description": description, "main_page": mainPage, "icon": icon,
}
text := string(tmpl)
+ text = strings.TrimRight(text, "\n") + "\n" + toolFieldsYAML(v.Manifest.Capabilities.Tools, "")
if mutable {
text = strings.Replace(text, "digest: \"{{digest}}\"", "mutable: true", 1)
}
diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go
index 778003018..17de3f47f 100644
--- a/pkg/addons/packaging/packaging_test.go
+++ b/pkg/addons/packaging/packaging_test.go
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"github.com/google/go-containerregistry/pkg/name"
@@ -906,6 +907,67 @@ var _ = Describe("catalog template", func() {
}
}
})
+ It("reports the chat tools of the manifest on each entry and not the admin tools", func() {
+ counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0}
+ for id, want := range counts {
+ p := newProjectFor(id, both)
+ m := p.merged("1.2.3", rev, nil)
+ out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
+ _, err := run("catalog", "--config", p.config, "--template", "../../../gallery/addons/"+id+".yaml.tmpl", "--merged", m, "--repository", newRegistry().repo("addons"), "--tag", "1.2.3", "--channel", "latest", "--owner-repo", "example/localai", "--signer-ref", "refs/tags/v1.2.3", "--out", out)
+ Expect(err).NotTo(HaveOccurred(), id)
+ b, err := os.ReadFile(out)
+ Expect(err).NotTo(HaveOccurred())
+ entries, err := readCatalogIndex(b)
+ Expect(err).NotTo(HaveOccurred(), id)
+ Expect(entries).To(HaveLen(2), id)
+ for _, e := range entries {
+ Expect(e.ToolCount).To(Equal(want), id)
+ Expect(e.Tools).To(HaveLen(want), id)
+ }
+ if want == 0 {
+ Expect(string(b)).NotTo(ContainSubstring("toolCount"))
+ }
+ if id == "tables" {
+ // The admin tool is declared next to the chat tools and is not one of them.
+ Expect(entries[0].Tools).NotTo(ContainElement("databases-delete"))
+ Expect(entries[0].Tools).To(ContainElement("databases-create"))
+ }
+ }
+ })
+ It("caps the tool names at MaxEntryTools and keeps the full count", func() {
+ var tools []string
+ for i := 0; i < MaxEntryTools+8; i++ {
+ tools = append(tools, fmt.Sprintf("tool-%c%c", 'a'+i/26, 'a'+i%26))
+ }
+ text := toolFieldsYAML(tools, "")
+ Expect(text).To(HavePrefix(fmt.Sprintf("toolCount: %d\n", MaxEntryTools+8)))
+ Expect(strings.Count(text, "\n - ")).To(Equal(MaxEntryTools))
+ Expect(toolFieldsYAML(nil, "")).To(BeEmpty())
+ })
+ It("adds the tool fields to an existing index from the repository manifests, and changes nothing else", func() {
+ raw, err := os.ReadFile("../../../gallery/addons/index.yaml")
+ Expect(err).NotTo(HaveOccurred())
+ stripped := regexp.MustCompile(`(?m)^ toolCount:.*\n tools:\n( - .*\n)+`).ReplaceAll(raw, nil)
+ Expect(string(stripped)).NotTo(ContainSubstring("toolCount"))
+ index := filepath.Join(GinkgoT().TempDir(), "index.yaml")
+ Expect(os.WriteFile(index, stripped, 0o600)).To(Succeed())
+ _, err = run("catalog-tools", "--index", index, "--addons", "../../../addons")
+ Expect(err).NotTo(HaveOccurred())
+ again, err := os.ReadFile(index)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(again)).To(Equal(string(raw)), "the committed index is what the generator writes")
+ _, err = run("catalog-tools", "--index", index, "--addons", "../../../addons")
+ Expect(err).NotTo(HaveOccurred())
+ twice, _ := os.ReadFile(index)
+ Expect(string(twice)).To(Equal(string(again)))
+ entries, err := readCatalogIndex(again)
+ Expect(err).NotTo(HaveOccurred())
+ got := map[string]int{}
+ for _, e := range entries {
+ got[e.ID] = e.ToolCount
+ }
+ Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0}))
+ })
It("refuses missing inputs, backend repositories, bad channels and unknown placeholders", func() {
reg := newRegistry()
p := newProject(both)
diff --git a/pkg/addons/packaging/run.go b/pkg/addons/packaging/run.go
index 1df0a5bf0..0037d3865 100644
--- a/pkg/addons/packaging/run.go
+++ b/pkg/addons/packaging/run.go
@@ -18,18 +18,19 @@ import (
var stdout io.Writer = os.Stdout
var commands = map[string]func(args []string) error{
- "matrix": cmdMatrix,
- "channel": cmdChannel,
- "build": cmdBuild,
- "verify": cmdVerify,
- "extract": cmdExtract,
- "native-check": cmdNativeCheck,
- "merge": cmdMerge,
- "publish": cmdPublish,
- "promote": cmdPromote,
- "target": cmdTarget,
- "catalog": cmdCatalog,
- "policy": cmdPolicy,
+ "matrix": cmdMatrix,
+ "channel": cmdChannel,
+ "build": cmdBuild,
+ "verify": cmdVerify,
+ "extract": cmdExtract,
+ "native-check": cmdNativeCheck,
+ "merge": cmdMerge,
+ "publish": cmdPublish,
+ "promote": cmdPromote,
+ "target": cmdTarget,
+ "catalog": cmdCatalog,
+ "catalog-tools": cmdCatalogTools,
+ "policy": cmdPolicy,
}
// Run executes one subcommand of the addonpkg command line.