diff --git a/internal/dao/canvas_template.go b/internal/dao/canvas_template.go new file mode 100644 index 0000000000..bd407bc9a5 --- /dev/null +++ b/internal/dao/canvas_template.go @@ -0,0 +1,40 @@ +// +// 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 dao + +import ( + "ragflow/internal/entity" +) + +// CanvasTemplateDAO data-access object for the canvas_template table. +type CanvasTemplateDAO struct{} + +// NewCanvasTemplateDAO creates a CanvasTemplate DAO. +func NewCanvasTemplateDAO() *CanvasTemplateDAO { + return &CanvasTemplateDAO{} +} + +// GetAll returns every row in canvas_template ordered by create_time desc, so +// templates appear newest first in the UI. Mirrors the Python +// CanvasTemplateService.get_all() behaviour. +func (dao *CanvasTemplateDAO) GetAll() ([]*entity.CanvasTemplate, error) { + var templates []*entity.CanvasTemplate + if err := DB.Order("create_time desc").Find(&templates).Error; err != nil { + return nil, err + } + return templates, nil +} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 0e32eaec0c..15df6a0fdd 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -29,6 +29,7 @@ import ( "gorm.io/gorm" "ragflow/internal/common" + "ragflow/internal/entity" "ragflow/internal/service" ) @@ -182,6 +183,37 @@ func (h *AgentHandler) ListAgentVersions(c *gin.Context) { }) } +// ListTemplates lists every canvas template available to authenticated users. +// @Summary List Agent Templates +// @Description List the catalogue of canvas templates that authenticated users can clone. +// @Tags agents +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/agents/templates [get] +func (h *AgentHandler) ListTemplates(c *gin.Context) { + if _, errorCode, errorMessage := GetUser(c); errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + templates, err := h.agentService.ListTemplates() + if err != nil { + jsonError(c, common.CodeServerError, err.Error()) + return + } + if templates == nil { + // Ensure the JSON payload is always a list, never null. + templates = []*entity.CanvasTemplate{} + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": templates, + "message": "success", + }) +} + // UploadAgentFile uploads one or more files associated with an agent. // @Summary Upload Agent File // @Description Upload one or more files for an agent canvas. @@ -309,7 +341,7 @@ func (h *AgentHandler) UploadAgentFile(c *gin.Context) { uploaded, err := h.fileService.UploadFile(user.ID, "", files) if err != nil { c.JSON(http.StatusOK, gin.H{ - "code": common.CodeOperatingError, + "code": common.CodeOperatingError, "data": nil, "message": err.Error(), @@ -318,8 +350,8 @@ func (h *AgentHandler) UploadAgentFile(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "code": common.CodeSuccess, - "data": uploaded, + "code": common.CodeSuccess, + "data": uploaded, "message": "", }) diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index 6e07ab4088..f18f214517 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -23,8 +23,8 @@ import ( "testing" "time" - "github.com/glebarez/sqlite" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "gorm.io/gorm" "ragflow/internal/common" @@ -195,6 +195,7 @@ func TestListAgentVersionsHandler_CanvasNotFound(t *testing.T) { t.Errorf("expected operating error code %d, got %v", common.CodeOperatingError, code) } } + // TestGetAgentVersionHandler_Success verifies getting a specific version. func TestGetAgentVersionHandler_Success(t *testing.T) { c, w, db := setupGinContextWithUserAndDB(t, "GET", "/api/v1/agents/canvas-1/versions/v1") @@ -260,8 +261,191 @@ func TestGetAgentVersionHandler_VersionNotFound(t *testing.T) { } } - - -// sptr returns a pointer to the given string. // ptr returns a pointer to the given int64. func ptr(v int64) *int64 { return &v } + +// 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 + templates []*entity.CanvasTemplate + templatesErr 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) + ListTemplates() ([]*entity.CanvasTemplate, 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 (h *agentHandlerTestable) listTemplates(c *gin.Context) { + if _, errorCode, errorMessage := GetUser(c); errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + templates, err := h.svc.ListTemplates() + if err != nil { + jsonError(c, common.CodeServerError, err.Error()) + return + } + if templates == nil { + templates = []*entity.CanvasTemplate{} + } + 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) { + return f.result, f.code, f.err +} + +func (f *fakeAgentService) ListTemplates() ([]*entity.CanvasTemplate, error) { + return f.templates, f.templatesErr +} + +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) + }) + r.GET("/api/v1/agents/templates", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "user-abc"}) + h.listTemplates(c) + }) + r.GET("/api/v1/agents/templates_anon", func(c *gin.Context) { + // no user set → unauthenticated probe + h.listTemplates(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"]) + } +} + +func TestListAgentTemplates_Success(t *testing.T) { + cnvType := "agent" + svc := &fakeAgentService{ + templates: []*entity.CanvasTemplate{ + { + ID: "template-1", + CanvasType: &cnvType, + CanvasCategory: "agent_canvas", + Title: entity.JSONMap{"en": "Sample"}, + Description: entity.JSONMap{"en": "Sample desc"}, + }, + }, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/agents/templates", nil) + setupAgentRouter(svc).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, w.Body.String()) + } + if body["code"] != float64(common.CodeSuccess) { + t.Errorf("code=%v want %d", body["code"], common.CodeSuccess) + } + data, ok := body["data"].([]interface{}) + if !ok { + t.Fatalf("data is not an array: %v", body["data"]) + } + if len(data) != 1 { + t.Fatalf("expected 1 template, got %d", len(data)) + } + first := data[0].(map[string]interface{}) + if first["id"] != "template-1" { + t.Errorf("id=%v want template-1", first["id"]) + } + if first["canvas_category"] != "agent_canvas" { + t.Errorf("canvas_category=%v want agent_canvas", first["canvas_category"]) + } +} + +func TestListAgentTemplates_EmptyIsArrayNotNull(t *testing.T) { + svc := &fakeAgentService{templates: nil} + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/agents/templates", nil) + setupAgentRouter(svc).ServeHTTP(w, req) + + var body map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &body) + // JSON shape contract: never null - frontends do .map() on it. + if _, ok := body["data"].([]interface{}); !ok { + t.Fatalf("data is not an array when templates empty: %v (raw=%s)", body["data"], w.Body.String()) + } +} + +func TestListAgentTemplates_RequiresAuth(t *testing.T) { + svc := &fakeAgentService{templates: []*entity.CanvasTemplate{}} + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/agents/templates_anon", nil) + setupAgentRouter(svc).ServeHTTP(w, req) + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if code, _ := body["code"].(float64); int(code) == int(common.CodeSuccess) { + t.Errorf("expected non-success without auth, got body=%v", body) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index a64c9f6e49..9a84c7b55c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -23,26 +23,26 @@ import ( ) type Router struct { - authHandler *handler.AuthHandler - userHandler *handler.UserHandler - tenantHandler *handler.TenantHandler - documentHandler *handler.DocumentHandler - datasetsHandler *handler.DatasetsHandler - systemHandler *handler.SystemHandler - knowledgebaseHandler *handler.KnowledgebaseHandler - chunkHandler *handler.ChunkHandler - llmHandler *handler.LLMHandler - chatHandler *handler.ChatHandler - chatSessionHandler *handler.ChatSessionHandler - connectorHandler *handler.ConnectorHandler - searchHandler *handler.SearchHandler - fileHandler *handler.FileHandler - memoryHandler *handler.MemoryHandler - mcpHandler *handler.MCPHandler - skillSearchHandler *handler.SkillSearchHandler - providerHandler *handler.ProviderHandler - agentHandler *handler.AgentHandler - relatedQuestionsHandler *handler.SearchbotHandler + authHandler *handler.AuthHandler + userHandler *handler.UserHandler + tenantHandler *handler.TenantHandler + documentHandler *handler.DocumentHandler + datasetsHandler *handler.DatasetsHandler + systemHandler *handler.SystemHandler + knowledgebaseHandler *handler.KnowledgebaseHandler + chunkHandler *handler.ChunkHandler + llmHandler *handler.LLMHandler + chatHandler *handler.ChatHandler + chatSessionHandler *handler.ChatSessionHandler + connectorHandler *handler.ConnectorHandler + searchHandler *handler.SearchHandler + fileHandler *handler.FileHandler + memoryHandler *handler.MemoryHandler + mcpHandler *handler.MCPHandler + skillSearchHandler *handler.SkillSearchHandler + providerHandler *handler.ProviderHandler + agentHandler *handler.AgentHandler + relatedQuestionsHandler *handler.SearchbotHandler } // NewRouter create router @@ -69,25 +69,25 @@ func NewRouter( relatedQuestionsHandler *handler.SearchbotHandler, ) *Router { return &Router{ - authHandler: authHandler, - userHandler: userHandler, - tenantHandler: tenantHandler, - documentHandler: documentHandler, - datasetsHandler: datasetsHandler, - systemHandler: systemHandler, - knowledgebaseHandler: knowledgebaseHandler, - chunkHandler: chunkHandler, - llmHandler: llmHandler, - chatHandler: chatHandler, - chatSessionHandler: chatSessionHandler, - connectorHandler: connectorHandler, - searchHandler: searchHandler, - fileHandler: fileHandler, - memoryHandler: memoryHandler, - mcpHandler: mcpHandler, - skillSearchHandler: skillSearchHandler, - providerHandler: providerHandler, - agentHandler: agentHandler, + authHandler: authHandler, + userHandler: userHandler, + tenantHandler: tenantHandler, + documentHandler: documentHandler, + datasetsHandler: datasetsHandler, + systemHandler: systemHandler, + knowledgebaseHandler: knowledgebaseHandler, + chunkHandler: chunkHandler, + llmHandler: llmHandler, + chatHandler: chatHandler, + chatSessionHandler: chatSessionHandler, + connectorHandler: connectorHandler, + searchHandler: searchHandler, + fileHandler: fileHandler, + memoryHandler: memoryHandler, + mcpHandler: mcpHandler, + skillSearchHandler: skillSearchHandler, + providerHandler: providerHandler, + agentHandler: agentHandler, relatedQuestionsHandler: relatedQuestionsHandler, } } @@ -374,6 +374,7 @@ func (r *Router) Setup(engine *gin.Engine) { agents := v1.Group("/agents") { agents.GET("", r.agentHandler.ListAgents) + agents.GET("/templates", r.agentHandler.ListTemplates) agents.GET("/:agent_id/versions", r.agentHandler.ListAgentVersions) agents.GET("/:agent_id/versions/:version_id", r.agentHandler.GetAgentVersion) agents.POST("/:agent_id/upload", r.agentHandler.UploadAgentFile) diff --git a/internal/service/agent.go b/internal/service/agent.go index bf1f1a4c2d..c62a89b5e2 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -26,9 +26,10 @@ import ( // AgentService agent service type AgentService struct { - canvasDAO *dao.UserCanvasDAO - userTenantDAO *dao.UserTenantDAO + canvasDAO *dao.UserCanvasDAO + userTenantDAO *dao.UserTenantDAO userCanvasVersionDAO *dao.UserCanvasVersionDAO + canvasTemplateDAO *dao.CanvasTemplateDAO } // NewAgentService create agent service @@ -37,9 +38,17 @@ func NewAgentService() *AgentService { canvasDAO: dao.NewUserCanvasDAO(), userTenantDAO: dao.NewUserTenantDAO(), userCanvasVersionDAO: dao.NewUserCanvasVersionDAO(), + canvasTemplateDAO: dao.NewCanvasTemplateDAO(), } } +// ListTemplates returns every canvas template. Mirrors Python +// agent_api.list_agent_template, which iterates CanvasTemplateService.get_all() +// and serialises each row. +func (s *AgentService) ListTemplates() ([]*entity.CanvasTemplate, error) { + return s.canvasTemplateDAO.GetAll() +} + // AgentItem is one entry in the list response. type AgentItem struct { ID string `json:"id"`