From d676d629a08d27364e4a58d6c1ffb76f1ab440ef Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:22:15 +0000 Subject: [PATCH] feat(studio): filter the chat list by origin GET /api/chats takes an optional origin parameter: person (chats a person started), automated (every other chat) or one origin kind such as trigger or connector. The limit counts the chats kept, so a flood of chats that a connector or a trigger started cannot push the chats of a person off a page, and the other way round. Without the parameter the list is unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/http/endpoints/studio/studio.go | 24 ++++++++++ core/http/routes/studio_origin_test.go | 66 ++++++++++++++++++++++++++ core/http/routes/studio_test.go | 5 +- core/services/studio/service.go | 10 +++- 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 core/http/routes/studio_origin_test.go diff --git a/core/http/endpoints/studio/studio.go b/core/http/endpoints/studio/studio.go index 33f1474003..e1cf77b8c2 100644 --- a/core/http/endpoints/studio/studio.go +++ b/core/http/endpoints/studio/studio.go @@ -159,6 +159,23 @@ func failFor(c echo.Context, err error) error { }) } +// originFilter turns the origin parameter of the chat list into a test on a +// chat. person keeps the chats a person started (no origin), automated keeps +// every other chat, and any other word keeps the chats of that origin kind. An +// empty value is no filter (nil), so a client that does not send it gets all. +func originFilter(origin string) func(session.ChatRecord) bool { + switch origin { + case "": + return nil + case "person": + return func(r session.ChatRecord) bool { return r.Origin == "" } + case "automated": + return func(r session.ChatRecord) bool { return r.Origin != "" } + default: + return func(r session.ChatRecord) bool { return r.Origin == origin } + } +} + func conversation(rec session.ChatRecord, info store.TurnInfo) schema.ChatsConversation { return schema.ChatsConversation{ ID: rec.ID, Title: rec.Title, Model: rec.Model, Kind: rec.Kind, ParentID: rec.ParentID, @@ -247,6 +264,7 @@ func (h *Handlers) Create(c echo.Context) error { // @Tags chats // @Param limit query int false "most conversations to return (default 50, at most 200)" // @Param kind query string false "with chat, sub-agent runs are left out and the limit counts chats" +// @Param origin query string false "only chats of one origin, with sub-agent runs left out and the limit counting the chats kept: person (a person started it), automated (anything else) or one origin kind (trigger, connector, job, page). Without it, all chats" // @Success 200 {object} schema.ChatsConversationList "Conversations, including sub-agent runs unless kind=chat" // @Router /api/chats [get] func (h *Handlers) List(c echo.Context) error { @@ -256,6 +274,12 @@ func (h *Handlers) List(c echo.Context) error { if c.QueryParam("kind") == "chat" { list = h.svc.ListChats } + // An origin filter reads chats only: a sub-agent run has no origin of its own. + if keep := originFilter(c.QueryParam("origin")); keep != nil { + list = func(ctx context.Context, user string, limit int) ([]session.ChatRecord, error) { + return h.svc.ListChatsWhere(ctx, user, limit, keep) + } + } recs, err := list(c.Request().Context(), user, limit) if err != nil { return failFor(c, err) diff --git a/core/http/routes/studio_origin_test.go b/core/http/routes/studio_origin_test.go new file mode 100644 index 0000000000..31e2333100 --- /dev/null +++ b/core/http/routes/studio_origin_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +//go:build auth + +package routes_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/mudler/LocalAI/core/services/studio/session" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Chats list origin filter", func() { + var s *studioServer + seed := func(user, id, origin, originID string, age int) { + now := time.Now().Add(time.Duration(age) * time.Minute) + Expect(s.store.CreateChat(context.Background(), session.ChatRecord{ + ID: id, Principal: user, Kind: session.KindChat, Title: id, Origin: origin, OriginID: originID, Created: now, Updated: now, + })).To(Succeed()) + } + ids := func(user, query string) []string { + rec := s.do(user, http.MethodGet, "/api/chats?"+query, nil) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + var out []string + for _, c := range decodeJSON(rec)["conversations"].([]any) { + out = append(out, c.(map[string]any)["id"].(string)) + } + return out + } + + BeforeEach(func() { + s = newStudioServer() + // Oldest first: the person's chats are older than the bot's. + seed("alice", "mine-1", "", "", 1) + seed("alice", "mine-2", "", "", 2) + for i := range 5 { + seed("alice", fmt.Sprintf("bot-%d", i), session.OriginConnector, "binding-1", 10+i) + } + seed("alice", "cron-1", session.OriginTrigger, "trigger-1", 20) + seed("bob", "bob-bot", session.OriginConnector, "binding-2", 30) + }) + + It("returns every chat when origin is absent, as old clients expect", func() { + Expect(ids("alice", "kind=chat")).To(HaveLen(8)) + }) + + It("person leaves out chats that started by themselves, and the limit counts the chats kept", func() { + // Seven newer automated chats must not push the two person chats off a page of 2. + Expect(ids("alice", "kind=chat&origin=person&limit=2")).To(Equal([]string{"mine-2", "mine-1"})) + }) + + It("automated returns the others and a kind returns only that kind", func() { + Expect(ids("alice", "origin=automated")).To(HaveLen(6)) + Expect(ids("alice", "origin=trigger")).To(Equal([]string{"cron-1"})) + Expect(ids("alice", "origin=connector&limit=3")).To(HaveLen(3)) + Expect(ids("alice", "origin=job")).To(BeEmpty()) + }) + + It("never shows another user's chats", func() { + Expect(ids("alice", "origin=automated")).NotTo(ContainElement("bob-bot")) + }) +}) diff --git a/core/http/routes/studio_test.go b/core/http/routes/studio_test.go index 8718d03493..9b4e575ce2 100644 --- a/core/http/routes/studio_test.go +++ b/core/http/routes/studio_test.go @@ -27,6 +27,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/loops" "github.com/mudler/LocalAI/core/services/studio/notify" "github.com/mudler/LocalAI/core/services/studio/provider" + "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/store" "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/triggers" @@ -45,6 +46,8 @@ type studioServer struct { notify *notify.Service host *studio.Triggers svc *studio.Service + // store is the chat store behind svc, for specs that seed chats a person did not start. + store session.Store } // studioTriggerOptions lets a spec set the clock or the limits of the trigger @@ -133,7 +136,7 @@ func newStudioServerWith(f *files.Service, mod func(*studio.Deps)) *studioServer DeferCleanup(conns.Close) DeferCleanup(host.Close) - s := &studioServer{e: echo.New(), harness: h, sessions: map[string]string{}, notify: nsvc, host: host, svc: svc} + s := &studioServer{e: echo.New(), harness: h, sessions: map[string]string{}, notify: nsvc, host: host, svc: svc, store: st} for _, id := range []string{"alice", "bob", "carol", "dave", "root"} { role := auth.RoleUser if id == "root" { diff --git a/core/services/studio/service.go b/core/services/studio/service.go index 90d137fed2..923ec8b1de 100644 --- a/core/services/studio/service.go +++ b/core/services/studio/service.go @@ -520,6 +520,14 @@ func (s *Service) List(ctx context.Context, user string, limit int) ([]session.C // limit applies to chats, so a burst of newer runs cannot push older chats out // of the page: the store is read page by page until enough chats are found. func (s *Service) ListChats(ctx context.Context, user string, limit int) ([]session.ChatRecord, error) { + return s.ListChatsWhere(ctx, user, limit, nil) +} + +// ListChatsWhere is ListChats for the chats that keep returns true for (nil +// keeps all). The limit counts the chats kept, so a flood of chats that a +// connector or a trigger started cannot push a person's chats out of a page of +// person-started chats, and the other way round. +func (s *Service) ListChatsWhere(ctx context.Context, user string, limit int, keep func(session.ChatRecord) bool) ([]session.ChatRecord, error) { var out []session.ChatRecord var cur session.ChatCursor for { @@ -528,7 +536,7 @@ func (s *Service) ListChats(ctx context.Context, user string, limit int) ([]sess return nil, err } for _, r := range page { - if r.Kind == session.KindSubAgent { + if r.Kind == session.KindSubAgent || (keep != nil && !keep(r)) { continue } out = append(out, r)