// 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 } // List is the response of the apps list. type List struct { Apps []App `json:"apps"` } // 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"` } // 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"` // 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"` // 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"` // 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"` // 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 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) 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 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 and fills the // listing declaration an older manifest implied (see legacyResourceList). 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 } else { r.List = legacyResourceList(m) } return &r }