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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:41:13 +00:00
1 parent d0ac371e01
commit d676d629a0
4 files changed
+103 -2

No files matched your search

+24
View File
@@ -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)
+66
View File
@@ -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"))
})
})
+4 -1
View File
@@ -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" {
+9 -1
View File
@@ -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)