From 0a7662cf3e7e539e747a27092e5363617d39e471 Mon Sep 17 00:00:00 2001 From: web-dev0521 Date: Thu, 28 May 2026 05:40:54 -0600 Subject: [PATCH] feat(go-api): implement GET /api/v1/agents list endpoint (issue #15328) (#15329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes: #15328 - Implements `GET /api/v1/agents` — the agent/canvas listing endpoint needed to complete the Home dashboard tile in `web/src/pages/home/`. - Mirrors Python `api/apps/restful_apis/agent_api.py::list_agents` exactly: tenant-join auth, optional `owner_ids` guard, keyword filter, pagination, ordering, and `canvas_category` filter (default: `agent_canvas`). - **Scope:** read-only list only. Full agent CRUD and canvas runtime are explicitly out of scope (separate slice of #15240). --- cmd/server_main.go | 3 +- internal/dao/user_canvas.go | 58 +++++++++++++++ internal/handler/agent.go | 119 ++++++++++++++++++++++++++++++ internal/handler/agent_test.go | 110 ++++++++++++++++++++++++++++ internal/router/router.go | 9 +++ internal/service/agent.go | 129 +++++++++++++++++++++++++++++++++ 6 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 internal/handler/agent.go create mode 100644 internal/handler/agent_test.go create mode 100644 internal/service/agent.go diff --git a/cmd/server_main.go b/cmd/server_main.go index 79e9d90cf4..affff7ac14 100644 --- a/cmd/server_main.go +++ b/cmd/server_main.go @@ -220,9 +220,10 @@ func startServer(config *server.Config) { mcpHandler := handler.NewMCPHandler(mcpService) skillSearchHandler := handler.NewSkillSearchHandler(docEngine) providerHandler := handler.NewProviderHandler(userService, modelProviderService) + agentHandler := handler.NewAgentHandler(service.NewAgentService()) // Initialize router - r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler) + r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler) // Create Gin engine ginEngine := gin.New() diff --git a/internal/dao/user_canvas.go b/internal/dao/user_canvas.go index 407e149b97..a333c7775b 100644 --- a/internal/dao/user_canvas.go +++ b/internal/dao/user_canvas.go @@ -113,6 +113,64 @@ func (dao *UserCanvasDAO) GetAllCanvasesByTenantIDs(tenantIDs []string, userID s return results, err } +// ListByTenantIDs lists agent canvases accessible to the given owner IDs with optional +// keyword 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) { + 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{}). + Where( + DB.Where("user_id IN ? AND permission = ?", ownerIDs, "team"). + Or("user_id = ?", userID), + ) + + if canvasCategory != "" { + base = base.Where("canvas_category = ?", canvasCategory) + } else { + base = base.Where("canvas_category = ?", "agent_canvas") + } + + if keywords != "" { + like := "%" + keywords + "%" + base = base.Where("title LIKE ?", like) + } + + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + order := orderby + if desc { + order += " DESC" + } else { + order += " ASC" + } + 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 { + return nil, 0, err + } + return canvases, total, 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 new file mode 100644 index 0000000000..ace7c2f9c0 --- /dev/null +++ b/internal/handler/agent.go @@ -0,0 +1,119 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package handler + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/service" +) + +// AgentHandler agent handler +type AgentHandler struct { + agentService *service.AgentService +} + +// NewAgentHandler create agent handler +func NewAgentHandler(agentService *service.AgentService) *AgentHandler { + return &AgentHandler{agentService: agentService} +} + +// ListAgents lists agent canvases for the current user. +// @Summary List Agents +// @Description List agent canvases accessible to the current user (Home dashboard tile) +// @Tags agents +// @Produce json +// @Param keywords query string false "Filter by title keyword" +// @Param page query int false "Page number (0 = no pagination)" +// @Param page_size query int false "Items per page (0 = no pagination)" +// @Param orderby query string false "Order-by field (default: create_time)" +// @Param desc query bool false "Descending order (default: true)" +// @Param owner_ids query string false "Comma-separated owner IDs to filter (default: all authorised tenants)" +// @Param canvas_category query string false "Canvas category (default: agent_canvas)" +// @Success 200 {object} service.ListAgentsResponse +// @Router /api/v1/agents [get] +func (h *AgentHandler) ListAgents(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + keywords := c.Query("keywords") + canvasCategory := c.Query("canvas_category") + + page := 0 + if v := c.Query("page"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 { + page = p + } + } + + pageSize := 0 + if v := c.Query("page_size"); v != "" { + if ps, err := strconv.Atoi(v); err == nil && ps > 0 { + pageSize = ps + } + } + + orderby := c.DefaultQuery("orderby", "create_time") + + desc := true + if v := c.Query("desc"); v != "" { + desc = strings.ToLower(v) != "false" + } + + var ownerIDs []string + if raw := c.Query("owner_ids"); raw != "" { + for _, id := range strings.Split(raw, ",") { + id = strings.TrimSpace(id) + if id != "" { + ownerIDs = append(ownerIDs, id) + } + } + } + + result, code, err := h.agentService.ListAgents( + user.ID, + keywords, + page, + pageSize, + orderby, + desc, + ownerIDs, + canvasCategory, + ) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": code, + "data": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": "success", + }) +} diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go new file mode 100644 index 0000000000..2becd2046e --- /dev/null +++ b/internal/handler/agent_test.go @@ -0,0 +1,110 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" + "ragflow/internal/service" +) + +// fakeAgentService satisfies the subset of AgentService used by the handler. +// It is injected via a wrapper to avoid importing the real DAO (which requires a DB). +type fakeAgentService struct { + result *service.ListAgentsResponse + code common.ErrorCode + err error +} + +// 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) +} + +// agentHandlerTestable is a version of AgentHandler that accepts the interface. +type agentHandlerTestable struct { + svc agentServiceIface +} + +func (h *agentHandlerTestable) listAgents(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + result, code, err := h.svc.ListAgents(user.ID, "", 0, 0, "create_time", true, nil, "") + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": code, "data": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": result, "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) { + return f.result, f.code, f.err +} + +func setupAgentRouter(svc agentServiceIface) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + h := &agentHandlerTestable{svc: svc} + r.GET("/api/v1/agents", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "user-abc"}) + h.listAgents(c) + }) + return r +} + +func TestListAgents_Success(t *testing.T) { + title := "My Agent" + svc := &fakeAgentService{ + result: &service.ListAgentsResponse{ + Canvas: []*service.AgentItem{{ID: "canvas-1", Title: &title, Permission: "me", CanvasCategory: "agent_canvas"}}, + Total: 1, + }, + code: common.CodeSuccess, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/agents", nil) + setupAgentRouter(svc).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["code"] != float64(common.CodeSuccess) { + t.Errorf("expected code %d, got %v", common.CodeSuccess, body["code"]) + } + data, ok := body["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data is not a map: %v", body["data"]) + } + if data["total"] != float64(1) { + t.Errorf("expected total=1, got %v", data["total"]) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index b22509ab62..2c625ecab9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -41,6 +41,7 @@ type Router struct { mcpHandler *handler.MCPHandler skillSearchHandler *handler.SkillSearchHandler providerHandler *handler.ProviderHandler + agentHandler *handler.AgentHandler } // NewRouter create router @@ -63,6 +64,7 @@ func NewRouter( mcpHandler *handler.MCPHandler, skillSearchHandler *handler.SkillSearchHandler, providerHandler *handler.ProviderHandler, + agentHandler *handler.AgentHandler, ) *Router { return &Router{ authHandler: authHandler, @@ -83,6 +85,7 @@ func NewRouter( mcpHandler: mcpHandler, skillSearchHandler: skillSearchHandler, providerHandler: providerHandler, + agentHandler: agentHandler, } } @@ -314,6 +317,12 @@ func (r *Router) Setup(engine *gin.Engine) { model.PATCH("/", r.tenantHandler.SetModels) } + // Agent routes + agents := v1.Group("/agents") + { + agents.GET("", r.agentHandler.ListAgents) + } + connector := v1.Group("/connectors") { connector.GET("/", r.connectorHandler.ListConnectors) diff --git a/internal/service/agent.go b/internal/service/agent.go new file mode 100644 index 0000000000..19a0be3889 --- /dev/null +++ b/internal/service/agent.go @@ -0,0 +1,129 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package service + +import ( + "fmt" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +// AgentService agent service +type AgentService struct { + canvasDAO *dao.UserCanvasDAO + userTenantDAO *dao.UserTenantDAO +} + +// NewAgentService create agent service +func NewAgentService() *AgentService { + return &AgentService{ + canvasDAO: dao.NewUserCanvasDAO(), + userTenantDAO: dao.NewUserTenantDAO(), + } +} + +// AgentItem is one entry in the list response. +type AgentItem struct { + ID string `json:"id"` + Avatar *string `json:"avatar,omitempty"` + Title *string `json:"title,omitempty"` + Permission string `json:"permission"` + CanvasType *string `json:"canvas_type,omitempty"` + CanvasCategory string `json:"canvas_category"` + CreateTime *int64 `json:"create_time,omitempty"` + UpdateTime *int64 `json:"update_time,omitempty"` +} + +// ListAgentsResponse is the response body for GET /api/v1/agents. +type ListAgentsResponse struct { + Canvas []*AgentItem `json:"canvas"` + Total int64 `json:"total"` +} + +func toAgentItem(c *entity.UserCanvas) *AgentItem { + return &AgentItem{ + ID: c.ID, + Avatar: c.Avatar, + Title: c.Title, + Permission: c.Permission, + CanvasType: c.CanvasType, + CanvasCategory: c.CanvasCategory, + CreateTime: c.CreateTime, + UpdateTime: c.UpdateTime, + } +} + +// 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) { + // Build the set of tenant IDs the user is authorised to query. + tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) + if err != nil { + return nil, common.CodeServerError, fmt.Errorf("failed to get tenant IDs: %w", err) + } + authorised := make(map[string]struct{}, len(tenantIDs)+1) + for _, id := range tenantIDs { + authorised[id] = struct{}{} + } + authorised[userID] = struct{}{} + + var effectiveOwnerIDs []string + if len(ownerIDs) > 0 { + for _, id := range ownerIDs { + if _, ok := authorised[id]; !ok { + return nil, common.CodeOperatingError, fmt.Errorf("only authorized owner_ids can be queried") + } + } + effectiveOwnerIDs = ownerIDs + } else { + effectiveOwnerIDs = make([]string, 0, len(authorised)) + for id := range authorised { + effectiveOwnerIDs = append(effectiveOwnerIDs, id) + } + } + + canvases, total, err := s.canvasDAO.ListByTenantIDs( + effectiveOwnerIDs, + userID, + page, + pageSize, + orderby, + desc, + keywords, + canvasCategory, + ) + if err != nil { + return nil, common.CodeServerError, fmt.Errorf("failed to list agents: %w", err) + } + + items := make([]*AgentItem, len(canvases)) + for i, c := range canvases { + items[i] = toAgentItem(c) + } + return &ListAgentsResponse{Canvas: items, Total: total}, common.CodeSuccess, nil +}