Files
LocalAI/core/http/routes/studio.go
T
Ettore Di Giacinto 24c837e453 feat(studio): grant built-in capabilities to a saved agent
A saved agent now has a Builtins list: the capabilities its owner gave
it (memory.write, profile.write, skills.write, pages.write,
dashboard.write, triggers.propose, triggers.run, talkback.channels,
talkback.new_channel, files.upload, act.files, act.addons, act.send,
act.spend and the opt-in group saved_agents). The list is empty by
default, and nothing turns a grant on for a template, an import or a
write that omits the field.

A chat of the agent holds the grants the user may use at the turn
(agent scope). A tool exists when the grant is held, the chat did not
switch its group off and the run can hold rights: a sub-agent or a run
that started by itself never does. propose_change and revert_change
now exist only for a chat that holds the grant of at least one kind,
and the schema lists only those kinds.

propose_change gains the kinds skill, page and dashboard. A skill is a
SKILL.md for the personal or the agent layer, and a skill with
locked: true is refused, also as a shadow of a locked personal skill.
A page is a set of files checked against the Pages rules and written
as one change set. The dashboard is a home.json checked against its
schema. All three are re-checked when the owner confirms or edits.

GET /api/chats/agents/builtin-grants lists each grant with its title,
description and sensitive flag. The agent record, the write body and
the export file carry builtins; an old file imports as before and an
unknown name is dropped in an import and refused in a write.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
2026-11-03 14:56:42 +00:00

289 lines
14 KiB
Go

