Files
LocalAI/core/http/routes/studio.go
T
Ettore Di Giacinto 2316e14632 feat(memory): add a Memory On/Off switch and private chats
Memory now has one switch, On or Off, in the Memory tab of the account
settings. Off means nothing from the notes is put in the prompt, the
model has no memory skill, tool or note kind, nothing is proposed or
saved (also by triggers and other runs that start by themselves), and
the model is told in one line that memory is off. The notes stay and can
still be read, exported and deleted.

A chat can be private: the Memory row of its Tools menu is the memory
switch group, stored with the chat. The narrowest level wins: chat,
agent, person, server. A saved agent cannot widen the choice of the
person. The tools list says which level is off (memory.code, blocked_by,
fix); the chat shows a chip and one line, and the agent form shows the
person's switch as a read-only line.

New API: GET and PUT /api/chats/memory for the switch and the save level,
and GET /api/chats/memory/export for a zip of the notes. The Memory tab
gains the switch, the three save levels, Export and Forget everything.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 17:50:26 +00:00

294 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.PUT(base+"/:id/connections", h.SetChatConnection)
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+"/memory", h.GetMemory)
e.PUT(base+"/memory", h.PutMemory)
e.GET(base+"/memory/export", h.ExportMemory)
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/access/preview", h.ConnectorAccessPreview)
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)
}