mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
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/<id>/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 <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
9dc8386801
commit
6e3222922e
26 files changed
+390
-24
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 (
|
||||
<p className="ad-tile__adds" data-testid="entry-adds-line">
|
||||
{adds.page && <span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span>}
|
||||
{adds.tools > 0 && <span className="ad-tile__add" data-testid="entry-tools"><Icon name="wrench" aria-hidden="true" /> {t('card.addsTools', { count: adds.tools })}</span>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// A small fact chip: platform, signature policy and so on.
|
||||
export function Fact({ icon, children, tone }) {
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<article className="ad-entry" aria-labelledby={`entry-${entry.key}`}>
|
||||
@@ -67,7 +68,8 @@ export default function BrowseView({ api, adapter }) {
|
||||
</p>
|
||||
{entry.description && <p className="ad-entry__text">{entry.description}</p>}
|
||||
<div className="ad-facts">
|
||||
{addsPage && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></Fact>}
|
||||
{adds.page && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></Fact>}
|
||||
{adds.tools > 0 && <Fact icon="wrench"><span data-testid="entry-tools">{t('card.addsTools', { count: adds.tools })}</span></Fact>}
|
||||
{have && <Fact icon="check-circle" tone="ok">{have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}</Fact>}
|
||||
{have && updateView(have.update).state === 'update-available' && <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: updateView(have.update).version })}</Fact>}
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
{entry.description && <p className="ad-card__text">{entry.description}</p>}
|
||||
{entry.ui && entry.ui.mainPage && <p className="ad-tile__adds"><span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span></p>}
|
||||
<EntryAdds entry={entry} />
|
||||
<FlowStatus flow={confirm && confirm.key === entry.key ? undefined : flowOf(entry)} name={entry.title} onRetry={flowOf(entry)?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flowOf(entry).failedStep === 'turn-on' }) : undefined} />
|
||||
<div className="ad-card__acts">
|
||||
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={busy(entry)} onClick={() => setConfirm(entry)}>
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
<p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>
|
||||
<EntryAdds entry={entry} />
|
||||
<dl className="dk-kv">
|
||||
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
|
||||
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, ".."))
|
||||
|
||||
@@ -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/<id>/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
|
||||
|
||||
@@ -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}}`,
|
||||
|
||||
@@ -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"
|
||||
@@ -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/<id>/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/<id>/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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-12
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user