Files
LocalAI/core/services/addons/apps/apps.go
T
Ettore Di Giacinto 7df9b263ed feat(addons): carry translated text in the manifest and the library
The manifest said its text in English only. An add-on can now translate
what the host shows to people, and the library list shows it before the
add-on is installed.

The title and the description use the language maps of ui.title and
ui.description, which every host already reads. A new optional field,
i18n, translates the connector kinds by language tag. It stays under api
2 and its set of fields is closed: the title and the description of a
declared kind, and only where the English kind has them. Tool
descriptions, schemas and skills are for the model and stay English.
Tags follow the language map rule (at most 16, no English), each string
has the limit of its English field, all translated text has at most
12 000 characters, and markup, control characters and direction
overrides are refused. A host that does not know the field refuses the
manifest, as it refuses any unknown field. The field is part of the
manifest, so the package digest and the signature cover it.

The catalog generator copies the translated title and description of
the manifest into each library entry (ui.titles, ui.descriptions), the
host validates them like the manifest values, and a library search
matches the English and the translated text. The apps list and the
connector kinds return the text of every language and, when the request
names a language (lang, else Accept-Language), the resolved text beside
the English fields: language, localizedTitle and localizedDescription.
No existing field changes.

Every add-on on master now has its title and description in Italian,
German, Spanish, French, Brazilian Portuguese, Simplified Chinese,
Korean and Indonesian, and the chat connectors carry their kind text in
the same languages. The packaging tests require that, and that a
translation has the fields of the English text it translates. The names
of the add-ons that provide a role are matched in their English title
only: a translation is often an ordinary word of its language.

Assisted-by: Claude:claude-sonnet-5-5 go
2026-11-04 17:46:53 +00:00

535 lines
23 KiB
Go

