fix: unable to open filter in agent page(no agent tags...) (#16531)

### Summary

As title
This commit is contained in:
Haruko386
2026-07-01 19:07:15 +08:00
committed by GitHub
parent fcf2ca869b
commit 9eacbb418f
6 changed files with 230 additions and 24 deletions

View File

@@ -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)

View File

@@ -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",
})
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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