mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-10 15:52:29 -04:00
* feat(agents): keep the web chat history between messages The agent chat endpoint ran every message as a fresh job (ag.Ask(WithText(message))), so a follow-up such as "add two days to item 3 and recalculate" never saw the answer it referred to. Agents then rebuilt their reply from scratch instead of changing it. Use the agent's own conversation tracker, as the Telegram and Slack connectors already do: send the earlier turns with the new message and record successful answers. Failed, cancelled or empty runs are not recorded, and the tracker drops a conversation after the agent's last_message_duration of inactivity. The distributed (NATS) chat path is unchanged. Assisted-by: Claude:claude-opus-5-5 ginkgo Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de> * fix(agents): keep each web chat conversation's history separate Review on this PR: the first version kept the history in the agent's conversation tracker under one fixed key per agent. The web UI keeps several conversations per agent (New Chat, switching, Clear) and did not send which one a message belongs to, so the hidden history mixed them: a new chat received the previous chat's turns, and Clear only cleared the screen. The client now sends the earlier turns of the conversation it is showing as `history` with POST /api/agents/:name/chat, and the server keeps no web chat history of its own. Each conversation only ever sees its own turns; New Chat and Clear start without history. The server uses only user and assistant turns with text, bounded to the most recent 40 turns and 64,000 characters. `history` is optional, so existing callers keep the previous behaviour (no history); the distributed (NATS) path does not forward it yet. Tests: two conversations of one agent stay apart, an empty history (New Chat or Clear) starts fresh, system/tool/empty turns are dropped, the bounds keep the most recent turns; the UI helper that builds the history from the visible messages has node --test coverage. Docs: features/agents.md describes `history` and the web UI behaviour. Assisted-by: Claude:claude-opus-5-5 ginkgo Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de> --------- Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
494 lines
16 KiB
Go
494 lines
16 KiB
Go
package localai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAGI/core/state"
|
|
coreTypes "github.com/mudler/LocalAGI/core/types"
|
|
agiServices "github.com/mudler/LocalAGI/services"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/agentpool"
|
|
"github.com/mudler/LocalAI/core/services/agents"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// getUserID extracts the scoped user ID from the request context.
|
|
// Returns empty string when auth is not active (backward compat).
|
|
func getUserID(c echo.Context) string {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return ""
|
|
}
|
|
return user.ID
|
|
}
|
|
|
|
// decodedParam returns the named path parameter, URL-decoding it.
|
|
//
|
|
// Echo routes a request via URL.RawPath whenever the path contains
|
|
// percent-encoded characters (e.g. %3A for ':'), and in that case stores the
|
|
// matched path-param value raw/escaped. Agent and collection names carry a
|
|
// "legacy-api-key:" prefix, so the ':' arrives as %3A and the raw param no
|
|
// longer matches the stored name. Callers must unescape before lookups.
|
|
// Falls back to the raw value if it isn't valid percent-encoding.
|
|
func decodedParam(c echo.Context, name string) string {
|
|
raw := c.Param(name)
|
|
if decoded, err := url.PathUnescape(raw); err == nil {
|
|
return decoded
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// isAdminUser returns true if the authenticated user has admin role.
|
|
func isAdminUser(c echo.Context) bool {
|
|
user := auth.GetUser(c)
|
|
return user != nil && user.Role == auth.RoleAdmin
|
|
}
|
|
|
|
// wantsAllUsers returns true if the request has ?all_users=true and the user is admin.
|
|
func wantsAllUsers(c echo.Context) bool {
|
|
return c.QueryParam("all_users") == "true" && isAdminUser(c)
|
|
}
|
|
|
|
// effectiveUserID returns the user ID to scope operations to.
|
|
// SECURITY: Only admins and agent-worker service accounts may supply
|
|
// ?user_id=<id> to operate on another user's resources. Agent-worker users are
|
|
// created exclusively server-side during node registration and need to access
|
|
// collections on behalf of the user whose agent they are executing.
|
|
// Regular callers always get their own ID regardless of query params.
|
|
func effectiveUserID(c echo.Context) string {
|
|
if targetUID := c.QueryParam("user_id"); targetUID != "" && canImpersonateUser(c) {
|
|
if callerID := getUserID(c); callerID != targetUID {
|
|
xlog.Info("User impersonation", "caller", callerID, "target", targetUID, "path", c.Path())
|
|
}
|
|
return targetUID
|
|
}
|
|
return getUserID(c)
|
|
}
|
|
|
|
// canImpersonateUser returns true if the caller is allowed to use ?user_id= to
|
|
// scope operations to another user. Allowed for admins and agent-worker service
|
|
// accounts (ProviderAgentWorker is set server-side during node registration and
|
|
// cannot be self-assigned).
|
|
func canImpersonateUser(c echo.Context) bool {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return false
|
|
}
|
|
return user.Role == auth.RoleAdmin || user.Provider == auth.ProviderAgentWorker
|
|
}
|
|
|
|
func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
statuses := svc.ListAgentsForUser(userID)
|
|
agents := slices.Sorted(maps.Keys(statuses))
|
|
resp := map[string]any{
|
|
"agents": agents,
|
|
"agentCount": len(agents),
|
|
"actions": len(agiServices.AvailableActions),
|
|
"connectors": len(agiServices.AvailableConnectors),
|
|
"statuses": statuses,
|
|
}
|
|
if hubURL := svc.AgentHubURL(); hubURL != "" {
|
|
resp["agent_hub_url"] = hubURL
|
|
}
|
|
|
|
// Admin cross-user aggregation
|
|
if wantsAllUsers(c) {
|
|
grouped := svc.ListAllAgentsGrouped()
|
|
userGroups := map[string]any{}
|
|
for uid, agentList := range grouped {
|
|
if uid == userID || uid == "" {
|
|
continue
|
|
}
|
|
userGroups[uid] = map[string]any{"agents": agentList}
|
|
}
|
|
if len(userGroups) > 0 {
|
|
resp["user_groups"] = userGroups
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
var cfg state.AgentConfig
|
|
if err := c.Bind(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.CreateAgentForUser(userID, &cfg); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
|
|
statuses := svc.ListAgentsForUser(userID)
|
|
active, exists := statuses[name]
|
|
if !exists {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"active": active})
|
|
}
|
|
}
|
|
|
|
func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
var cfg state.AgentConfig
|
|
if err := c.Bind(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.UpdateAgentForUser(userID, name, &cfg); err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
if err := svc.DeleteAgentForUser(userID, name); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
cfg := svc.GetAgentConfigForUser(userID, name)
|
|
if cfg == nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, cfg)
|
|
}
|
|
}
|
|
|
|
func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
if err := svc.PauseAgentForUser(userID, decodedParam(c, "name")); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
if err := svc.ResumeAgentForUser(userID, decodedParam(c, "name")); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
|
|
history := svc.GetAgentStatusForUser(userID, name)
|
|
if history == nil {
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": []string{},
|
|
})
|
|
}
|
|
entries := []string{}
|
|
for i := len(history.Results()) - 1; i >= 0; i-- {
|
|
h := history.Results()[i]
|
|
actionName := ""
|
|
if h.ActionCurrentState.Action != nil {
|
|
actionName = h.ActionCurrentState.Action.Definition().Name.String()
|
|
}
|
|
entries = append(entries, fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s",
|
|
h.Reasoning,
|
|
actionName,
|
|
h.ActionCurrentState.Params,
|
|
h.Result))
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": entries,
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
|
|
history, err := svc.GetAgentObservablesForUser(userID, name)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
if history == nil {
|
|
history = []json.RawMessage{}
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"Name": name,
|
|
"History": history,
|
|
})
|
|
}
|
|
}
|
|
|
|
func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
if err := svc.ClearAgentObservablesForUser(userID, name); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true})
|
|
}
|
|
}
|
|
|
|
func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
var payload struct {
|
|
Message string `json:"message"`
|
|
// History is the earlier turns of the conversation the client is
|
|
// showing; omitted or empty starts a conversation without history.
|
|
History []agentpool.ChatHistoryMessage `json:"history"`
|
|
}
|
|
if err := c.Bind(&payload); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request format"})
|
|
}
|
|
message := strings.TrimSpace(payload.Message)
|
|
if message == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"})
|
|
}
|
|
messageID, err := svc.ChatForUser(userID, name, message, payload.History...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusAccepted, map[string]any{
|
|
"status": "message_received",
|
|
"message_id": messageID,
|
|
})
|
|
}
|
|
}
|
|
|
|
func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
|
|
// Try local SSE manager first
|
|
manager := svc.GetSSEManagerForUser(userID, name)
|
|
if manager != nil {
|
|
return agentpool.HandleSSE(c, manager)
|
|
}
|
|
|
|
// Fall back to distributed EventBridge SSE
|
|
var bridge *agents.EventBridge
|
|
if d := app.Distributed(); d != nil {
|
|
bridge = d.AgentBridge
|
|
}
|
|
if bridge != nil {
|
|
return bridge.HandleSSE(c, name, userID)
|
|
}
|
|
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
|
}
|
|
}
|
|
|
|
func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
return c.JSON(http.StatusOK, svc.GetConfigMetaResult())
|
|
}
|
|
}
|
|
|
|
func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := effectiveUserID(c)
|
|
name := decodedParam(c, "name")
|
|
data, err := svc.ExportAgentForUser(userID, name)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.json", name))
|
|
return c.JSONBlob(http.StatusOK, data)
|
|
}
|
|
}
|
|
|
|
func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
userID := getUserID(c)
|
|
|
|
// Try multipart form file first
|
|
file, err := c.FormFile("file")
|
|
if err == nil {
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to open file"})
|
|
}
|
|
defer src.Close()
|
|
data, err := io.ReadAll(src)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
|
}
|
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// Try JSON body
|
|
var cfg state.AgentConfig
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&cfg); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request: provide a file or JSON body"})
|
|
}
|
|
data, err := json.Marshal(&cfg)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
|
|
func ListActionsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"actions": svc.ListAvailableActions(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetActionDefinitionEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
actionName := c.Param("name")
|
|
|
|
var payload struct {
|
|
Config map[string]string `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
|
|
payload.Config = map[string]string{}
|
|
}
|
|
|
|
def, err := svc.GetActionDefinition(actionName, payload.Config)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, def)
|
|
}
|
|
}
|
|
|
|
func ExecuteActionEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
actionName := c.Param("name")
|
|
|
|
var payload struct {
|
|
Config map[string]string `json:"config"`
|
|
Params coreTypes.ActionParams `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
}
|
|
|
|
result, err := svc.ExecuteAction(c.Request().Context(), actionName, payload.Config, payload.Params)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, result)
|
|
}
|
|
}
|
|
|
|
func AgentFileEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
svc := app.AgentPoolService()
|
|
|
|
requestedPath := c.QueryParam("path")
|
|
if requestedPath == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no file path specified"})
|
|
}
|
|
|
|
// Resolve the real path (follows symlinks, eliminates ..)
|
|
resolved, err := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Determine the allowed outputs directory — scoped to the user when auth is active
|
|
allowedDir := svc.OutputsDir()
|
|
user := auth.GetUser(c)
|
|
if user != nil {
|
|
allowedDir = filepath.Join(allowedDir, user.ID)
|
|
}
|
|
|
|
allowedDirResolved, _ := filepath.EvalSymlinks(filepath.Clean(allowedDir))
|
|
|
|
if utils.InTrustedRoot(resolved, allowedDirResolved) != nil {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
|
}
|
|
|
|
info, err := os.Stat(resolved)
|
|
if err != nil || info.IsDir() {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
return c.File(resolved)
|
|
}
|
|
}
|