feat: Implement AI Agent Workflow Binding functionality

- Added aiAgentWorkflowBindingRepository for managing workflow bindings associated with AI agents.
- Enhanced agentRevisionService to include workflow bindings in agent revisions.
- Updated aIAgentService to handle workflow bindings during agent creation and updates.
- Introduced ai_agent_workflow_binding_service for managing workflow binding logic.
- Created new API endpoints for fetching, creating, updating, and deleting AI workflows.
- Developed a new dashboard page for managing AI workflows.
- Updated frontend components to support workflow binding management in agent configuration.
- Added necessary tests for workflow binding functionality and updated existing tests for compatibility.
- Translated relevant UI texts and messages for workflow management.
This commit is contained in:
mlogclub
2026-07-25 15:24:49 +08:00
parent e4ad83dc20
commit 7b86fd1f09
21 changed files with 590 additions and 136 deletions
+21 -7
View File
@@ -38,10 +38,19 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi
}
type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowDefinition string `json:"workflowDefinition"`
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowDefinition string `json:"workflowDefinition"`
WorkflowBindings []agentRevisionWorkflowBinding `json:"workflowBindings"`
}
type agentRevisionWorkflowBinding struct {
WorkflowID int64 `json:"workflowId"`
WorkflowVersionID int64 `json:"workflowVersionId"`
ToolName string `json:"toolName"`
TriggerInstruction string `json:"triggerInstruction"`
Priority int `json:"priority"`
}
// agentRevisionModel deliberately excludes APIKey. A revision must capture
@@ -84,9 +93,10 @@ type agentRevisionAgent struct {
// a published revision. Model credentials deliberately remain on the current
// AIConfig so credential rotation does not require republishing every Agent.
type AgentRevisionSnapshot struct {
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
WorkflowBindings []agentRevisionWorkflowBinding
}
// ResolvePublishedSnapshot restores a published Agent revision for runtime
@@ -112,6 +122,7 @@ func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, co
return nil, errorsx.InvalidParam("published agent model config no longer matches")
}
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
if definition.WorkflowVersionID > 0 {
snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID
}
@@ -196,6 +207,9 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
WorkflowVersionID: workflowVersionID,
WorkflowDefinition: workflowDefinition,
}
for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) {
definition.WorkflowBindings = append(definition.WorkflowBindings, agentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority})
}
data, err := json.Marshal(definition)
if err != nil {
return nil, err
+44 -6
View File
@@ -83,11 +83,18 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
return err
}
if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
if err != nil {
return err
}
return nil
if len(bindings) == 0 && (item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid) {
_, createErr := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
if createErr != nil {
return createErr
}
return nil // Legacy create calls remain compatible until clients send explicit bindings.
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
}); err != nil {
return nil, err
}
@@ -139,7 +146,35 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
// behavior. The operator must explicitly publish the new revision.
columns["published_revision_id"] = 0
}
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns)
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
return err
}
bindings, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
if err != nil {
return err
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
})
}
func (s *aIAgentService) validateWorkflowBindingMode(db *gorm.DB, agent *models.AIAgent, bindings []models.AIAgentWorkflowBinding) error {
enabled := make([]models.AIAgentWorkflowBinding, 0, len(bindings))
for _, binding := range bindings {
if binding.Enabled {
enabled = append(enabled, binding)
}
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous {
return nil
}
if len(enabled) == 0 {
return errorsx.InvalidParam("workflow and hybrid agents require at least one enabled workflow")
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow && len(enabled) != 1 {
return errorsx.InvalidParam("workflow agent requires exactly one enabled workflow")
}
return repositories.AIAgentRepository.Updates(db, agent.ID, map[string]any{"workflow_version_id": enabled[0].WorkflowVersionID})
}
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
@@ -169,12 +204,15 @@ func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
return errorsx.InvalidParam("workflow and hybrid agents must publish a workflow version")
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
return errorsx.InvalidParam("workflow agents publish through their selected workflow version")
}
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid && len(s.ListEnabledWorkflowBindings(ctx.Tx, agent.ID)) == 0 {
return errorsx.InvalidParam("hybrid agent requires at least one published workflow")
}
var err error
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
if err != nil {
@@ -0,0 +1,70 @@
package services
import (
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
type AIAgentWorkflowBindingContext struct {
Binding models.AIAgentWorkflowBinding
Workflow *models.AIWorkflow
Version *models.AIWorkflowVersion
}
func (s *aIAgentService) ListWorkflowBindings(agentID int64) []AIAgentWorkflowBindingContext {
bindings := repositories.AIAgentWorkflowBindingRepository.FindByAgentID(sqls.DB(), agentID)
return s.buildWorkflowBindingContexts(sqls.DB(), bindings)
}
func (s *aIAgentService) ListEnabledWorkflowBindings(db *gorm.DB, agentID int64) []AIAgentWorkflowBindingContext {
return s.buildWorkflowBindingContexts(db, repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agentID))
}
func (s *aIAgentService) buildWorkflowBindingContexts(db *gorm.DB, bindings []models.AIAgentWorkflowBinding) []AIAgentWorkflowBindingContext {
ret := make([]AIAgentWorkflowBindingContext, 0, len(bindings))
for _, binding := range bindings {
ret = append(ret, AIAgentWorkflowBindingContext{Binding: binding, Workflow: repositories.AIWorkflowRepository.Get(db, binding.WorkflowID), Version: repositories.AIWorkflowVersionRepository.Get(db, binding.WorkflowVersionID)})
}
return ret
}
func (s *aIAgentService) replaceWorkflowBindings(db *gorm.DB, agentID int64, input []request.AIAgentWorkflowBindingRequest, operator *dto.AuthPrincipal) ([]models.AIAgentWorkflowBinding, error) {
seen := make(map[int64]struct{}, len(input))
items := make([]models.AIAgentWorkflowBinding, 0, len(input))
for index, item := range input {
if item.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("workflow version is required")
}
if _, exists := seen[item.WorkflowVersionID]; exists {
return nil, errorsx.InvalidParam("workflow version must not be bound more than once")
}
seen[item.WorkflowVersionID] = struct{}{}
version := repositories.AIWorkflowVersionRepository.Get(db, item.WorkflowVersionID)
if version == nil || version.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("workflow version is not published")
}
workflow := repositories.AIWorkflowRepository.Get(db, version.WorkflowID)
if workflow == nil || workflow.Status == enums.StatusDeleted {
return nil, errorsx.InvalidParam("workflow does not exist")
}
priority := item.Priority
if priority == 0 {
priority = index + 1
}
items = append(items, models.AIAgentWorkflowBinding{AIAgentID: agentID, WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ToolName: strings.TrimSpace(item.ToolName), TriggerInstruction: strings.TrimSpace(item.TriggerInstruction), Priority: priority, Enabled: item.Enabled, AuditFields: utils.BuildAuditFields(operator)})
}
if err := repositories.AIAgentWorkflowBindingRepository.ReplaceByAgentID(db, agentID, items); err != nil {
return nil, err
}
return items, nil
}
@@ -463,13 +463,46 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
}
}
func TestAIAgentServiceBindsPublishedWorkflowVersionIndependently(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{Name: "共享建单流程", Definition: validAIWorkflowDefinition()}, operator)
if err != nil {
t.Fatalf("CreateWorkflow() error = %v", err)
}
version, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{WorkflowID: workflow.ID, Definition: validAIWorkflowDefinition()}, operator)
if err != nil {
t.Fatalf("PublishWorkflow() error = %v", err)
}
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "绑定共享工作流的 Agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
WorkflowBindings: []request.AIAgentWorkflowBindingRequest{{WorkflowVersionID: version.ID, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单", Enabled: true}},
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
bindings := AIAgentService.ListWorkflowBindings(agent.ID)
if len(bindings) != 1 || bindings[0].Binding.WorkflowVersionID != version.ID || bindings[0].Workflow == nil || bindings[0].Workflow.AgentID != 0 {
t.Fatalf("unexpected independent workflow binding: %#v", bindings)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("PublishAIAgent() error = %v", err)
}
stored := AIAgentService.Get(agent.ID)
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(*stored, *AIConfigService.Get(stored.AIConfigID))
if err != nil || len(snapshot.WorkflowBindings) != 1 || snapshot.WorkflowBindings[0].WorkflowVersionID != version.ID {
t.Fatalf("expected published workflow binding snapshot, snapshot=%#v err=%v", snapshot, err)
}
}
func setupAIAgentWorkflowTestDB(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AgentRevision{}); err != nil {
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
+19 -5
View File
@@ -246,7 +246,22 @@ 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)
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errorsx.InvalidParam("workflow name is required")
}
definition, err := marshalDefinition(req.Definition)
if err != nil {
return nil, err
}
item := &models.AIWorkflow{Name: name, Description: strings.TrimSpace(req.Description), 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
}
func (s *aiWorkflowService) SaveAgentWorkflow(req request.SaveAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
@@ -305,9 +320,6 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest,
if name == "" {
return errorsx.InvalidParam("workflow name is required")
}
if req.AgentID <= 0 {
return errorsx.InvalidParam("agent id is required")
}
definition, err := marshalDefinition(req.Definition)
if err != nil {
return err
@@ -315,7 +327,6 @@ 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),
"agent_id": req.AgentID,
"draft_definition": definition,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -330,6 +341,9 @@ func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal
if s.Get(id) == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
if repositories.AIAgentWorkflowBindingRepository.CountByWorkflowID(sqls.DB(), id) > 0 {
return errorsx.InvalidParam("workflow is still associated with an agent")
}
return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
@@ -283,7 +283,7 @@ func setupAIWorkflowTestDB(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil {
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
+1 -1
View File
@@ -183,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil {
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.AIAgentWorkflowBinding{}, &models.Channel{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)