From 9eacbb418fb250c1a0c62389d944ef0e78e2238c Mon Sep 17 00:00:00 2001 From: Haruko386 Date: Wed, 1 Jul 2026 19:07:15 +0800 Subject: [PATCH] fix: unable to open filter in agent page(no agent tags...) (#16531) ### Summary As title --- internal/dao/user_canvas.go | 155 +++++++++++++++++-- internal/handler/agent.go | 25 ++- internal/handler/agent_test.go | 8 +- internal/handler/agent_wait_for_user_test.go | 2 +- internal/service/agent.go | 29 +++- internal/service/agent_sessions.go | 35 +++++ 6 files changed, 230 insertions(+), 24 deletions(-) diff --git a/internal/dao/user_canvas.go b/internal/dao/user_canvas.go index 0fa99b9c6..0ca8babdf 100644 --- a/internal/dao/user_canvas.go +++ b/internal/dao/user_canvas.go @@ -18,6 +18,8 @@ package dao import ( "errors" + "regexp" + "strings" "gorm.io/gorm" @@ -55,6 +57,60 @@ func userCanvasOrderClause(orderby string, desc bool) string { return orderby + " ASC" } +func userCanvasQualifiedOrderClause(orderby string, desc bool) string { + if _, ok := userCanvasOrderableColumns[orderby]; !ok { + orderby = "create_time" + } + order := "user_canvas." + orderby + if desc { + return order + " DESC" + } + return order + " ASC" +} + +func escapeSQLLike(s string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return replacer.Replace(s) +} + +func splitUserCanvasTags(raw string) []string { + parts := strings.Split(raw, ",") + tags := make([]string, 0, len(parts)) + for _, tag := range parts { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + return tags +} + +func applyUserCanvasTagFilter(query *gorm.DB, tags []string) *gorm.DB { + if len(tags) == 0 { + return query + } + tagQuery := DB.Session(&gorm.Session{NewDB: true}) + hasTag := false + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + pattern := "(^|,)[[:space:]]*" + regexp.QuoteMeta(tag) + "[[:space:]]*(,|$)" + cond := DB.Where("user_canvas.tags REGEXP ?", pattern) + if !hasTag { + tagQuery = tagQuery.Where(cond) + hasTag = true + } else { + tagQuery = tagQuery.Or(cond) + } + } + if !hasTag { + return query + } + return query.Where(tagQuery) +} + var ErrUserCanvasNotFound = errors.New("user_canvas: not found or access denied") // UserCanvasDAO user canvas data access object @@ -271,56 +327,129 @@ func (dao *UserCanvasDAO) GetAllCanvasesByTenantIDs(tenantIDs []string, userID s return results, err } +// UserCanvasListItem is the joined row returned by ListByTenantIDs. +type UserCanvasListItem struct { + ID string `gorm:"column:id"` + Avatar *string `gorm:"column:avatar"` + Title *string `gorm:"column:title"` + Description *string `gorm:"column:description"` + Permission string `gorm:"column:permission"` + UserID string `gorm:"column:user_id"` + TenantID string `gorm:"column:tenant_id"` + Nickname *string `gorm:"column:nickname"` + TenantAvatar *string `gorm:"column:tenant_avatar"` + CanvasType *string `gorm:"column:canvas_type"` + CanvasCategory string `gorm:"column:canvas_category"` + Tags string `gorm:"column:tags"` + CreateTime *int64 `gorm:"column:create_time"` + UpdateTime *int64 `gorm:"column:update_time"` +} + // ListByTenantIDs lists agent canvases accessible to the given owner IDs with optional -// keyword filter, pagination, and ordering. +// keyword filter, tag filter, pagination, and ordering. // Mirrors Python UserCanvasService.get_by_tenant_ids (list route only). -func (dao *UserCanvasDAO) ListByTenantIDs(ownerIDs []string, userID string, page, pageSize int, orderby string, desc bool, keywords string, canvasCategory string) ([]*entity.UserCanvas, int64, error) { +func (dao *UserCanvasDAO) ListByTenantIDs(ownerIDs []string, userID string, page, pageSize int, orderby string, desc bool, keywords string, canvasCategory string, tags []string) ([]*UserCanvasListItem, int64, error) { if len(ownerIDs) == 0 { return nil, 0, nil } // Canvases owned by any of the ownerIDs that are "team"-permission, plus all owned by userID. base := DB.Model(&entity.UserCanvas{}). + Select(`user_canvas.id, + user_canvas.avatar, + user_canvas.title, + user_canvas.description, + user_canvas.permission, + user_canvas.user_id, + user_canvas.user_id AS tenant_id, + user.nickname, + user.avatar AS tenant_avatar, + user_canvas.canvas_type, + user_canvas.canvas_category, + user_canvas.tags, + user_canvas.create_time, + user_canvas.update_time`). + Joins("LEFT JOIN user ON user_canvas.user_id = user.id"). Where( - DB.Where("user_id IN ? AND permission = ?", ownerIDs, "team"). - Or("user_id = ?", userID), - ) + DB.Where("user_canvas.user_id IN ? AND user_canvas.permission = ?", ownerIDs, "team"). + Or("user_canvas.user_id = ?", userID), + "user_canvas.user_id IN ?", + ownerIDs, + ).Where( + DB.Where("user_canvas.permission = ?", "team"). + Or("user_canvas.user_id = ?", userID)) if canvasCategory != "" { - base = base.Where("canvas_category = ?", canvasCategory) + base = base.Where("user_canvas.canvas_category = ?", canvasCategory) } else { - base = base.Where("canvas_category = ?", "agent_canvas") + base = base.Where("user_canvas.canvas_category = ?", "agent_canvas") } if keywords != "" { like := "%" + keywords + "%" - base = base.Where("title LIKE ?", like) + base = base.Where("user_canvas.title LIKE ?", like) } + base = applyUserCanvasTagFilter(base, tags) var total int64 if err := base.Count(&total).Error; err != nil { return nil, 0, err } - order := userCanvasOrderClause(orderby, desc) + order := userCanvasQualifiedOrderClause(orderby, desc) // codeql[go/sql-injection] False positive: `order` was just derived - // from userCanvasOrderClause above, which validates `orderby` + // from userCanvasQualifiedOrderClause above, which validates `orderby` // against userCanvasOrderableColumns (a closed allowlist) and // defaults to "create_time" on miss. The string spliced into - // Order() is always one of a fixed set of column names. + // Order() is always one of a fixed set of qualified column names. query := base.Order(order) if page > 0 && pageSize > 0 { query = query.Offset((page - 1) * pageSize).Limit(pageSize) } - var canvases []*entity.UserCanvas - if err := query.Find(&canvases).Error; err != nil { + var canvases []*UserCanvasListItem + if err := query.Scan(&canvases).Error; err != nil { return nil, 0, err } return canvases, total, nil } +// ListTags returns tag usage counts across canvases visible to userID. +func (dao *UserCanvasDAO) ListTags(ownerIDs []string, userID string, canvasCategory string) (map[string]int, error) { + if len(ownerIDs) == 0 { + return map[string]int{}, nil + } + + query := DB.Model(&entity.UserCanvas{}). + Select("user_canvas.tags"). + Where( + DB.Where("user_canvas.user_id IN ? AND user_canvas.permission = ?", ownerIDs, "team"). + Or("user_canvas.user_id = ?", userID), + ) + + if canvasCategory != "" { + query = query.Where("user_canvas.canvas_category = ?", canvasCategory) + } else { + query = query.Where("user_canvas.canvas_category = ?", "agent_canvas") + } + + var rows []struct { + Tags string `gorm:"column:tags"` + } + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + counts := make(map[string]int) + for _, row := range rows { + for _, tag := range splitUserCanvasTags(row.Tags) { + counts[tag]++ + } + } + return counts, nil +} + // GetByCanvasID get user canvas by canvas ID (alias for GetByID) func (dao *UserCanvasDAO) GetByCanvasID(canvasID string) (*entity.UserCanvas, error) { return dao.GetByID(canvasID) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 328868bc5..55961c901 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -167,6 +167,15 @@ func (h *AgentHandler) ListAgents(c *gin.Context) { } } } + var tags []string + if raw := c.Query("tags"); raw != "" { + for _, tag := range strings.Split(raw, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + } result, code, err := h.agentService.ListAgents( user.ID, @@ -177,6 +186,7 @@ func (h *AgentHandler) ListAgents(c *gin.Context) { desc, ownerIDs, canvasCategory, + tags, ) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -682,16 +692,23 @@ func (h *AgentHandler) Prompts(c *gin.Context) { }) } -// ListAgentTags GET /api/v1/agents/tags — out of scope (no test depends on -// it); return 501 to keep the surface honest. +// ListAgentTags list agent tags. func (h *AgentHandler) ListAgentTags(c *gin.Context) { - if _, code, msg := GetUser(c); code != common.CodeSuccess { + user, code, msg := GetUser(c) + if code != common.CodeSuccess { jsonError(c, code, msg) return } + + rows, errCode, err := h.agentService.ListAgentTags(user.ID, strings.TrimSpace(c.Query("canvas_category"))) + if err != nil { + jsonError(c, errCode, err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{ "code": common.CodeSuccess, - "data": []string{}, + "data": rows, "message": "success", }) } diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index d52c21df3..3f4028aab 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -320,7 +320,7 @@ type fakeAgentService struct { // agentServiceIface is the minimum interface the handler depends on. type agentServiceIface interface { - ListAgents(userID, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string) (*service.ListAgentsResponse, common.ErrorCode, error) + ListAgents(userID, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string, tags []string) (*service.ListAgentsResponse, common.ErrorCode, error) ListTemplates() ([]*entity.CanvasTemplate, error) } @@ -335,7 +335,7 @@ func (h *agentHandlerTestable) listAgents(c *gin.Context) { jsonError(c, errorCode, errorMessage) return } - result, code, err := h.svc.ListAgents(user.ID, "", 0, 0, "create_time", true, nil, "") + result, code, err := h.svc.ListAgents(user.ID, "", 0, 0, "create_time", true, nil, "", nil) if err != nil { c.JSON(http.StatusOK, gin.H{"code": code, "data": false, "message": err.Error()}) return @@ -359,7 +359,7 @@ func (h *agentHandlerTestable) listTemplates(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": templates, "message": "success"}) } -func (f *fakeAgentService) ListAgents(userID, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string) (*service.ListAgentsResponse, common.ErrorCode, error) { +func (f *fakeAgentService) ListAgents(userID, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string, tags []string) (*service.ListAgentsResponse, common.ErrorCode, error) { return f.result, f.code, f.err } @@ -429,7 +429,7 @@ type fullFakeAgentService struct { versions []*entity.UserCanvasVersion } -func (f *fullFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string) (*service.ListAgentsResponse, common.ErrorCode, error) { +func (f *fullFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string, []string) (*service.ListAgentsResponse, common.ErrorCode, error) { return &service.ListAgentsResponse{}, common.CodeSuccess, nil } func (f *fullFakeAgentService) CreateAgent(context.Context, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) { diff --git a/internal/handler/agent_wait_for_user_test.go b/internal/handler/agent_wait_for_user_test.go index 5bac0e38f..1baaea7eb 100644 --- a/internal/handler/agent_wait_for_user_test.go +++ b/internal/handler/agent_wait_for_user_test.go @@ -75,7 +75,7 @@ func newWaitFakeAgentService(stub func(call int, root map[string]any) (*runtime. } } -func (f *waitFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string) (*service.ListAgentsResponse, common.ErrorCode, error) { +func (f *waitFakeAgentService) ListAgents(string, string, int, int, string, bool, []string, string, []string) (*service.ListAgentsResponse, common.ErrorCode, error) { return &service.ListAgentsResponse{}, common.CodeSuccess, nil } func (f *waitFakeAgentService) CreateAgent(context.Context, *service.CreateAgentRequest) (*entity.UserCanvas, common.ErrorCode, error) { diff --git a/internal/service/agent.go b/internal/service/agent.go index 04f9c105a..0bdca754f 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -203,7 +203,13 @@ type AgentItem struct { ID string `json:"id"` Avatar *string `json:"avatar,omitempty"` Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` Permission string `json:"permission"` + UserID string `json:"user_id"` + TenantID string `json:"tenant_id"` + Nickname string `json:"nickname"` + TenantAvatar *string `json:"tenant_avatar,omitempty"` + Tags string `json:"tags"` CanvasType *string `json:"canvas_type,omitempty"` CanvasCategory string `json:"canvas_category"` CreateTime *int64 `json:"create_time,omitempty"` @@ -216,14 +222,32 @@ type ListAgentsResponse struct { Total int64 `json:"total"` } -func toAgentItem(c *entity.UserCanvas) *AgentItem { +type AgentTagCount struct { + Tag string `json:"tag"` + Count int `json:"count"` +} + +func toAgentItem(c *dao.UserCanvasListItem) *AgentItem { + nickname := "" + if c.Nickname != nil { + nickname = *c.Nickname + } + if nickname == "" { + nickname = c.TenantID + } return &AgentItem{ ID: c.ID, Avatar: c.Avatar, Title: c.Title, + Description: c.Description, Permission: c.Permission, + UserID: c.UserID, + TenantID: c.TenantID, + Nickname: nickname, + TenantAvatar: c.TenantAvatar, CanvasType: c.CanvasType, CanvasCategory: c.CanvasCategory, + Tags: c.Tags, CreateTime: c.CreateTime, UpdateTime: c.UpdateTime, } @@ -232,7 +256,7 @@ func toAgentItem(c *entity.UserCanvas) *AgentItem { // ListAgents returns agent canvases visible to userID. // Mirrors Python agent_api.list_agents — validates owner_ids against joined tenants, // then delegates to the DAO. -func (s *AgentService) ListAgents(userID string, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string) (*ListAgentsResponse, common.ErrorCode, error) { +func (s *AgentService) ListAgents(userID string, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string, canvasCategory string, tags []string) (*ListAgentsResponse, common.ErrorCode, error) { // Build the set of tenant IDs the user is authorised to query. tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) if err != nil { @@ -268,6 +292,7 @@ func (s *AgentService) ListAgents(userID string, keywords string, page, pageSize desc, keywords, canvasCategory, + tags, ) if err != nil { return nil, common.CodeServerError, fmt.Errorf("failed to list agents: %w", err) diff --git a/internal/service/agent_sessions.go b/internal/service/agent_sessions.go index 4385807f0..593b59b8b 100644 --- a/internal/service/agent_sessions.go +++ b/internal/service/agent_sessions.go @@ -511,6 +511,41 @@ func (s *AgentService) DeleteAgentSessions(userID, agentID string, ids []string, return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil } +// ListAgentTags list agent tags +func (s *AgentService) ListAgentTags(userID, canvasCategory string) ([]AgentTagCount, common.ErrorCode, error) { + tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) + if err != nil { + return nil, common.CodeServerError, err + } + + ownerSet := make(map[string]struct{}, len(tenantIDs)+1) + ownerSet[userID] = struct{}{} + for _, id := range tenantIDs { + ownerSet[id] = struct{}{} + } + ownerIDs := make([]string, 0, len(ownerSet)) + for id := range ownerSet { + ownerIDs = append(ownerIDs, id) + } + + counts, err := s.canvasDAO.ListTags(ownerIDs, userID, canvasCategory) + if err != nil { + return nil, common.CodeServerError, err + } + + tags := make([]AgentTagCount, 0, len(counts)) + for tag, count := range counts { + tags = append(tags, AgentTagCount{Tag: tag, Count: count}) + } + sort.Slice(tags, func(i, j int) bool { + if tags[i].Count == tags[j].Count { + return tags[i].Tag < tags[j].Tag + } + return tags[i].Count > tags[j].Count + }) + return tags, common.CodeSuccess, nil +} + // normalizeAgentTags returns an error for unsupported tag payload types. // The branch behaviour intentionally mirrors the Python implementation: // - string: treat the value as a CSV — split on "," and use each piece