Go: implement ?type=filter branch for GET /agents (#18111)

This commit is contained in:
euvre
2026-08-11 23:54:48 -07:00
committed by GitHub
parent 49646da32d
commit 27edad8a76
6 changed files with 282 additions and 18 deletions

View File

@@ -59,6 +59,17 @@ func (dao *CompilationTemplateGroupDAO) ListSaved(ctx context.Context, db *gorm.
return groups, nil
}
// CountSavedByTenant counts the tenant's own valid groups; built-in groups
// (empty tenant_id) are excluded, mirroring the group_count query in Python
// get_owner_filter / get_category_filter.
func (dao *CompilationTemplateGroupDAO) CountSavedByTenant(ctx context.Context, db *gorm.DB, tenantID string) (int64, error) {
var count int64
err := db.WithContext(ctx).Model(&entity.CompilationTemplateGroup{}).
Where("tenant_id = ? AND status = ?", tenantID, string(entity.StatusValid)).
Count(&count).Error
return count, err
}
// GetSaved returns a single valid group for the tenant (or built-in), or nil.
func (dao *CompilationTemplateGroupDAO) GetSaved(ctx context.Context, db *gorm.DB, tenantID, groupID string) (*entity.CompilationTemplateGroup, error) {
var g entity.CompilationTemplateGroup

View File

@@ -410,6 +410,61 @@ func (dao *UserCanvasDAO) ListByTenantIDs(ctx context.Context, db *gorm.DB, owne
return canvases, total, nil
}
// OwnerFilterItem is one row of the owner aggregation backing the
// ?type=filter branch of the agents list endpoint.
type OwnerFilterItem struct {
ID string `gorm:"column:id"`
Label *string `gorm:"column:label"`
Count int64 `gorm:"column:count"`
}
// CategoryFilterItem is one row of the canvas_category aggregation backing
// the ?type=filter branch of the agents list endpoint.
type CategoryFilterItem struct {
ID string `gorm:"column:id"`
Count int64 `gorm:"column:count"`
}
// visibleToUserScope scopes a user_canvas query to rows the user can see:
// team-permission canvases owned by ownerIDs plus everything the user owns.
func visibleToUserScope(db *gorm.DB, ownerIDs []string, userID string) *gorm.DB {
return db.Model(&entity.UserCanvas{}).
Where("user_canvas.user_id IN ?", ownerIDs).
Where(
db.Where("user_canvas.permission = ?", "team").
Or("user_canvas.user_id = ?", userID))
}
// GetOwnerFilter aggregates visible canvases by owner, joining the user
// table for the display label. Mirrors Python
// UserCanvasService.get_owner_filter.
func (dao *UserCanvasDAO) GetOwnerFilter(ctx context.Context, db *gorm.DB, ownerIDs []string, userID string) ([]*OwnerFilterItem, error) {
if len(ownerIDs) == 0 {
return nil, nil
}
var items []*OwnerFilterItem
err := visibleToUserScope(db.WithContext(ctx), ownerIDs, userID).
Select("user_canvas.user_id AS id, user.nickname AS label, COUNT(user_canvas.id) AS count").
Joins("LEFT JOIN user ON user_canvas.user_id = user.id").
Group("user_canvas.user_id, user.nickname").
Scan(&items).Error
return items, err
}
// GetCategoryFilter aggregates visible canvases by canvas_category.
// Mirrors Python UserCanvasService.get_category_filter.
func (dao *UserCanvasDAO) GetCategoryFilter(ctx context.Context, db *gorm.DB, ownerIDs []string, userID string) ([]*CategoryFilterItem, error) {
if len(ownerIDs) == 0 {
return nil, nil
}
var items []*CategoryFilterItem
err := visibleToUserScope(db.WithContext(ctx), ownerIDs, userID).
Select("user_canvas.canvas_category AS id, COUNT(user_canvas.id) AS count").
Group("user_canvas.canvas_category").
Scan(&items).Error
return items, err
}
// ListTags returns tag usage counts across canvases visible to userID.
func (dao *UserCanvasDAO) ListTags(ctx context.Context, db *gorm.DB, ownerIDs []string, userID string, canvasCategory string) (map[string]int, error) {
if len(ownerIDs) == 0 {

View File

@@ -192,3 +192,75 @@ func TestUserCanvasDAOListTagsIncludesPipelineWhenCategoryIsEmpty(t *testing.T)
t.Fatalf("agent-tag count with agent_canvas filter = %d, want 1", counts["agent-tag"])
}
}
func TestUserCanvasDAOOwnerAndCategoryFilters(t *testing.T) {
db := setupUserCanvasTestDB(t)
if err := db.AutoMigrate(&entity.User{}); err != nil {
t.Fatalf("failed to migrate user: %v", err)
}
pushDB(t, db)
ctx := t.Context()
d := NewUserCanvasDAO()
users := []entity.User{
{ID: "user-1", Nickname: "Alice", Email: "alice@example.com"},
{ID: "user-2", Nickname: "Bob", Email: "bob@example.com"},
}
for i := range users {
if err := db.WithContext(ctx).Create(&users[i]).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
}
canvases := []entity.UserCanvas{
{ID: "c1", UserID: "user-1", Permission: "me", CanvasCategory: "agent_canvas"},
{ID: "c2", UserID: "user-1", Permission: "me", CanvasCategory: "dataflow_canvas"},
{ID: "c3", UserID: "user-2", Permission: "team", CanvasCategory: "agent_canvas"},
{ID: "c4", UserID: "user-2", Permission: "me", CanvasCategory: "agent_canvas"},
}
for i := range canvases {
if err := db.WithContext(ctx).Create(&canvases[i]).Error; err != nil {
t.Fatalf("failed to create canvas: %v", err)
}
}
ownerIDs := []string{"user-1", "user-2"}
owners, err := d.GetOwnerFilter(ctx, db, ownerIDs, "user-1")
if err != nil {
t.Fatalf("GetOwnerFilter failed: %v", err)
}
if len(owners) != 2 {
t.Fatalf("owner filter rows = %d, want 2", len(owners))
}
byID := make(map[string]*OwnerFilterItem, len(owners))
for _, o := range owners {
byID[o.ID] = o
}
// user-1 sees both of their own canvases; user-2 only the team one (c4 is "me").
if byID["user-1"].Count != 2 {
t.Fatalf("user-1 count = %d, want 2", byID["user-1"].Count)
}
if byID["user-2"].Count != 1 {
t.Fatalf("user-2 count = %d, want 1", byID["user-2"].Count)
}
if byID["user-1"].Label == nil || *byID["user-1"].Label != "Alice" {
t.Fatalf("user-1 label = %v, want Alice", byID["user-1"].Label)
}
categories, err := d.GetCategoryFilter(ctx, db, ownerIDs, "user-1")
if err != nil {
t.Fatalf("GetCategoryFilter failed: %v", err)
}
catByID := make(map[string]int64, len(categories))
for _, c := range categories {
catByID[c.ID] = c.Count
}
// agent_canvas: c1 (own) + c3 (team); c4 hidden. dataflow_canvas: c2.
if catByID["agent_canvas"] != 2 {
t.Fatalf("agent_canvas count = %d, want 2", catByID["agent_canvas"])
}
if catByID["dataflow_canvas"] != 1 {
t.Fatalf("dataflow_canvas count = %d, want 1", catByID["dataflow_canvas"])
}
}

View File

@@ -187,6 +187,19 @@ func (h *AgentHandler) ListAgents(c *gin.Context) {
return
}
// Filter-aggregation mode: the agents page filter bar fetches
// GET /api/v1/agents?type=filter and expects
// {filter: {owner, canvas_category}, total} instead of a canvas list.
if c.Query("type") == "filter" {
filters, code, err := h.agentService.ListAgentFilters(c.Request.Context(), user.ID)
if err != nil {
common.ResponseWithCodeData(c, code, false, err.Error())
return
}
common.SuccessWithData(c, filters, "success")
return
}
keywords := c.Query("keywords")
canvasCategory := c.Query("canvas_category")
canvasType := c.Query("canvas_type")

View File

@@ -303,12 +303,13 @@ var ErrAgentStorageError = errors.New("agent storage error")
// AgentService agent service
type AgentService struct {
canvasDAO *dao.UserCanvasDAO
canvasTemplateDAO *dao.CanvasTemplateDAO
userDAO *dao.UserDAO
userTenantDAO *dao.UserTenantDAO
versionDAO *dao.UserCanvasVersionDAO
api4ConversationDAO *dao.API4ConversationDAO
canvasDAO *dao.UserCanvasDAO
canvasTemplateDAO *dao.CanvasTemplateDAO
userDAO *dao.UserDAO
userTenantDAO *dao.UserTenantDAO
versionDAO *dao.UserCanvasVersionDAO
api4ConversationDAO *dao.API4ConversationDAO
compilationTemplateGroupDAO *dao.CompilationTemplateGroupDAO
// driver is the per-process runner that drives canvas
// invocations and produces SSE events. V1 persistence is
@@ -375,17 +376,18 @@ func NewAgentServiceWithOptions(
agenttool.SetSandboxClient(agentsandbox.NewManagerClient())
}
return &AgentService{
canvasDAO: dao.NewUserCanvasDAO(),
canvasTemplateDAO: dao.NewCanvasTemplateDAO(),
userDAO: dao.NewUserDAO(),
userTenantDAO: dao.NewUserTenantDAO(),
versionDAO: dao.NewUserCanvasVersionDAO(),
api4ConversationDAO: dao.NewAPI4ConversationDAO(),
runner: canvas.NewRunner(),
activeSessions: make(map[string]*activeAgentRun),
checkpointStore: cp,
stateSerializer: ser,
runTracker: rt,
canvasDAO: dao.NewUserCanvasDAO(),
canvasTemplateDAO: dao.NewCanvasTemplateDAO(),
userDAO: dao.NewUserDAO(),
userTenantDAO: dao.NewUserTenantDAO(),
versionDAO: dao.NewUserCanvasVersionDAO(),
api4ConversationDAO: dao.NewAPI4ConversationDAO(),
compilationTemplateGroupDAO: dao.NewCompilationTemplateGroupDAO(),
runner: canvas.NewRunner(),
activeSessions: make(map[string]*activeAgentRun),
checkpointStore: cp,
stateSerializer: ser,
runTracker: rt,
}
}
@@ -421,6 +423,114 @@ type ListAgentsResponse struct {
Total int64 `json:"total"`
}
// CompilationTemplateGroupCategory is the synthetic canvas_category the
// frontend uses to filter compilation template groups through the merged
// /agents endpoint. Mirrors Python _COMPILATION_TEMPLATE_GROUP_CATEGORY.
const CompilationTemplateGroupCategory = "compilation_template_group"
// AgentOwnerFilter is one owner option in the agents filter response.
type AgentOwnerFilter struct {
ID string `json:"id"`
Label string `json:"label"`
Count int64 `json:"count"`
}
// AgentCategoryFilter is one canvas_category option in the agents filter
// response.
type AgentCategoryFilter struct {
ID string `json:"id"`
Count int64 `json:"count"`
}
// AgentFiltersResponse is the response body for
// GET /api/v1/agents?type=filter.
type AgentFiltersResponse struct {
Filter struct {
Owner []AgentOwnerFilter `json:"owner"`
CanvasCategory []AgentCategoryFilter `json:"canvas_category"`
} `json:"filter"`
Total int64 `json:"total"`
}
// ListAgentFilters returns the owner/category aggregations backing the
// agents page filter bar. Mirrors the ?type=filter branch of Python
// agent_api.list_agents.
func (s *AgentService) ListAgentFilters(ctx context.Context, userID string) (*AgentFiltersResponse, common.ErrorCode, error) {
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
if err != nil {
return nil, common.CodeServerError, fmt.Errorf("failed to get tenant IDs: %w", err)
}
ownerIDs := make([]string, 0, len(tenantIDs)+1)
seen := make(map[string]struct{}, len(tenantIDs)+1)
seen[userID] = struct{}{}
ownerIDs = append(ownerIDs, userID)
for _, id := range tenantIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ownerIDs = append(ownerIDs, id)
}
owners, err := s.canvasDAO.GetOwnerFilter(ctx, dao.DB, ownerIDs, userID)
if err != nil {
return nil, common.CodeServerError, fmt.Errorf("failed to aggregate agent owners: %w", err)
}
categories, err := s.canvasDAO.GetCategoryFilter(ctx, dao.DB, ownerIDs, userID)
if err != nil {
return nil, common.CodeServerError, fmt.Errorf("failed to aggregate agent categories: %w", err)
}
groupCount, err := s.compilationTemplateGroupDAO.CountSavedByTenant(ctx, dao.DB, userID)
if err != nil {
return nil, common.CodeServerError, fmt.Errorf("failed to count compilation template groups: %w", err)
}
ownerFilters := make([]AgentOwnerFilter, 0, len(owners)+1)
for _, o := range owners {
label := o.ID
if o.Label != nil && *o.Label != "" {
label = *o.Label
}
ownerFilters = append(ownerFilters, AgentOwnerFilter{ID: o.ID, Label: label, Count: o.Count})
}
if groupCount > 0 {
idx := -1
for i := range ownerFilters {
if ownerFilters[i].ID == userID {
idx = i
break
}
}
if idx >= 0 {
ownerFilters[idx].Count += groupCount
} else {
nickname, nerr := s.userDAO.GetNicknameByID(ctx, dao.DB, userID)
if nerr != nil {
nickname = ""
}
ownerFilters = append(ownerFilters, AgentOwnerFilter{ID: userID, Label: nickname, Count: groupCount})
}
}
categoryFilters := make([]AgentCategoryFilter, 0, len(categories)+1)
for _, c := range categories {
categoryFilters = append(categoryFilters, AgentCategoryFilter{ID: c.ID, Count: c.Count})
}
if groupCount > 0 {
categoryFilters = append(categoryFilters, AgentCategoryFilter{ID: CompilationTemplateGroupCategory, Count: groupCount})
}
var total int64
for _, o := range ownerFilters {
total += o.Count
}
resp := &AgentFiltersResponse{Total: total}
resp.Filter.Owner = ownerFilters
resp.Filter.CanvasCategory = categoryFilters
return resp, common.CodeSuccess, nil
}
type AgentTagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`

View File

@@ -951,7 +951,10 @@ export const useFetchAgentFilters = () => {
},
});
return { data: data.filter, loading };
return {
data: data?.filter ?? { owner: [], canvas_category: [] },
loading,
};
};
export const BuiltinPipelineKeys = {