mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 13:03:20 -05:00
The provider guard rejects a shipped add-on name in a string literal, and the swagger example of the network title in the apps view named one. Use a neutral example value. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
427 lines
18 KiB
Go
427 lines
18 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
|
|
}
|
|
|
|
// 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"`
|
|
// 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"`
|
|
// 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.Resources = resourceTools(m)
|
|
a.Skills = slices.Clone(m.Capabilities.SkillDefinitions)
|
|
a.SkillShadows = contract.ShadowedSkills(m.Capabilities.SkillDefinitions)
|
|
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
|
|
}
|