// SPDX-License-Identifier: MIT
// Package apps builds the Apps view of the addons an owner runs: what a
// caller sees of each installed addon, derived only from the verified package
// manifest, the durable lifecycle intent and the process observation.
package apps
import (
"github.com/mudler/LocalAI/core/services/addons/hostops"
"maps"
"net/url"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Snapshot is one addon as the owner knows it. It is input to Build and never
// leaves the owner.
type Snapshot struct {
AddonID string
Installed bool
Enabled bool
Generation uint64
// State is the supervisor's observation of the process.
State string
Receipt packages.PackageReceipt
// InstalledAt is when the add-on was first installed, RFC 3339 in UTC, or
// "" for a record that predates the field.
InstalledAt string
// UpdatePending is true when the owner's cached update check says an
// update is available for exactly this installed package.
UpdatePending bool
// HostOps are the host operation prefixes the host setting grants this
// add-on. They decide which declared host controls are drawn.
HostOps []string
HostOperations *hostops.RequestSummary
Unavailable bool
// Operation is the lifecycle operation that runs on the add-on now, or nil.
Operation *Operation
}
// Operation names the lifecycle operation that runs on an add-on. The apps
// list reports it so a page can say "upgrading" while the owner works, and the
// list stays readable meanwhile: reading never waits for the operation.
type Operation struct {
// Kind is the action: install, upgrade, enable, disable or uninstall.
Kind string `json:"kind" enums:"install,upgrade,enable,disable,uninstall" example:"upgrade"`
// Phase is the step the owner is in: queued, downloading (fetch and
// verify the package), backing-up, installing, migrating, starting or
// working for a step without a name of its own.
Phase string `json:"phase" example:"downloading"`
// Since is when the operation was accepted, RFC 3339 in UTC. It is absent
// for an operation recorded before the owner kept the time.
Since string `json:"since,omitempty" example:"2026-11-04T17:18:00Z"`
}
// List is the response of the apps list.
type List struct {
Apps []App `json:"apps"`
// Stale is true when the receiving frontend could not reach the owner in
// time and answers with the last list it received from it. The list may be
// out of date; Apps still carries the operations the owner reported then.
Stale bool `json:"stale,omitempty"`
// StaleReason is why the list is stale: owner_busy when the owner did not
// answer within the read deadline, owner_unavailable when it could not be
// reached at all.
StaleReason string `json:"staleReason,omitempty" enums:"owner_busy,owner_unavailable"`
// StaleSince is when the owner last answered, RFC 3339 in UTC.
StaleSince string `json:"staleSince,omitempty"`
}
// StartRequest is the body of the start and stop routes. The generation is
// the one the caller listed: a request for another generation is denied.
type StartRequest struct {
// OperationID names the lifecycle operation. Repeating it never runs the
// change twice and, after an outcome_unknown answer, it is the identifier
// to poll.
OperationID string `json:"operationId" example:"start-notes-1"`
// Generation is the installed generation the caller saw in the list.
Generation uint64 `json:"generation" example:"3"`
}
// Localize returns a copy of the app with the text in the language the
// request negotiates: the explicit language, else the Accept-Language header,
// else none. An app whose manifest translates nothing in that language is
// returned as it is.
func (a App) Localize(explicit, acceptLanguage string) App {
available := map[string]bool{}
for tag := range a.Titles {
available[tag] = true
}
for tag := range a.Descriptions {
available[tag] = true
}
tags := make([]string, 0, len(available))
for tag := range available {
tags = append(tags, tag)
}
tag := contract.NegotiateLanguage(explicit, acceptLanguage, tags)
if tag == "" {
return a
}
pick := func(m map[string]string, fallback string) string {
if got, ok := contract.MatchLanguage(tag, languagesOf(m)); ok && m[got] != "" {
return m[got]
}
return fallback
}
a.Titles, a.Descriptions = maps.Clone(a.Titles), maps.Clone(a.Descriptions)
a.Language = tag
a.LocalizedTitle = pick(a.Titles, a.Title)
a.LocalizedDescription = pick(a.Descriptions, a.Description)
return a
}
func languagesOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for tag := range m {
out = append(out, tag)
}
return out
}
// MaxTools bounds the tool names of one app.
const MaxTools = 32
// App is what a caller sees of one installed addon. Every field comes from
// the verified manifest, the lifecycle record or the process observation. It
// never carries a package location, a source, an addon endpoint or a
// credential.
type App struct {
// ID is the addon identifier and Name its stable machine name. They are
// the same in version one: the receipt records no separate catalog name.
ID string `json:"id" example:"notes"`
Name string `json:"name" example:"notes"`
// Title and Description are the default-language text of the manifest.
// Titles and Descriptions carry every language when the manifest
// localizes the text, keyed by BCP 47 tag; they are absent otherwise.
Title string `json:"title" example:"Notes"`
Titles map[string]string `json:"titles,omitempty"`
Description string `json:"description,omitempty" example:"Keep notes."`
Descriptions map[string]string `json:"descriptions,omitempty"`
// Language, LocalizedTitle and LocalizedDescription are present only when
// the request named a language (the lang query parameter, else the
// Accept-Language header) that the manifest translates. Language is the
// tag that matched. Each localized field falls back to English on its own,
// so a title without a translation is the English title. No other field
// changes: Title and Description stay the default-language text.
Language string `json:"language,omitempty" example:"it"`
LocalizedTitle string `json:"localizedTitle,omitempty" example:"Appunti"`
LocalizedDescription string `json:"localizedDescription,omitempty" example:"Tieni degli appunti."`
// Icon is a same-origin URL of the verified immutable icon of this
// generation. It is absent when the manifest declares none.
Icon string `json:"icon,omitempty" example:"/api/addons/apps/notes/icon?generation=3"`
Version string `json:"version" example:"1.2.3"`
// InstalledAt is when the add-on was first installed, RFC 3339 in UTC. It
// is absent for an add-on installed before the host kept the time. The
// oldest install is the default provider of a tool or skill name that
// several add-ons replace.
InstalledAt string `json:"installedAt,omitempty" example:"2026-10-30T09:15:00Z"`
// Generation is the installed generation. A Start or Stop request must
// carry the value that was listed; a stale value is denied.
Generation uint64 `json:"generation" example:"3"`
// Enabled is the durable desired state. Health is State, a separate
// observation: an enabled app can be failed.
Enabled bool `json:"enabled"`
// State is running, starting, failed or stopped: the observation of the
// process, never an update.
State string `json:"state" enums:"running,starting,failed,stopped" example:"running"`
// Update is update-pending when the owner's cached update check says an
// update is available for the installed package. It is a separate
// observation from State and is absent otherwise: an unchecked, failed
// or up to date add-on has none. The list never reaches a library.
Update string `json:"update,omitempty" enums:"update-pending" example:"update-pending"`
// Operation is the lifecycle operation that runs on the add-on now (an
// upgrade, say). It is absent when none runs. The observation is
// separate from State: the process can run on while the owner stages
// the next generation.
Operation *Operation `json:"operation,omitempty"`
// MainPage is the path the Open action navigates to. It is present only
// for an enabled addon whose manifest declares a main page that is one of
// its pages.
MainPage string `json:"mainPage,omitempty" example:"/app/addons/notes/home"`
// HasUI is true when the addon contributes any UI.
HasUI bool `json:"hasUI"`
LaunchMode string `json:"launchMode" example:"always"`
// Tools are the tool names of the manifest, at most 32. ToolCount is the
// full number. The manifest carries no tool descriptions.
Tools []string `json:"tools"`
ToolCount int `json:"toolCount"`
// Shadows lists the built-in chat tools the add-on replaces (bash, say).
Shadows []string `json:"shadows,omitempty"`
// ToolDefinitions are the model-facing definitions the manifest declares:
// the argument schema and the kind of each tool. The host serves them to
// chat from the verified manifest; the add-on process is never asked.
ToolDefinitions []contract.ToolDefinition `json:"toolDefinitions,omitempty"`
// Credentialed is true when the manifest declares credentials: the tools
// work with a secret that the person keeps in a connector binding. A
// connector chat holds its write tools to contract.ToolPermission.
Credentialed bool `json:"credentialed,omitempty"`
// ResourceArgument and ResourceLabel come from the credential slot: the
// argument that names the resource of a call, and the word for it in the
// lines that say which connection acted. They are not part of the apps API.
ResourceArgument string `json:"-"`
ResourceLabel string `json:"-"`
// Resources is the resource mapping of the manifest, with the listing
// declaration the host acts on. It is not part of the apps API.
Resources *contract.ResourceTools `json:"-"`
// Skills are the skills the package ships, from the verified manifest.
// The owner copies their files into the chats' skill layer.
Skills []contract.SkillDefinition `json:"skills,omitempty"`
// SkillShadows lists the built-in skills the add-on replaces (xlsx, say).
SkillShadows []string `json:"skillShadows,omitempty"`
// ContextFiles are the index files the add-on asks the host to load into
// the prompt of a chat, from the verified manifest. The host reads them as
// the person; the add-on is not called.
ContextFiles []contract.ContextFile `json:"contextFiles,omitempty"`
// UserSettings are the per-person choices the add-on declares, from the
// verified manifest. The host keeps the answers; the add-on is not called.
UserSettings []contract.UserSetting `json:"userSettings,omitempty"`
// UI describes what the shell loads for this app. It is present only for
// an enabled addon with a UI whose installed package has a sha256 digest.
UI *UIView `json:"ui,omitempty"`
// Network is the network policy the add-on declares, present only while
// the host draws its network-policy control (see contract.EntitledControls).
Network *NetworkView `json:"network,omitempty"`
// Admin is present only for administrators.
Admin *AdminView `json:"admin,omitempty"`
}
// NetworkView is the network of the commands an add-on runs, as the host
// names it in the text it shows.
type NetworkView struct {
// Kind is the declared kind of network policy.
Kind string `json:"kind" enums:"egress-check" example:"egress-check"`
// Title names the network in text: the declared title, else the add-on's.
Title string `json:"title" example:"Example provider"`
// Presets are the titles the add-on gives the host's network presets.
Presets []contract.NetworkPreset `json:"presets,omitempty"`
}
// UIView is the registry entry of an addon UI: the same-origin URLs of its
// immutable assets for the installed generation, the SDK versions it accepts
// and the contributions its manifest declares. The page loads nothing from any
// other place, and a URL here stops being served the moment the generation is
// replaced or the addon is disabled.
type UIView struct {
// Entry is the URL of the packaged ES module. Every asset URL names the
// package digest, so a new package is a new URL.
Entry string `json:"entry" example:"/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js"`
// CSS are the URLs of the declared style sheets.
CSS []string `json:"css,omitempty"`
// SDK is the range of host SDK versions the module accepts.
SDK contract.VersionRange `json:"sdk"`
// Pages, Navigation and ChatActions are the identifiers the manifest
// declares. The host decides where each one is shown.
Pages []string `json:"pages,omitempty"`
Navigation []string `json:"navigation,omitempty"`
ChatActions []string `json:"chatActions,omitempty"`
// Actions are the chat actions that declare a placement and a target.
// Slots and Renderers are the other declared contributions. The manifest
// was validated at install: every tool named here is a tool of the addon,
// every slot is a host slot and every icon is a host icon name or the
// packaged icon. The host draws them only while the addon is enabled and
// usable by the caller.
Actions []ChatActionView `json:"actions,omitempty"`
Slots []SlotView `json:"slots,omitempty"`
Renderers []contract.Renderer `json:"renderers,omitempty"`
// HomeCommands contains only resolved navigation metadata for this app.
HomeCommands []HomeCommandView `json:"homeCommands,omitempty"`
// ModelActions contains only declared destinations for this installed UI.
ModelActions []contract.ModelAction `json:"modelActions,omitempty"`
// Controls are the host controls the host draws for the addon: the ones
// its manifest declares that its grants and tools entitle it to now. The
// host draws them with its own components; the addon supplies nothing.
Controls []string `json:"controls,omitempty" enums:"network-policy,tool-providers" example:"network-policy"`
}
// HomeCommandView is a navigation declaration resolved against this installed
// generation. Page is a declared page identifier, not a URL or tool invocation.
type HomeCommandView struct {
ID string `json:"id"`
Alias string `json:"alias,omitempty"`
Page string `json:"page"`
Label string `json:"label"`
Labels map[string]string `json:"labels,omitempty"`
Icon string `json:"icon"`
}
// ChatActionView is a declared chat action as the host draws it.
type ChatActionView struct {
// ID is the manifest identifier of the action.
ID string `json:"id" example:"summarize"`
// Label is the default-language text; Labels carries every language when
// the manifest localizes it.
Label string `json:"label" example:"Summarize"`
Labels map[string]string `json:"labels,omitempty"`
// Icon is a host icon name, or the empty string when the action uses the
// packaged icon of the app (see the icon of the app).
Icon string `json:"icon,omitempty" example:"sparkles"`
// PackagedIcon is true when the action draws the packaged app icon.
PackagedIcon bool `json:"packagedIcon,omitempty"`
// Placement is composer or message.
Placement string `json:"placement" enums:"composer,message" example:"composer"`
// Tool is the own tool the action calls, or Component the exported
// component it opens. Exactly one is set.
Tool string `json:"tool,omitempty" example:"summarize"`
Component string `json:"component,omitempty" example:"SummaryPanel"`
}
// SlotView is a component the manifest places in a host slot.
type SlotView struct {
ID string `json:"id" example:"notes-nav"`
Slot string `json:"slot" enums:"chat.composer.actions,chat.message.actions,app.nav,settings.sections,files.preview" example:"settings.sections"`
Component string `json:"component" example:"NotesSettings"`
// Extensions are set for a files.preview slot: the file extensions it handles.
Extensions []string `json:"extensions,omitempty" example:"docx,xlsx"`
}
// AdminView carries the controls only an administrator is shown. The server
// refuses the matching requests of everyone else; this is a convenience for
// the UI, never the control.
type AdminView struct {
CanStart bool `json:"canStart"`
HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"`
CanStop bool `json:"canStop"`
// ManagementURL opens the management view of the addon.
ManagementURL string `json:"managementUrl" example:"/app/addons?manage=notes"`
}
// Build returns the apps a caller may see, sorted by identifier. An uninstalled
// addon is never listed. A disabled one is listed only for an administrator,
// who can start it; for every other caller it does not exist.
func Build(snaps []Snapshot, admin bool) []App {
out := []App{}
for _, s := range snaps {
if !s.Installed || (!s.Enabled && !admin) {
continue
}
out = append(out, build(s, admin))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func build(s Snapshot, admin bool) App {
m := s.Receipt.Manifest
id := s.AddonID
a := App{ID: id, Name: id, Title: id, Version: m.Version, InstalledAt: s.InstalledAt, Generation: s.Generation, Enabled: s.Enabled, State: state(s), LaunchMode: contract.LaunchModeAlways, Tools: []string{}}
if m.LaunchMode != "" {
a.LaunchMode = m.LaunchMode
}
a.ToolCount = len(m.Capabilities.Tools)
a.Tools = append(a.Tools, m.Capabilities.Tools[:min(a.ToolCount, MaxTools)]...)
a.Shadows = contract.ShadowedTools(m.Capabilities.ToolDefinitions)
for _, d := range m.Capabilities.ToolDefinitions {
d.InputSchema = slices.Clone(d.InputSchema)
a.ToolDefinitions = append(a.ToolDefinitions, d)
}
a.Credentialed = len(m.Capabilities.Credentials) > 0
if a.Credentialed {
a.ResourceArgument, a.ResourceLabel = m.Capabilities.Credentials[0].ResourceArgument, m.Capabilities.Credentials[0].ResourceLabel
}
a.Resources = resourceTools(m)
a.Skills = slices.Clone(m.Capabilities.SkillDefinitions)
a.SkillShadows = contract.ShadowedSkills(m.Capabilities.SkillDefinitions)
a.ContextFiles = slices.Clone(m.Capabilities.ContextFiles)
for _, us := range m.Capabilities.UserSettings {
us.Values = slices.Clone(us.Values)
a.UserSettings = append(a.UserSettings, us)
}
if u := m.Capabilities.UI; u != nil {
a.HasUI = true
if u.Title != nil {
a.Title, a.Titles = u.Title.Default(), u.Title.ByLanguage
}
if u.Description != nil {
a.Description, a.Descriptions = u.Description.Default(), u.Description.ByLanguage
}
if s.Receipt.Icon != nil && s.Receipt.Icon.Path == u.Icon && u.Icon != "" {
a.Icon = "/api/addons/apps/" + url.PathEscape(id) + "/icon?generation=" + strconv.FormatUint(s.Generation, 10)
}
// The receipt came from the owner's journal, but the route is
// re-checked here: an Open action for a page the addon does not
// declare is never produced.
if s.Enabled && !s.Unavailable && u.MainPage != "" && slices.Contains(u.Pages, u.MainPage) {
a.MainPage = "/app/addons/" + url.PathEscape(id) + "/" + url.PathEscape(u.MainPage)
}
if s.Enabled && !s.Unavailable {
a.UI = uiView(id, s.Receipt.Digest, u)
if a.UI != nil {
a.UI.Controls = contract.EntitledControls(m, s.HostOps)
a.Network = networkView(a.Title, m.Capabilities.Network, a.UI.Controls)
}
}
}
if s.UpdatePending {
a.Update = "update-pending"
}
if s.Operation != nil {
op := *s.Operation
a.Operation = &op
}
if admin {
a.Admin = &AdminView{CanStart: !s.Enabled && !s.Unavailable, HostOperations: s.HostOperations, CanStop: s.Enabled, ManagementURL: "/app/addons?manage=" + url.QueryEscape(id)}
}
return a
}
// networkView is the declared network of an add-on whose network-policy
// control the host draws, or nil.
func networkView(title string, n *contract.NetworkCapability, controls []string) *NetworkView {
if n == nil || !slices.Contains(controls, contract.ControlNetworkPolicy) {
return nil
}
if n.Title != "" {
title = n.Title
}
return &NetworkView{Kind: n.Kind, Title: title, Presets: slices.Clone(n.Presets)}
}
// state never claims more than was observed: a disabled addon is stopped, and
// an observation the host does not know is failed.
func state(s Snapshot) string {
if !s.Enabled {
return "stopped"
}
switch s.State {
case "running", "starting", "failed", "stopped":
return s.State
}
return "failed"
}
var packageDigest = regexp.MustCompile(`^sha256:([0-9a-f]{64})$`)
// uiView returns the UI registry entry, or nil when the package digest is not
// a sha256: without it there is no immutable URL to name.
func uiView(id, digest string, u *contract.UI) *UIView {
m := packageDigest.FindStringSubmatch(digest)
if m == nil {
return nil
}
base := "/api/addons/" + url.PathEscape(id) + "/assets/" + m[1] + "/"
asset := func(p string) string {
parts := strings.Split(p, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return base + strings.Join(parts, "/")
}
v := &UIView{Entry: asset(u.Entry), SDK: u.SDK, Pages: slices.Clone(u.Pages), Navigation: slices.Clone(u.Navigation), ChatActions: u.ChatActionIDs()}
v.Actions, v.Slots, v.Renderers = contributions(u)
if contract.ValidateModelActions(u) == nil {
v.ModelActions = slices.Clone(u.ModelActions)
}
for _, command := range u.HomeCommands {
page := command.Page
if page == "" {
page = u.MainPage
}
if page == "" || !slices.Contains(u.Pages, page) {
continue
}
c := HomeCommandView{ID: command.ID, Alias: command.Alias, Page: page, Label: id, Icon: "puzzle"}
if u.Title != nil {
c.Label, c.Labels = u.Title.Default(), maps.Clone(u.Title.ByLanguage)
}
if page != u.MainPage {
c.Label += " — " + page
for language, label := range c.Labels {
c.Labels[language] = label + " — " + page
}
}
if command.Label != nil {
c.Label, c.Labels = command.Label.Default(), maps.Clone(command.Label.ByLanguage)
}
if u.Icon != "" {
c.Icon = asset(u.Icon)
}
if command.Icon != "" {
if command.Icon == u.Icon {
c.Icon = asset(u.Icon)
} else {
c.Icon = command.Icon
}
}
v.HomeCommands = append(v.HomeCommands, c)
}
for _, css := range u.CSS {
v.CSS = append(v.CSS, asset(css))
}
return v
}
// contributions turns the validated declarations into the view the host
// draws. A bare identifier has no placement and is not drawn.
func contributions(u *contract.UI) ([]ChatActionView, []SlotView, []contract.Renderer) {
var actions []ChatActionView
for _, a := range u.ChatActions {
if !a.Declared() || a.Target == nil || a.Label == nil {
continue
}
v := ChatActionView{ID: a.ID, Label: a.Label.Default(), Labels: a.Label.ByLanguage, Placement: a.Placement, Tool: a.Target.Tool, Component: a.Target.Component}
if a.Icon != "" && a.Icon == u.Icon {
v.PackagedIcon = true
} else {
v.Icon = a.Icon
}
actions = append(actions, v)
}
var slots []SlotView
for _, s := range u.Slots {
slots = append(slots, SlotView{ID: s.ID, Slot: s.Slot, Component: s.Component, Extensions: slices.Clone(s.Extensions)})
}
return actions, slots, slices.Clone(u.Renderers)
}
// resourceTools copies the resource mapping of the manifest.
func resourceTools(m contract.Manifest) *contract.ResourceTools {
if m.Capabilities.Resources == nil {
return nil
}
r := *m.Capabilities.Resources
if r.List != nil {
l := *r.List
r.List = &l
}
return &r
}