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.