diff --git a/internal/dao/compilation_template_group.go b/internal/dao/compilation_template_group.go index 9366920085..76826d6b9d 100644 --- a/internal/dao/compilation_template_group.go +++ b/internal/dao/compilation_template_group.go @@ -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 diff --git a/internal/dao/user_canvas.go b/internal/dao/user_canvas.go index 405aa31a0e..26ca185d41 100644 --- a/internal/dao/user_canvas.go +++ b/internal/dao/user_canvas.go @@ -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 { diff --git a/internal/dao/user_canvas_test.go b/internal/dao/user_canvas_test.go index efce6faec0..ffd30baf85 100644 --- a/internal/dao/user_canvas_test.go +++ b/internal/dao/user_canvas_test.go @@ -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"]) + } +} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 5ec14ef053..69421144ce 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -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") diff --git a/internal/service/agent.go b/internal/service/agent.go index 0cadfddeb9..1d9910ce5c 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -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"` diff --git a/web/src/hooks/use-agent-request.ts b/web/src/hooks/use-agent-request.ts index d5b2df267d..0646cf9418 100644 --- a/web/src/hooks/use-agent-request.ts +++ b/web/src/hooks/use-agent-request.ts @@ -951,7 +951,10 @@ export const useFetchAgentFilters = () => { }, }); - return { data: data.filter, loading }; + return { + data: data?.filter ?? { owner: [], canvas_category: [] }, + loading, + }; }; export const BuiltinPipelineKeys = {