diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index ce19fa0..ff6d93b 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -214,6 +214,10 @@ func registerDashboardAgentTeamScheduleRoutes(group *gin.RouterGroup) { } func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { + group.GET("/:id/workflow", dashboard.AIWorkflowGetByAgent) + group.POST("/workflow/save", dashboard.AIWorkflowPostSaveAgent) + group.POST("/workflow/validate", dashboard.AIWorkflowPostValidate) + group.POST("/workflow/publish", dashboard.AIWorkflowPostPublishAgent) group.GET("/:id", dashboard.AIAgentGetBy) group.POST("/create", dashboard.AIAgentPostCreate) group.POST("/delete", dashboard.AIAgentPostDelete) @@ -225,16 +229,10 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { } func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { - group.Any("/list", dashboard.AIWorkflowAnyList) - group.POST("/create", dashboard.AIWorkflowPostCreate) - group.POST("/update", dashboard.AIWorkflowPostUpdate) - group.POST("/delete", dashboard.AIWorkflowPostDelete) group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.POST("/validate", dashboard.AIWorkflowPostValidate) - group.POST("/publish", dashboard.AIWorkflowPostPublish) group.Any("/version/list", dashboard.AIWorkflowAnyVersionList) group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) - group.GET("/:id", dashboard.AIWorkflowGetBy) } func registerDashboardAIConfigRoutes(group *gin.RouterGroup) { diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index 6a78dd7..13ac177 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -17,8 +17,7 @@ func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse { ID: item.ID, Name: item.Name, Description: item.Description, - OwnerType: item.OwnerType, - OwnerID: item.OwnerID, + AgentID: item.AgentID, Status: item.Status, DraftDefinition: parseWorkflowDefinition(item.DraftDefinition), PublishedVersionID: item.PublishedVersionID, diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index f64a703..3c91a98 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -21,8 +21,7 @@ func AIWorkflowAnyList(ctx *gin.Context) { cnd := params.NewPagedSqlCnd(ctx, params.QueryFilter{ParamName: "status"}, params.QueryFilter{ParamName: "name", Op: params.Like}, - params.QueryFilter{ParamName: "ownerType"}, - params.QueryFilter{ParamName: "ownerId"}, + params.QueryFilter{ParamName: "agentId"}, ).Desc("id") list, paging := services.AIWorkflowService.FindPageByCnd(cnd) httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAIWorkflowList(list), Page: paging}) @@ -144,6 +143,62 @@ func AIWorkflowPostPublish(ctx *gin.Context) { httpx.WriteJSON(ctx, builders.BuildAIWorkflowVersion(item)) } +func AIWorkflowGetByAgent(ctx *gin.Context) { + agentID, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.AIWorkflowService.GetOrCreateAgentWorkflow(agentID, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildAIWorkflow(item)) +} + +func AIWorkflowPostSaveAgent(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SaveAIWorkflowRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.AIWorkflowService.SaveAgentWorkflow(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildAIWorkflow(item)) +} + +func AIWorkflowPostPublishAgent(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.PublishAIWorkflowRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.AIWorkflowService.PublishAgentWorkflow(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildAIWorkflowVersion(item)) +} + func AIWorkflowAnyVersionList(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/models/models.go b/internal/models/models.go index 165498a..c467d64 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -538,8 +538,7 @@ type AIWorkflow struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(100);not null;default:'';index"` Description string `gorm:"type:text"` - OwnerType string `gorm:"type:varchar(30);not null;default:'';index"` - OwnerID int64 `gorm:"type:bigint;not null;default:0;index"` + AgentID int64 `gorm:"type:bigint;not null;default:0;index"` Status enums.Status `gorm:"type:int;not null;default:0;index"` DraftDefinition string `gorm:"type:longtext"` PublishedVersionID int64 `gorm:"type:bigint;not null;default:0;index"` diff --git a/internal/pkg/dto/request/ai_request.go b/internal/pkg/dto/request/ai_request.go index df46a02..35d5f0a 100644 --- a/internal/pkg/dto/request/ai_request.go +++ b/internal/pkg/dto/request/ai_request.go @@ -58,8 +58,6 @@ type CreateAIAgentRequest struct { SkillIDs []int64 `json:"skillIds"` DirectTools []AIAgentMCPToolRequest `json:"directTools"` GraphTools []string `json:"graphTools"` - RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"` - WorkflowVersionID int64 `json:"workflowVersionId"` } type UpdateAIAgentRequest struct { diff --git a/internal/pkg/dto/request/ai_workflow_request.go b/internal/pkg/dto/request/ai_workflow_request.go index 3c4d8c6..eed8d67 100644 --- a/internal/pkg/dto/request/ai_workflow_request.go +++ b/internal/pkg/dto/request/ai_workflow_request.go @@ -5,11 +5,12 @@ import "agent-desk/internal/ai/workflow/dsl" type CreateAIWorkflowRequest struct { Name string `json:"name"` Description string `json:"description"` - OwnerType string `json:"ownerType"` - OwnerID int64 `json:"ownerId"` + AgentID int64 `json:"agentId"` Definition dsl.Definition `json:"definition"` } +type SaveAIWorkflowRequest = CreateAIWorkflowRequest + type UpdateAIWorkflowRequest struct { ID int64 `json:"id"` CreateAIWorkflowRequest @@ -25,6 +26,7 @@ type ValidateAIWorkflowRequest struct { type PublishAIWorkflowRequest struct { WorkflowID int64 `json:"workflowId"` + AgentID int64 `json:"agentId"` Definition dsl.Definition `json:"definition"` } diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index d4077f9..a45ffe3 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -11,8 +11,7 @@ type AIWorkflowResponse struct { ID int64 `json:"id"` Name string `json:"name"` Description string `json:"description"` - OwnerType string `json:"ownerType"` - OwnerID int64 `json:"ownerId"` + AgentID int64 `json:"agentId"` Status enums.Status `json:"status"` DraftDefinition dsl.Definition `json:"draftDefinition"` PublishedVersionID int64 `json:"publishedVersionId"` diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index 87b3197..4da0f42 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -75,7 +75,13 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato item.Status = enums.StatusOk item.SortNo = 0 item.AuditFields = utils.BuildAuditFields(operator) - if err := repositories.AIAgentRepository.Create(sqls.DB(), item); err != nil { + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil { + return err + } + _, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator) + return err + }); err != nil { return nil, err } return item, nil @@ -108,8 +114,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato "skill_ids": item.SkillIDs, "allowed_mcp_tools": item.AllowedMCPTools, "allowed_graph_tools": item.AllowedGraphTools, - "runtime_mode": item.RuntimeMode, - "workflow_version_id": item.WorkflowVersionID, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), @@ -193,10 +197,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe if err != nil { return nil, err } - runtimeMode, workflowVersionID, err := s.normalizeRuntimeMode(req.RuntimeMode, req.WorkflowVersionID) - if err != nil { - return nil, err - } directToolsJSON := "" if len(directTools) > 0 { buf, marshalErr := json.Marshal(directTools) @@ -229,8 +229,8 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe SkillIDs: utils.JoinInt64s(skillIDs), AllowedMCPTools: directToolsJSON, AllowedGraphTools: graphToolsJSON, - RuntimeMode: runtimeMode, - WorkflowVersionID: workflowVersionID, + RuntimeMode: enums.AIAgentRuntimeModeBuiltinGraph, + WorkflowVersionID: 0, }, nil } diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index 9e34f1e..dd1d8fc 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -1,8 +1,10 @@ package services import ( + "encoding/json" "testing" + "agent-desk/internal/ai/workflow/dsl" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -13,52 +15,89 @@ import ( "gorm.io/gorm" ) -func TestAIAgentServiceSavesWorkflowBinding(t *testing.T) { +func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { setupAIAgentWorkflowTestDB(t) operator := aiAgentWorkflowTestOperator() aiConfigID := createAIAgentWorkflowTestConfig(t) knowledgeID := createAIAgentWorkflowTestKnowledgeBase(t) - versionID := createAIAgentWorkflowVersion(t) item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "workflow agent", - AIConfigID: aiConfigID, - ServiceMode: enums.IMConversationServiceModeAIOnly, - HandoffMode: enums.AIAgentHandoffModeWaitPool, - FallbackMode: enums.AIAgentFallbackModeNoAnswer, - KnowledgeIDs: []int64{knowledgeID}, - RuntimeMode: enums.AIAgentRuntimeModeWorkflow, - WorkflowVersionID: versionID, + Name: "workflow agent", + AIConfigID: aiConfigID, + ServiceMode: enums.IMConversationServiceModeAIOnly, + HandoffMode: enums.AIAgentHandoffModeWaitPool, + FallbackMode: enums.AIAgentFallbackModeNoAnswer, + KnowledgeIDs: []int64{knowledgeID}, }, operator) if err != nil { t.Fatalf("CreateAIAgent() error = %v", err) } - if item.RuntimeMode != enums.AIAgentRuntimeModeWorkflow { - t.Fatalf("expected workflow runtime mode, got %d", item.RuntimeMode) + workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator) + if err != nil { + t.Fatalf("GetOrCreateAgentWorkflow() error = %v", err) } - if item.WorkflowVersionID != versionID { - t.Fatalf("expected workflow version %d, got %d", versionID, item.WorkflowVersionID) + if workflow.AgentID != item.ID { + t.Fatalf("expected workflow agent id %d, got %d", item.ID, workflow.AgentID) + } + if workflow.Name != item.Name+" 会话流程" { + t.Fatalf("unexpected workflow name: %s", workflow.Name) + } + var stored dsl.Definition + if err := json.Unmarshal([]byte(workflow.DraftDefinition), &stored); err != nil { + t.Fatalf("unmarshal draft definition: %v", err) + } + if stored.EntryNodeID == "" { + t.Fatalf("expected default draft definition") } } -func TestAIAgentServiceRejectsWorkflowModeWithoutVersion(t *testing.T) { +func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) { setupAIAgentWorkflowTestDB(t) operator := aiAgentWorkflowTestOperator() aiConfigID := createAIAgentWorkflowTestConfig(t) knowledgeID := createAIAgentWorkflowTestKnowledgeBase(t) - _, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ + agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ Name: "workflow agent without version", AIConfigID: aiConfigID, ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, KnowledgeIDs: []int64{knowledgeID}, - RuntimeMode: enums.AIAgentRuntimeModeWorkflow, }, operator) - if err == nil { - t.Fatalf("expected workflow runtime without version to fail") + if err != nil { + t.Fatalf("CreateAIAgent() error = %v", err) + } + workflow, err := AIWorkflowService.SaveAgentWorkflow(request.SaveAIWorkflowRequest{ + AgentID: agent.ID, + Name: "After sales flow", + Description: "Support workflow", + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("SaveAgentWorkflow() error = %v", err) + } + + version, err := AIWorkflowService.PublishAgentWorkflow(request.PublishAIWorkflowRequest{ + AgentID: agent.ID, + Definition: validAIWorkflowDefinition(), + }, operator) + if err != nil { + t.Fatalf("PublishAgentWorkflow() error = %v", err) + } + if version.WorkflowID != workflow.ID { + t.Fatalf("expected version workflow id %d, got %d", workflow.ID, version.WorkflowID) + } + storedAgent := AIAgentService.Get(agent.ID) + if storedAgent == nil { + t.Fatalf("expected stored agent") + } + if storedAgent.RuntimeMode != enums.AIAgentRuntimeModeWorkflow { + t.Fatalf("expected agent runtime workflow, got %d", storedAgent.RuntimeMode) + } + if storedAgent.WorkflowVersionID != version.ID { + t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID) } } @@ -105,10 +144,9 @@ func createAIAgentWorkflowTestKnowledgeBase(t *testing.T) int64 { func createAIAgentWorkflowVersion(t *testing.T) int64 { t.Helper() workflow := &models.AIWorkflow{ - Name: "workflow-test", - OwnerType: "ai_agent", - OwnerID: 1, - Status: enums.StatusOk, + Name: "workflow-test", + AgentID: 1, + Status: enums.StatusOk, } if err := sqls.DB().Create(workflow).Error; err != nil { t.Fatalf("create workflow: %v", err) diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 8f1f89c..f4f7bee 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -20,6 +20,7 @@ import ( "agent-desk/internal/repositories" "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) var AIWorkflowService = newAIWorkflowService() @@ -56,6 +57,49 @@ func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams) return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params) } +func (s *aiWorkflowService) GetByAgentID(agentID int64) *models.AIWorkflow { + if agentID <= 0 { + return nil + } + return repositories.AIWorkflowRepository.Take(sqls.DB(), "agent_id = ? AND status <> ?", agentID, enums.StatusDeleted) +} + +func (s *aiWorkflowService) GetOrCreateAgentWorkflow(agentID int64, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if agentID <= 0 { + return nil, errorsx.InvalidParam("agent id is required") + } + if agent := AIAgentService.Get(agentID); agent == nil || agent.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0002") + } + if item := s.GetByAgentID(agentID); item != nil { + return item, nil + } + var item *models.AIWorkflow + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if current := repositories.AIWorkflowRepository.Take(ctx.Tx, "agent_id = ? AND status <> ?", agentID, enums.StatusDeleted); current != nil { + item = current + return nil + } + agent := repositories.AIAgentRepository.Get(ctx.Tx, agentID) + if agent == nil || agent.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0002") + } + created, err := s.createDefaultAgentWorkflow(ctx.Tx, agent, operator) + if err != nil { + return err + } + item = created + return nil + }) + if err != nil { + return nil, err + } + return item, nil +} + func (s *aiWorkflowService) ListNodeSpecs() []workflowregistry.NodeSpec { return s.registry.List() } @@ -65,37 +109,52 @@ func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalid } func (s *aiWorkflowService) CreateWorkflow(req request.CreateAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) { + return s.SaveAgentWorkflow(req, operator) +} + +func (s *aiWorkflowService) SaveAgentWorkflow(req request.SaveAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) { if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") } + agent := AIAgentService.Get(req.AgentID) + if agent == nil || agent.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0002") + } name := strings.TrimSpace(req.Name) if name == "" { - return nil, errorsx.InvalidParam("workflow name is required") - } - ownerType := normalizeWorkflowOwnerType(req.OwnerType) - if ownerType == "" { - return nil, errorsx.InvalidParam("workflow owner type is required") - } - if req.OwnerID <= 0 { - return nil, errorsx.InvalidParam("workflow owner id is required") + name = defaultAgentWorkflowName(agent.Name) } definition, err := marshalDefinition(req.Definition) if err != nil { return nil, err } - item := &models.AIWorkflow{ - Name: name, - Description: strings.TrimSpace(req.Description), - OwnerType: ownerType, - OwnerID: req.OwnerID, - Status: enums.StatusOk, - DraftDefinition: definition, - AuditFields: utils.BuildAuditFields(operator), + current := s.GetByAgentID(req.AgentID) + if current == nil { + item := &models.AIWorkflow{ + Name: name, + Description: strings.TrimSpace(req.Description), + AgentID: req.AgentID, + Status: enums.StatusOk, + DraftDefinition: definition, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil } - if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil { + if err := repositories.AIWorkflowRepository.Updates(sqls.DB(), current.ID, map[string]interface{}{ + "name": name, + "description": strings.TrimSpace(req.Description), + "agent_id": req.AgentID, + "draft_definition": definition, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { return nil, err } - return item, nil + return s.Get(current.ID), nil } func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, operator *dto.AuthPrincipal) error { @@ -109,12 +168,8 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, if name == "" { return errorsx.InvalidParam("workflow name is required") } - ownerType := normalizeWorkflowOwnerType(req.OwnerType) - if ownerType == "" { - return errorsx.InvalidParam("workflow owner type is required") - } - if req.OwnerID <= 0 { - return errorsx.InvalidParam("workflow owner id is required") + if req.AgentID <= 0 { + return errorsx.InvalidParam("agent id is required") } definition, err := marshalDefinition(req.Definition) if err != nil { @@ -123,8 +178,7 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{ "name": name, "description": strings.TrimSpace(req.Description), - "owner_type": ownerType, - "owner_id": req.OwnerID, + "agent_id": req.AgentID, "draft_definition": definition, "update_user_id": operator.UserID, "update_user_name": operator.Username, @@ -148,6 +202,9 @@ func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal } func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) { + if req.AgentID > 0 { + return s.PublishAgentWorkflow(req, operator) + } if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") } @@ -195,6 +252,106 @@ func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest return version, nil } +func (s *aiWorkflowService) PublishAgentWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + workflow, err := s.GetOrCreateAgentWorkflow(req.AgentID, operator) + if err != nil { + return nil, err + } + req.WorkflowID = workflow.ID + result := s.ValidateDefinition(req.Definition) + if !result.Valid { + return nil, errorsx.InvalidParam("workflow definition is invalid") + } + definition, err := marshalDefinition(req.Definition) + if err != nil { + return nil, err + } + now := time.Now() + var version *models.AIWorkflowVersion + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + current := repositories.AIWorkflowRepository.Get(ctx.Tx, workflow.ID) + if current == nil || current.AgentID != req.AgentID || current.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0002") + } + nextVersion := repositories.AIWorkflowVersionRepository.MaxVersionByWorkflowID(ctx.Tx, current.ID) + 1 + version = &models.AIWorkflowVersion{ + WorkflowID: current.ID, + Version: nextVersion, + Status: enums.StatusOk, + Definition: definition, + DefinitionHash: hashDefinition(definition), + PublishedAt: &now, + PublishedByID: operator.UserID, + PublishedByName: operator.Username, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.AIWorkflowVersionRepository.Create(ctx.Tx, version); err != nil { + return err + } + if err := repositories.AIWorkflowRepository.Updates(ctx.Tx, current.ID, map[string]interface{}{ + "draft_definition": definition, + "published_version_id": version.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return repositories.AIAgentRepository.Updates(ctx.Tx, req.AgentID, map[string]any{ + "runtime_mode": enums.AIAgentRuntimeModeWorkflow, + "workflow_version_id": version.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) + }) + if err != nil { + return nil, err + } + return version, nil +} + +func (s *aiWorkflowService) createDefaultAgentWorkflow(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) { + definition, err := marshalDefinition(defaultAgentWorkflowDefinition()) + if err != nil { + return nil, err + } + item := &models.AIWorkflow{ + Name: defaultAgentWorkflowName(agent.Name), + AgentID: agent.ID, + Status: enums.StatusOk, + DraftDefinition: definition, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.AIWorkflowRepository.Create(db, item); err != nil { + return nil, err + } + return item, nil +} + +func defaultAgentWorkflowDefinition() dsl.Definition { + return dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start_1", + Nodes: []dsl.Node{ + {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start", Position: dsl.Position{X: 0, Y: 80}}, + {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End", Position: dsl.Position{X: 360, Y: 80}}, + }, + Edges: []dsl.Edge{{ID: "edge_start_end", Source: "start_1", Target: "end_1"}}, + } +} + +func defaultAgentWorkflowName(agentName string) string { + agentName = strings.TrimSpace(agentName) + if agentName == "" { + return "会话流程" + } + return agentName + " 会话流程" +} + func marshalDefinition(def dsl.Definition) (string, error) { buf, err := json.Marshal(def) if err != nil { @@ -207,13 +364,3 @@ func hashDefinition(definition string) string { sum := sha256.Sum256([]byte(definition)) return hex.EncodeToString(sum[:]) } - -func normalizeWorkflowOwnerType(ownerType string) string { - ownerType = strings.TrimSpace(ownerType) - switch ownerType { - case "ai_agent", "workspace": - return ownerType - default: - return "" - } -} diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index 6903043..73f33d8 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -8,6 +8,7 @@ import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "github.com/glebarez/sqlite" @@ -45,8 +46,7 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) { workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ Name: "support flow", Description: "customer service flow", - OwnerType: "ai_agent", - OwnerID: 12, + AgentID: 12, Definition: validAIWorkflowDefinition(), }, operator) if err != nil { @@ -88,8 +88,7 @@ func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) { operator := aiWorkflowTestOperator() workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ Name: "support flow versions", - OwnerType: "ai_agent", - OwnerID: 99, + AgentID: 99, Definition: validAIWorkflowDefinition(), }, operator) if err != nil { @@ -121,8 +120,7 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) { operator := aiWorkflowTestOperator() workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{ Name: "invalid publish flow", - OwnerType: "ai_agent", - OwnerID: 23, + AgentID: 23, Definition: validAIWorkflowDefinition(), }, operator) if err != nil { @@ -159,10 +157,15 @@ func setupAIWorkflowTestDB(t *testing.T) { if err != nil { t.Fatalf("open sqlite db: %v", err) } - if err := db.AutoMigrate(&models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil { + if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil { t.Fatalf("auto migrate: %v", err) } sqls.SetDB(db) + for _, id := range []int64{12, 23, 99} { + if err := sqls.DB().Create(&models.AIAgent{ID: id, Name: "agent", Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + } } func validAIWorkflowDefinition() dsl.Definition { diff --git a/web/app/dashboard/ai-agents/_components/edit.tsx b/web/app/dashboard/ai-agents/_components/edit.tsx index 3474fff..8ac111a 100644 --- a/web/app/dashboard/ai-agents/_components/edit.tsx +++ b/web/app/dashboard/ai-agents/_components/edit.tsx @@ -41,14 +41,12 @@ import { Textarea } from "@/components/ui/textarea"; import { fetchAIAgent, fetchAIConfigsAll, - fetchAIWorkflowVersions, fetchAgentTeamsAll, fetchKnowledgeBasesAll, fetchMCPCatalog, fetchSkillDefinitionsAll, type AIAgent, type AIConfig, - type AIWorkflowVersion, type AdminAgentTeam, type CreateAIAgentPayload, type KnowledgeBase, @@ -96,8 +94,6 @@ type EditForm = { description: string; aiConfigId: string; serviceMode: string; - runtimeMode: string; - workflowVersionId: string; systemPrompt: string; welcomeMessage: string; replyTimeoutSeconds: number; @@ -106,9 +102,6 @@ type EditForm = { fallbackMessage: string; }; -const AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH = 1; -const AI_AGENT_RUNTIME_MODE_WORKFLOW = 2; - function getServiceModeOptions(t: TFunction) { return [ { value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") }, @@ -139,8 +132,6 @@ function buildForm(item: AIAgent | null): EditForm { description: "", aiConfigId: "", serviceMode: String(IMConversationServiceMode.AIFirst), - runtimeMode: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), - workflowVersionId: "", systemPrompt: "", welcomeMessage: "", replyTimeoutSeconds: 180, @@ -154,8 +145,6 @@ function buildForm(item: AIAgent | null): EditForm { description: item.description || "", aiConfigId: item.aiConfigId > 0 ? String(item.aiConfigId) : "", serviceMode: String(item.serviceMode), - runtimeMode: String(item.runtimeMode || AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), - workflowVersionId: item.workflowVersionId > 0 ? String(item.workflowVersionId) : "", systemPrompt: item.systemPrompt || "", welcomeMessage: item.welcomeMessage || "", replyTimeoutSeconds: item.replyTimeoutSeconds ?? 180, @@ -178,11 +167,6 @@ function buildPayload( description: form.description.trim(), aiConfigId: Number(form.aiConfigId), serviceMode: Number(form.serviceMode), - runtimeMode: Number(form.runtimeMode), - workflowVersionId: - Number(form.runtimeMode) === AI_AGENT_RUNTIME_MODE_WORKFLOW - ? Number(form.workflowVersionId) - : 0, systemPrompt: form.systemPrompt.trim(), welcomeMessage: form.welcomeMessage.trim(), replyTimeoutSeconds: Number(form.replyTimeoutSeconds), @@ -236,8 +220,6 @@ function EditDialogBody({ description: z.string().trim(), aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")), serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")), - runtimeMode: z.string().trim().min(1, t("aiAgent.runtimeModeRequired")), - workflowVersionId: z.string().trim(), systemPrompt: z.string().trim(), welcomeMessage: z.string().trim(), replyTimeoutSeconds: z @@ -246,18 +228,6 @@ function EditDialogBody({ handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")), fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")), fallbackMessage: z.string().trim(), - }).check((ctx) => { - if ( - ctx.value.runtimeMode === String(AI_AGENT_RUNTIME_MODE_WORKFLOW) && - !/^\d+$/.test(ctx.value.workflowVersionId) - ) { - ctx.issues.push({ - code: "custom", - input: ctx.value.workflowVersionId, - message: t("aiAgent.workflowVersionRequired"), - path: ["workflowVersionId"], - }); - } }), [t], ); @@ -266,19 +236,6 @@ function EditDialogBody({ [schema], ); const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]); - const runtimeModeOptions = useMemo( - () => [ - { - value: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), - label: t("aiAgent.runtimeBuiltinGraph"), - }, - { - value: String(AI_AGENT_RUNTIME_MODE_WORKFLOW), - label: t("aiAgent.runtimeWorkflow"), - }, - ], - [t], - ); const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]); const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]); const form = useForm({ @@ -305,7 +262,6 @@ function EditDialogBody({ const [directToolToAdd, setDirectToolToAdd] = useState(""); const [graphToolToAdd, setGraphToolToAdd] = useState(""); const [aiConfigs, setAIConfigs] = useState([]); - const [workflowVersions, setWorkflowVersions] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [agentTeams, setAgentTeams] = useState([]); const [skills, setSkills] = useState([]); @@ -390,23 +346,6 @@ function EditDialogBody({ void loadAgentTeams(); }, [t]); - useEffect(() => { - async function loadWorkflowVersions() { - try { - const data = await fetchAIWorkflowVersions({ - page: 1, - limit: 1000, - }); - setWorkflowVersions(data.results ?? []); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("aiAgent.loadWorkflowVersionsFailed"), - ); - } - } - void loadWorkflowVersions(); - }, [t]); - useEffect(() => { async function loadKnowledgeBases() { try { @@ -497,15 +436,6 @@ function EditDialogBody({ [agentTeams], ); - const workflowVersionOptions = useMemo( - () => - workflowVersions.map((item) => ({ - value: String(item.id), - label: `Workflow #${item.workflowId} · v${item.version}`, - })), - [workflowVersions], - ); - const knowledgeOptions = useMemo( () => knowledgeBases.map((item) => ({ @@ -626,7 +556,6 @@ function EditDialogBody({ ); const handoffMode = watch("handoffMode"); - const runtimeMode = watch("runtimeMode"); const selectedHandoffModeLabel = handoffModeOptions.find((item) => item.value === handoffMode)?.label ?? t("aiAgent.notSelected"); @@ -818,56 +747,6 @@ function EditDialogBody({ -
- - {t("aiAgent.runtimeMode")} - - ( - - )} - /> - - - - - - {t("aiAgent.workflowVersion")} - - ( - - )} - /> - - - -
- {t("aiAgent.description")} diff --git a/web/app/dashboard/ai-agents/page.tsx b/web/app/dashboard/ai-agents/page.tsx index 3497222..d0c95fb 100644 --- a/web/app/dashboard/ai-agents/page.tsx +++ b/web/app/dashboard/ai-agents/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { BotMessageSquareIcon, PowerIcon } from "lucide-react"; +import { BotMessageSquareIcon, GitBranchIcon, PowerIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { @@ -62,6 +63,7 @@ function getNextStatus(item: AIAgent) { export default function DashboardAIAgentsPage() { const t = useI18n(); + const router = useRouter(); const statusOptions = useMemo(() => getStatusOptions(t), [t]); const filters = useMemo( @@ -235,6 +237,14 @@ export default function DashboardAIAgentsPage() { updateItem={(item, payload) => updateAIAgent({ id: item.id, ...payload })} deleteItem={(item) => deleteAIAgent(item.id)} rowActions={[ + { + key: "workflow", + icon: , + label: t("aiAgent.workflow"), + run: ({ item }) => { + router.push(`/dashboard/ai-agents/workflow?agentId=${item.id}`); + }, + }, createDashboardStatusToggleAction({ icon: , label: (item) => diff --git a/web/app/dashboard/ai-workflows/page.tsx b/web/app/dashboard/ai-agents/workflow/page.tsx similarity index 57% rename from web/app/dashboard/ai-workflows/page.tsx rename to web/app/dashboard/ai-agents/workflow/page.tsx index 4719ce1..9a0c91f 100644 --- a/web/app/dashboard/ai-workflows/page.tsx +++ b/web/app/dashboard/ai-agents/workflow/page.tsx @@ -1,7 +1,8 @@ "use client" import { useCallback, useEffect, useMemo, useState } from "react" -import { CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { ArrowLeftIcon, CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" @@ -10,18 +11,19 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { - createAIWorkflow, + fetchAIAgent, + fetchAIAgentWorkflow, fetchAIWorkflowNodeSpecs, - fetchAIWorkflows, - publishAIWorkflow, - updateAIWorkflow, + publishAIAgentWorkflow, + saveAIAgentWorkflow, validateAIWorkflow, + type AIAgent, type AIWorkflow, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type AIWorkflowValidationResult, } from "@/lib/api/admin" -import { WorkflowEditor } from "./_components/workflow-editor" +import { WorkflowEditor } from "../../ai-workflows/_components/workflow-editor" const emptyDefinition: AIWorkflowDefinition = { schemaVersion: 1, @@ -45,73 +47,68 @@ const emptyDefinition: AIWorkflowDefinition = { edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }], } -export default function DashboardAIWorkflowsPage() { - const [workflows, setWorkflows] = useState([]) +function readAgentIdFromLocation() { + if (typeof window === "undefined") { + return 0 + } + return Number(new URLSearchParams(window.location.search).get("agentId")) +} + +export default function DashboardAIAgentWorkflowPage() { + const router = useRouter() + const [agentId] = useState(() => readAgentIdFromLocation()) + const [agent, setAgent] = useState(null) + const [workflow, setWorkflow] = useState(null) const [nodeSpecs, setNodeSpecs] = useState([]) - const [selected, setSelected] = useState(null) - const [name, setName] = useState("Customer support flow") + const [name, setName] = useState("") const [description, setDescription] = useState("") - const [ownerId, setOwnerId] = useState("1") const [definition, setDefinition] = useState(emptyDefinition) const [validation, setValidation] = useState(null) const [loading, setLoading] = useState(false) const editorKey = useMemo( - () => `${selected?.id ?? "new"}-${selected?.updatedAt ?? ""}`, - [selected?.id, selected?.updatedAt] + () => `${workflow?.id ?? "new"}-${workflow?.updatedAt ?? ""}`, + [workflow?.id, workflow?.updatedAt] ) const loadData = useCallback(async () => { - const [workflowPage, specs] = await Promise.all([ - fetchAIWorkflows({ page: 1, limit: 50, status: 0 }), + if (!Number.isFinite(agentId) || agentId <= 0) { + return + } + const [agentDetail, workflowDetail, specs] = await Promise.all([ + fetchAIAgent(agentId), + fetchAIAgentWorkflow(agentId), fetchAIWorkflowNodeSpecs(), ]) - setWorkflows(workflowPage?.results ?? []) + setAgent(agentDetail) + setWorkflow(workflowDetail) setNodeSpecs(specs ?? []) - }, []) + setName(workflowDetail.name || `${agentDetail.name} 会话流程`) + setDescription(workflowDetail.description || "") + setDefinition(workflowDetail.draftDefinition ?? emptyDefinition) + setValidation(null) + }, [agentId]) useEffect(() => { void loadData().catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to load workflows") + toast.error(error instanceof Error ? error.message : "Failed to load workflow") }) }, [loadData]) - const selectWorkflow = (workflow: AIWorkflow) => { - setSelected(workflow) - setName(workflow.name) - setDescription(workflow.description) - setOwnerId(String(workflow.ownerId || 1)) - setDefinition(workflow.draftDefinition ?? emptyDefinition) - setValidation(null) - } - - const createNew = () => { - setSelected(null) - setName("Customer support flow") - setDescription("") - setOwnerId("1") - setDefinition(emptyDefinition) - setValidation(null) - } - const saveDraft = async () => { + if (!Number.isFinite(agentId) || agentId <= 0) { + toast.error("Invalid AI Agent.") + return + } setLoading(true) try { - const payload = { + const saved = await saveAIAgentWorkflow({ + agentId, name, description, - ownerType: "ai_agent", - ownerId: Number(ownerId) || 0, definition, - } - if (selected) { - await updateAIWorkflow({ id: selected.id, ...payload }) - toast.success("Draft saved") - } else { - const created = await createAIWorkflow(payload) - setSelected(created) - toast.success("Workflow created") - } - await loadData() + }) + setWorkflow(saved) + toast.success("Draft saved") } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to save workflow") } finally { @@ -135,13 +132,20 @@ export default function DashboardAIWorkflowsPage() { } const publish = async () => { - if (!selected) { - toast.error("Save the workflow before publishing.") + if (!Number.isFinite(agentId) || agentId <= 0) { + toast.error("Invalid AI Agent.") return } setLoading(true) try { - const version = await publishAIWorkflow(selected.id, definition) + const saved = await saveAIAgentWorkflow({ + agentId, + name, + description, + definition, + }) + setWorkflow(saved) + const version = await publishAIAgentWorkflow(agentId, definition) toast.success(`Published version ${version.version}`) await loadData() } catch (error) { @@ -154,16 +158,20 @@ export default function DashboardAIWorkflowsPage() { return (
-
-

AI Workflows

-

- Edit and publish customer-service conversation flows. -

+
+ +
+

+ {agent ? `${agent.name} · 会话流程` : "AI Agent Workflow"} +

+

+ Edit and publish this Agent's customer-service conversation flow. +

+
- - @@ -189,16 +197,6 @@ export default function DashboardAIWorkflowsPage() { onChange={(event) => setName(event.target.value)} />
-
- - setOwnerId(event.target.value)} - /> -