package routes
import (
"context"
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
studioendpoint "github.com/mudler/LocalAI/core/http/endpoints/studio"
"github.com/mudler/LocalAI/core/schema"
studiosvc "github.com/mudler/LocalAI/core/services/studio"
)
// NewStudioModelChecker says whether a user may start a conversation with a
// model: it must be a model this instance has (an alias counts when its target
// resolves), and the user's model allowlist, when they have one, must include
// the name they chose. The allowlist is matched against that name, an alias
// or not, exactly as the request middleware does. The chat route checks the allowlist again
// on every request, so this only gives an early, clear answer.
func NewStudioModelChecker(app *application.Application) studioendpoint.ModelChecker {
return studioendpoint.ModelCheckerFunc(func(_ context.Context, user *auth.User, model string) error {
if err := checkStudioModelResolves(app.ModelConfigLoader(), model); err != nil {
return err
}
if db := app.AuthDB(); db != nil && user != nil && !auth.IsModelAllowed(db, user, model) {
return studioendpoint.ErrModelNotAllowed
}
return nil
})
}
// checkStudioModelResolves rejects a model with no config and an alias that
// points nowhere, so a broken alias is refused when it is saved and not when
// the first message fails.
func checkStudioModelResolves(cl *config.ModelConfigLoader, model string) error {
cfg, ok := cl.GetModelConfig(model)
if !ok {
return studioendpoint.ErrUnknownModel
}
if _, _, err := cl.ResolveAlias(&cfg); err != nil {
return studioendpoint.ErrUnknownModel
}
return nil
}
// RegisterStudioRoutes registers the chats API. Without a service (the chats
// are switched off or could not start) every /api/chats, /api/workspace and
// /api/agent path answers 404 with the reason, so a client sees "off" and not a missing route. Authentication and
// the feature gates come from the global middleware and the route-feature
// registry, so no route carries its own; the administrator routes take adminMw.
func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioendpoint.ModelChecker, adminMw echo.MiddlewareFunc) {
if svc == nil {
off := func(c echo.Context) error {
return c.JSON(http.StatusNotFound, schema.ErrorResponse{Error: &schema.APIError{
Message: "the chats are turned off on this server", Code: http.StatusNotFound, Type: "chats_off"}})
}
e.Any("/api/chats", off)
e.Any("/api/chats/*", off)
e.Any("/api/workspace/*", off)
// The jobs API of the saved agents is part of the chats too.
e.Any("/api/agent/*", off)
return
}
h := studioendpoint.New(svc, models)
const base = "/api/chats"
// The files routes come before the "/:id" ones; "files" is not a chat id.
// The administrator's view of every user's agents, triggers and connectors.
// "admin" is not a chat id, and the static segment wins over "/:id".
e.GET(base+"/admin/agents", h.AdminListAgents, adminMw)
e.DELETE(base+"/admin/agents/:id", h.AdminDeleteAgent, adminMw)
e.GET(base+"/admin/triggers", h.AdminListTriggers, adminMw)
e.GET(base+"/admin/triggers/:id/runs", h.AdminTriggerRuns, adminMw)
e.POST(base+"/admin/triggers/:id/disable", h.AdminDisableTrigger, adminMw)
e.DELETE(base+"/admin/triggers/:id", h.AdminDeleteTrigger, adminMw)
e.GET(base+"/admin/connectors", h.AdminListConnectors, adminMw)
e.POST(base+"/admin/web/test", h.AdminTestWebSearch, adminMw)
e.POST(base+"/admin/connectors/:id/disable", h.AdminDisableConnector, adminMw)
e.DELETE(base+"/admin/connectors/:id", h.AdminDeleteConnector, adminMw)
// The shared folders and who may use them. "restore" and "events" are not
// folder ids; the static segments win over "/:id".
e.GET(base+"/admin/shared-folders", h.AdminListSharedFolders, adminMw)
e.POST(base+"/admin/shared-folders", h.AdminCreateSharedFolder, adminMw)
e.POST(base+"/admin/shared-folders/restore", h.AdminRestoreSharedFolder, adminMw)
e.GET(base+"/admin/shared-folders/events", h.AdminSharedFolderEvents, adminMw)
// The built-in chat tools (Settings > Tools) and the audit of the commands
// the built-in bash ran on this machine.
e.GET(base+"/admin/tools", h.AdminBuiltinTools, adminMw)
e.GET(base+"/admin/host-exec/events", h.AdminHostExecEvents, adminMw)
// The network of the Sandbox commands, and its audit.
e.GET(base+"/admin/egress", h.AdminEgress, adminMw)
e.GET(base+"/admin/egress/events", h.AdminEgressEvents, adminMw)
e.GET(base+"/admin/shared-folders/:id", h.AdminGetSharedFolder, adminMw)
e.PATCH(base+"/admin/shared-folders/:id", h.AdminRenameSharedFolder, adminMw)
e.DELETE(base+"/admin/shared-folders/:id", h.AdminDeleteSharedFolder, adminMw)
e.PUT(base+"/admin/shared-folders/:id/members/:principal", h.AdminSetSharedFolderMember, adminMw)
e.DELETE(base+"/admin/shared-folders/:id/members/:principal", h.AdminRemoveSharedFolderMember, adminMw)
// The agent tasks and jobs keep the paths they had on master. They are
// gated by the mcp_jobs feature in the route-feature registry.
const agent = "/api/agent"
e.POST(agent+"/tasks", h.CreateTask)
e.GET(agent+"/tasks", h.ListTasks)
e.GET(agent+"/tasks/:id", h.GetTask)
e.PUT(agent+"/tasks/:id", h.UpdateTask)
e.DELETE(agent+"/tasks/:id", h.DeleteTask)
e.POST(agent+"/tasks/:name/execute", h.ExecuteTaskByName)
e.POST(agent+"/jobs/execute", h.ExecuteJob)
e.GET(agent+"/jobs", h.ListJobs)
e.GET(agent+"/jobs/:id", h.GetJob)
e.POST(agent+"/jobs/:id/cancel", h.CancelJob)
e.POST(agent+"/jobs/:id/rerun", h.RerunJob)
e.DELETE(agent+"/jobs/:id", h.DeleteJob)
e.GET(base+"/files", h.ListFiles)
e.DELETE(base+"/files", h.DeleteFile)
e.GET(base+"/files/content", h.GetFileContent)
e.GET(base+"/files/preview", h.PreviewFile)
e.PUT(base+"/files/content", h.PutFileContent)
e.POST(base+"/files/folders", h.CreateFolder)
e.POST(base+"/files/move", h.MoveFile)
e.GET(base+"/files/trash", h.Trash)
e.DELETE(base+"/files/trash", h.EmptyTrash)
e.DELETE(base+"/files/trash/:rev", h.PurgeTrashEntry)
e.POST(base+"/files/undo", h.UndoFiles)
e.GET(base+"/files/history", h.FileHistory)
e.GET(base+"/wellknown/:name", h.GetWellKnown)
e.PUT(base+"/wellknown/:name", h.PutWellKnown)
e.POST(base+"/wellknown/:name/revert", h.RevertWellKnown)
e.GET(base+"/wellknown/:name/schema", h.WellKnownSchema)
e.GET(base+"/wellknown/:name/versions", h.WellKnownVersions)
e.GET(base+"/skills", h.ListSkills)
e.POST(base+"/skills", h.CreateSkill)
e.GET(base+"/skills/:layer/:name", h.GetSkill)
e.PUT(base+"/skills/:layer/:name", h.UpdateSkill)
e.DELETE(base+"/skills/:layer/:name", h.DeleteSkill)
// What a chat that does not exist yet could switch; "draft" is not a chat id.
e.GET(base+"/draft/tools", h.DraftTools)
e.GET(base+"/draft/addons", h.DraftAddons)
e.PUT(base+"/:id/skills", h.SetChatSkills)
e.GET(base+"/:id/addons", h.ListChatAddons)
e.PUT(base+"/:id/addons", h.SetChatAddons)
e.POST(base+"/:id/host-exec/consent", h.HostExecConsent)
e.GET(base+"/:id/egress", h.GetEgress)
e.PUT(base+"/:id/egress", h.PutEgress)
e.DELETE(base+"/:id/egress/rules/:host/:port", h.DeleteEgressRule)
e.POST(base+"/:id/egress/consent", h.EgressConsent)
e.GET(base+"/files/search", h.SearchFiles)
// The pages of the workspace: the chats feature lets the request in, and
// the handlers check the pages feature too.
e.GET(base+"/pages", h.ListPages)
e.GET(base+"/pages/:name", h.GetPage)
e.GET(base+"/pages/:name/files", h.ListPageFiles)
e.GET(base+"/pages/:name/file", h.ReadPageFile)
e.PUT(base+"/pages/:name/file", h.WritePageFile)
e.POST(base+"/pages/:name/chat", h.SendFromPage)
// The pages of the caller's shared folders, by folder id and name, and the
// caller's consent to the version that runs.
// The folders an administrator shared with the caller.
e.GET(base+"/shared-folders", h.ListMySharedFolders)
e.GET(base+"/shared-folders/:id", h.GetMySharedFolder)
e.GET(base+"/shared-pages", h.ListSharedPages)
e.GET(base+"/shared-pages/:folder/:name", h.GetSharedPage)
e.PUT(base+"/shared-pages/:folder/:name/consent", h.AllowSharedPage)
e.DELETE(base+"/shared-pages/:folder/:name/consent", h.RevokeSharedPage)
e.GET(base+"/shared-pages/:folder/:name/files", h.ListSharedPageFiles)
e.GET(base+"/shared-pages/:folder/:name/file", h.ReadSharedPageFile)
e.PUT(base+"/shared-pages/:folder/:name/file", h.WriteSharedPageFile)
e.POST(base+"/shared-pages/:folder/:name/chat", h.SendFromSharedPage)
e.GET(base+"/page-starters", h.ListPageStarters)
e.POST(base+"/page-starters/:id/copy", h.CopyPageStarter)
e.GET(base+"/conversion", h.Conversion)
e.POST(base+"/import/browser", h.ImportBrowserChats)
e.GET(base+"/notifications", h.ListNotifications)
e.DELETE(base+"/notifications", h.DeleteNotifications)
e.GET(base+"/notifications/count", h.CountNotifications)
e.POST(base+"/notifications/read", h.ReadNotifications)
e.GET(base+"/notifications/events", h.NotificationEvents)
// The change feed of the user's files belongs to the chats feature: the
// files are the chats' workspace.
e.GET("/api/workspace/events", h.WorkspaceEvents)
e.GET(base+"/context-files", h.ListContextFiles)
e.GET(base+"/changes", h.ListChanges)
e.GET(base+"/changes/audit", h.ChangeAudit)
e.GET(base+"/changes/:id", h.GetChange)
e.POST(base+"/changes/:id/confirm", h.ConfirmChange)
e.POST(base+"/changes/:id/dismiss", h.DismissChange)
e.POST(base+"/changes/:id/undo", h.UndoChange)
e.GET(base+"/triggers", h.ListTriggers)
e.POST(base+"/triggers", h.CreateTrigger)
e.GET(base+"/triggers/preview", h.PreviewTrigger)
e.GET(base+"/triggers/proposals", h.ListTriggerProposals)
e.GET(base+"/triggers/proposals/:id", h.GetTriggerProposal)
e.POST(base+"/triggers/proposals/:id/undo", h.UndoTriggerProposal)
e.POST(base+"/triggers/proposals/:id/reopen", h.ReopenTriggerProposal)
e.POST(base+"/triggers/proposals/:id/confirm", h.ConfirmTriggerProposal)
e.PUT(base+"/triggers/proposals/:id", h.EditTriggerProposal)
e.DELETE(base+"/triggers/proposals/:id", h.DismissTriggerProposal)
e.GET(base+"/triggers/changes/:id", h.GetTriggerChange)
e.POST(base+"/triggers/changes/:id/apply", h.ApplyTriggerChange)
e.POST(base+"/triggers/changes/:id/undo", h.UndoTriggerChange)
e.DELETE(base+"/triggers/changes/:id", h.DismissTriggerChange)
e.GET(base+"/triggers/:id", h.GetTrigger)
e.PUT(base+"/triggers/:id", h.UpdateTrigger)
e.DELETE(base+"/triggers/:id", h.DeleteTrigger)
e.POST(base+"/triggers/:id/run", h.RunTrigger)
e.POST(base+"/triggers/:id/enable", h.EnableTrigger)
e.POST(base+"/triggers/:id/disable", h.DisableTrigger)
e.GET(base+"/triggers/:id/runs", h.TriggerRuns)
e.GET(base+"/push/key", h.PushKey)
e.POST(base+"/push/subscribe", h.PushSubscribe)
e.DELETE(base+"/push/subscribe", h.PushUnsubscribe)
e.GET(base+"/connectors", h.ListConnectors)
e.GET(base+"/connectors/kinds", h.ConnectorKinds)
e.POST(base+"/connectors", h.CreateConnector)
e.GET(base+"/connectors/:id", h.GetConnector)
e.PUT(base+"/connectors/:id", h.UpdateConnector)
e.DELETE(base+"/connectors/:id", h.DeleteConnector)
e.POST(base+"/connectors/:id/enable", h.EnableConnector)
e.POST(base+"/connectors/:id/disable", h.DisableConnector)
e.POST(base+"/connectors/:id/test", h.TestConnector)
e.GET(base+"/connectors/:id/senders", h.ConnectorSenders)
e.POST(base+"/connectors/:id/senders/:sender/allow", h.AllowConnectorSender)
e.GET(base+"/connectors/:id/access", h.ConnectorAccess)
e.POST(base+"/connectors/:id/pairing", h.StartConnectorPairing)
e.DELETE(base+"/connectors/:id/paired/:sender", h.RemoveConnectorPaired)
e.GET(base+"/mcp-connectors", h.ListMCPConnectors)
e.POST(base+"/mcp-connectors", h.CreateMCPConnector)
e.GET(base+"/mcp-connectors/:id", h.GetMCPConnector)
e.PUT(base+"/mcp-connectors/:id", h.UpdateMCPConnector)
e.DELETE(base+"/mcp-connectors/:id", h.DeleteMCPConnector)
e.POST(base+"/mcp-connectors/:id/enable", h.EnableMCPConnector)
e.POST(base+"/mcp-connectors/:id/disable", h.DisableMCPConnector)
e.POST(base+"/mcp-connectors/:id/test", h.TestMCPConnector)
e.GET(base+"/:id/tools", h.ListChatTools)
e.PUT(base+"/:id/mcp", h.SetChatMCP)
e.PUT(base+"/:id/builtins", h.SetChatBuiltins)
e.GET(base+"/:id/mcp/prompts", h.MCPPrompts)
e.POST(base+"/:id/mcp/prompts", h.MCPExpandPrompt)
e.GET(base+"/:id/mcp/resources", h.MCPResources)
e.POST(base+"/:id/mcp/resources/read", h.MCPReadResource)
e.GET(base+"/:id/mcp-apps/resource", h.GetMCPAppResource)
e.POST(base+"/:id/mcp-apps/call", h.CallMCPAppTool)
e.GET(base+"/agents/templates", h.AgentTemplates)
e.GET(base+"/agents/builtin-grants", h.BuiltinGrants)
e.POST(base+"/agents/templates/:id/create", h.CreateAgentFromTemplate)
e.POST(base+"/agents/import", h.ImportAgent)
e.GET(base+"/agents/:id/export", h.ExportAgent)
e.GET(base+"/agents/:id/runs", h.AgentRuns)
e.GET(base+"/agents", h.ListAgents)
e.POST(base+"/agents", h.CreateAgent)
e.GET(base+"/agents/:id", h.GetAgent)
e.PUT(base+"/agents/:id", h.UpdateAgent)
e.DELETE(base+"/agents/:id", h.DeleteAgent)
e.POST(base, h.Create)
e.GET(base, h.List)
e.GET(base+"/search", h.Search)
e.POST(base+"/import", h.Import)
e.GET(base+"/:id", h.Get)
e.PATCH(base+"/:id", h.Update)
e.POST(base+"/:id/regenerate", h.Regenerate)
e.POST(base+"/:id/messages/:seq/edit", h.EditMessage)
e.POST(base+"/:id/fork", h.Fork)
e.GET(base+"/:id/export", h.Export)
e.GET(base+"/:id/files", h.ChatFiles)
e.GET(base+"/:id/agents", h.SubAgents)
e.GET(base+"/:id/agents/:agent_id/messages", h.SubAgentMessages)
e.POST(base+"/:id/agents/:agent_id/stop", h.StopSubAgent)
e.DELETE(base+"/:id", h.Delete)
e.GET(base+"/:id/messages", h.Messages)
e.POST(base+"/:id/messages", h.Send)
e.PATCH(base+"/:id/pending/:pid", h.EditPending)
e.DELETE(base+"/:id/pending/:pid", h.CancelPending)
e.POST(base+"/:id/send-now", h.SendNow)
e.POST(base+"/:id/stop", h.Stop)
e.GET(base+"/:id/goal", h.GetGoal)
e.PUT(base+"/:id/goal", h.PutGoal)
e.DELETE(base+"/:id/goal", h.DeleteGoal)
e.POST(base+"/:id/goal/resume", h.ResumeGoal)
e.GET(base+"/:id/loops", h.ListLoops)
e.POST(base+"/:id/loops", h.StartLoop)
e.DELETE(base+"/:id/loops", h.StopLoops)
e.PUT(base+"/:id/loops/:loopId", h.SetLoopState)
e.DELETE(base+"/:id/loops/:loopId", h.StopLoop)
e.POST(base+"/:id/compact", h.Compact)
e.POST(base+"/:id/clear", h.Clear)
e.GET(base+"/:id/events", h.Events)
}