From 7b86fd1f0901d2ed578db118fc0e115cc9ea8b3b Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 25 Jul 2026 15:24:49 +0800 Subject: [PATCH] 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. --- .../ai/application/runtime/hybrid_engine.go | 62 ++++++++--- internal/bootstrap/routes.go | 5 + .../handlers/dashboard/ai_agent_handler.go | 11 ++ internal/models/models.go | 16 +++ internal/pkg/dto/request/ai_request.go | 9 ++ internal/pkg/dto/response/ai_response.go | 93 +++++++++------- .../ai_agent_workflow_binding_repository.go | 49 +++++++++ internal/services/agent_revision_service.go | 28 +++-- internal/services/ai_agent_service.go | 50 +++++++-- .../ai_agent_workflow_binding_service.go | 70 ++++++++++++ .../ai_agent_workflow_service_test.go | 35 +++++- internal/services/ai_workflow_service.go | 24 ++++- internal/services/ai_workflow_service_test.go | 2 +- internal/services/channel_service_test.go | 2 +- .../_components/config-workbench.tsx | 102 ++++++++---------- web/app/dashboard/ai-agents/page.tsx | 4 +- web/app/dashboard/ai-workflows/page.tsx | 99 +++++++++++++++++ web/lib/api/admin.ts | 57 +++++++++- web/lib/navigation.tsx | 6 ++ web/messages/en-US.json | 1 + web/messages/zh-CN.json | 1 + 21 files changed, 590 insertions(+), 136 deletions(-) create mode 100644 internal/repositories/ai_agent_workflow_binding_repository.go create mode 100644 internal/services/ai_agent_workflow_binding_service.go create mode 100644 web/app/dashboard/ai-workflows/page.tsx diff --git a/internal/ai/application/runtime/hybrid_engine.go b/internal/ai/application/runtime/hybrid_engine.go index dd1e030..a4d19af 100644 --- a/internal/ai/application/runtime/hybrid_engine.go +++ b/internal/ai/application/runtime/hybrid_engine.go @@ -49,26 +49,42 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error return nil, err } req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig - if req.AIAgent.WorkflowVersionID <= 0 { - return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow") + workflowVersionIDs := make([]int64, 0, len(snapshot.WorkflowBindings)) + workflowTools := make([]ai.ToolDefinition, 0, len(snapshot.WorkflowBindings)) + for _, binding := range snapshot.WorkflowBindings { + if binding.WorkflowVersionID <= 0 { + continue + } + versionAgent := req.AIAgent + versionAgent.WorkflowVersionID = binding.WorkflowVersionID + workflow, resolveErr := resolveAgentWorkflow(versionAgent) + if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid { + return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid") + } + workflowVersionIDs = append(workflowVersionIDs, binding.WorkflowVersionID) + workflowTools = append(workflowTools, hybridWorkflowToolDefinition(binding.WorkflowVersionID, binding.ToolName, binding.TriggerInstruction)) } - workflow, err := resolveAgentWorkflow(req.AIAgent) - if err != nil { - return nil, err + if len(workflowVersionIDs) == 0 && req.AIAgent.WorkflowVersionID > 0 { + workflow, resolveErr := resolveAgentWorkflow(req.AIAgent) + if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid { + return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid") + } + workflowVersionIDs = append(workflowVersionIDs, req.AIAgent.WorkflowVersionID) + workflowTools = append(workflowTools, hybridWorkflowToolDefinition(req.AIAgent.WorkflowVersionID, "", "")) } - if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid { - return nil, errorsx.InvalidParam("hybrid agent playbook validation failed") + if len(workflowVersionIDs) == 0 { + return nil, errorsx.InvalidParam("hybrid agent requires a published workflow") } turn := e.autonomous.prepareTurn(ctx, req) if turn.ResponsePolicy.Enforced { return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, nil, turn.ResponsePolicy, nil) } - turn.SystemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions." + turn.SystemPrompt += "\n\nWhen a deterministic business process is required, use the matching workflow tool. Do not call workflows for ordinary factual questions." var playbookSummary *Summary toolCalls := make([]svc.EngineToolCallInput, 0, 1) - loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) { + loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, workflowTools, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) { if call.Name != "run_playbook" { return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name) } @@ -79,7 +95,7 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error if err != nil { return "", err } - if workflowVersionID != req.AIAgent.WorkflowVersionID { + if !containsWorkflowVersion(workflowVersionIDs, workflowVersionID) { return "", fmt.Errorf("playbook is not allowed") } playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1} @@ -93,7 +109,9 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error return "", err } callStartedAt := time.Now() - playbookSummary, err = e.workflow.Run(ctx, req) + workflowReq := req + workflowReq.AIAgent.WorkflowVersionID = workflowVersionID + playbookSummary, err = e.workflow.Run(ctx, workflowReq) toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())} if err != nil { toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error() @@ -143,12 +161,28 @@ func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, return summary, nil } -func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition { - return ai.ToolDefinition{Name: "run_playbook", Description: "Run the Agent's published deterministic Playbook when the customer needs a controlled business action.", Parameters: map[string]any{ - "type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"}, +func hybridWorkflowToolDefinition(workflowVersionID int64, toolName, instruction string) ai.ToolDefinition { + description := "Run this Agent's published deterministic workflow when the customer needs the controlled business action." + if strings.TrimSpace(toolName) != "" { + description += " Workflow: " + toolName + "." + } + if strings.TrimSpace(instruction) != "" { + description += " Use when: " + instruction + } + return ai.ToolDefinition{Name: "run_playbook", Description: description, Parameters: map[string]any{ + "type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": fmt.Sprintf("The allowed workflow version (%d).", workflowVersionID)}}, "required": []string{"workflowVersionId"}, }} } +func containsWorkflowVersion(items []int64, value int64) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + func parseHybridPlaybookCall(raw string) (int64, error) { var input struct { WorkflowVersionID int64 `json:"workflowVersionId"` diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 8e16fa5..55c056b 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -233,6 +233,11 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { } func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { + group.GET("/:id", dashboard.AIWorkflowGetBy) + group.POST("/create", dashboard.AIWorkflowPostCreate) + group.POST("/update", dashboard.AIWorkflowPostUpdate) + group.POST("/delete", dashboard.AIWorkflowPostDelete) + group.Any("/list", dashboard.AIWorkflowAnyList) group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition) group.GET("/template/list", dashboard.AIWorkflowGetTemplateList) diff --git a/internal/handlers/dashboard/ai_agent_handler.go b/internal/handlers/dashboard/ai_agent_handler.go index 22fe473..fb5091a 100644 --- a/internal/handlers/dashboard/ai_agent_handler.go +++ b/internal/handlers/dashboard/ai_agent_handler.go @@ -271,6 +271,7 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons Skills: make([]response.AIAgentSkillResponse, 0), Teams: make([]response.AIAgentTeamResponse, 0), DirectTools: make([]response.AIAgentMCPToolResponse, 0), + WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 0), WorkflowVersionID: item.WorkflowVersionID, PublishedRevisionID: item.PublishedRevisionID, WorkflowPublished: item.WorkflowVersionID > 0, @@ -348,6 +349,16 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons } } } + for _, binding := range services.AIAgentService.ListWorkflowBindings(item.ID) { + if binding.Workflow == nil || binding.Version == nil { + continue + } + ret.WorkflowBindings = append(ret.WorkflowBindings, response.AIAgentWorkflowBindingResponse{ + ID: binding.Binding.ID, WorkflowID: binding.Binding.WorkflowID, WorkflowVersionID: binding.Binding.WorkflowVersionID, + WorkflowName: binding.Workflow.Name, WorkflowVersion: binding.Version.Version, ToolName: binding.Binding.ToolName, + TriggerInstruction: binding.Binding.TriggerInstruction, Priority: binding.Binding.Priority, Enabled: binding.Binding.Enabled, + }) + } return ret } diff --git a/internal/models/models.go b/internal/models/models.go index 54acc89..dedaff5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -65,6 +65,7 @@ var Models = []any{ &AgentRunQualityFeedback{}, &AIWorkflow{}, &AIWorkflowVersion{}, + &AIAgentWorkflowBinding{}, &AIWorkflowRun{}, &AIWorkflowNodeRun{}, &ConversationInterrupt{}, @@ -664,6 +665,21 @@ type AIWorkflowVersion struct { AuditFields } +// AIAgentWorkflowBinding binds an Agent to one immutable, published workflow +// version. Workflows are maintained independently and can be reused by many +// Agents; Agent publication snapshots the binding set for reproducible runs. +type AIAgentWorkflowBinding struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + AIAgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_workflow_binding"` + WorkflowID int64 `gorm:"type:bigint;not null;index"` + WorkflowVersionID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_workflow_binding"` + ToolName string `gorm:"type:varchar(100);not null;default:''"` + TriggerInstruction string `gorm:"type:text"` + Priority int `gorm:"type:int;not null;default:0;index"` + Enabled bool `gorm:"not null;default:true;index"` + AuditFields +} + // AIWorkflowRun 表示一次会话 workflow 执行记录。 type AIWorkflowRun struct { ID int64 `gorm:"primaryKey;autoIncrement"` diff --git a/internal/pkg/dto/request/ai_request.go b/internal/pkg/dto/request/ai_request.go index 0411a0b..33ee715 100644 --- a/internal/pkg/dto/request/ai_request.go +++ b/internal/pkg/dto/request/ai_request.go @@ -11,6 +11,14 @@ type AIAgentMCPToolRequest struct { Arguments map[string]string `json:"arguments"` } +type AIAgentWorkflowBindingRequest struct { + WorkflowVersionID int64 `json:"workflowVersionId"` + ToolName string `json:"toolName"` + TriggerInstruction string `json:"triggerInstruction"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` +} + type CreateAIConfigRequest struct { Name string `json:"name"` Provider enums.AIProvider `json:"provider"` @@ -63,6 +71,7 @@ type CreateAIAgentRequest struct { KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` SkillIDs []int64 `json:"skillIds"` DirectTools []AIAgentMCPToolRequest `json:"directTools"` + WorkflowBindings []AIAgentWorkflowBindingRequest `json:"workflowBindings"` } type UpdateAIAgentRequest struct { diff --git a/internal/pkg/dto/response/ai_response.go b/internal/pkg/dto/response/ai_response.go index 0ea661e..97c8db2 100644 --- a/internal/pkg/dto/response/ai_response.go +++ b/internal/pkg/dto/response/ai_response.go @@ -24,6 +24,18 @@ type AIAgentMCPToolResponse struct { Arguments map[string]string `json:"arguments"` } +type AIAgentWorkflowBindingResponse struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflowId"` + WorkflowVersionID int64 `json:"workflowVersionId"` + WorkflowName string `json:"workflowName"` + WorkflowVersion int `json:"workflowVersion"` + ToolName string `json:"toolName"` + TriggerInstruction string `json:"triggerInstruction"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` +} + type AgentRevisionResponse struct { ID int64 `json:"id"` AgentID int64 `json:"agentId"` @@ -79,44 +91,45 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse { } type AIAgentResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Status enums.Status `json:"status"` - StatusName string `json:"statusName"` - AIConfigID int64 `json:"aiConfigId"` - AIConfigName string `json:"aiConfigName"` - RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"` - RuntimeModeName string `json:"runtimeModeName"` - MaxSteps int `json:"maxSteps"` - ContextWindow int `json:"contextWindow"` - ToolPolicy string `json:"toolPolicy"` - KnowledgePolicy string `json:"knowledgePolicy"` - ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` - ServiceModeName string `json:"serviceModeName"` - SystemPrompt string `json:"systemPrompt"` - WelcomeMessage string `json:"welcomeMessage"` - ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` - RolloutPercent int `json:"rolloutPercent"` - PreviousRolloutPercent int `json:"previousRolloutPercent"` - Teams []AIAgentTeamResponse `json:"teams"` - HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` - HandoffModeName string `json:"handoffModeName"` - FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` - FallbackModeName string `json:"fallbackModeName"` - FallbackMessage string `json:"fallbackMessage"` - KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` - SkillIDs []int64 `json:"skillIds"` - Skills []AIAgentSkillResponse `json:"skills"` - DirectTools []AIAgentMCPToolResponse `json:"directTools"` - WorkflowVersionID int64 `json:"workflowVersionId"` - PublishedRevisionID int64 `json:"publishedRevisionId"` - WorkflowPublished bool `json:"workflowPublished"` - WorkflowState string `json:"workflowState"` - WorkflowStateText string `json:"workflowStateText"` - SortNo int `json:"sortNo"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CreateUserName string `json:"createUserName"` - UpdateUserName string `json:"updateUserName"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status enums.Status `json:"status"` + StatusName string `json:"statusName"` + AIConfigID int64 `json:"aiConfigId"` + AIConfigName string `json:"aiConfigName"` + RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"` + RuntimeModeName string `json:"runtimeModeName"` + MaxSteps int `json:"maxSteps"` + ContextWindow int `json:"contextWindow"` + ToolPolicy string `json:"toolPolicy"` + KnowledgePolicy string `json:"knowledgePolicy"` + ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` + ServiceModeName string `json:"serviceModeName"` + SystemPrompt string `json:"systemPrompt"` + WelcomeMessage string `json:"welcomeMessage"` + ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` + RolloutPercent int `json:"rolloutPercent"` + PreviousRolloutPercent int `json:"previousRolloutPercent"` + Teams []AIAgentTeamResponse `json:"teams"` + HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` + HandoffModeName string `json:"handoffModeName"` + FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` + FallbackModeName string `json:"fallbackModeName"` + FallbackMessage string `json:"fallbackMessage"` + KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` + SkillIDs []int64 `json:"skillIds"` + Skills []AIAgentSkillResponse `json:"skills"` + DirectTools []AIAgentMCPToolResponse `json:"directTools"` + WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"` + WorkflowVersionID int64 `json:"workflowVersionId"` + PublishedRevisionID int64 `json:"publishedRevisionId"` + WorkflowPublished bool `json:"workflowPublished"` + WorkflowState string `json:"workflowState"` + WorkflowStateText string `json:"workflowStateText"` + SortNo int `json:"sortNo"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CreateUserName string `json:"createUserName"` + UpdateUserName string `json:"updateUserName"` } diff --git a/internal/repositories/ai_agent_workflow_binding_repository.go b/internal/repositories/ai_agent_workflow_binding_repository.go new file mode 100644 index 0000000..bd13b0e --- /dev/null +++ b/internal/repositories/ai_agent_workflow_binding_repository.go @@ -0,0 +1,49 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +var AIAgentWorkflowBindingRepository = newAIAgentWorkflowBindingRepository() + +func newAIAgentWorkflowBindingRepository() *aiAgentWorkflowBindingRepository { + return &aiAgentWorkflowBindingRepository{} +} + +type aiAgentWorkflowBindingRepository struct{} + +func (r *aiAgentWorkflowBindingRepository) FindByAgentID(db *gorm.DB, agentID int64) []models.AIAgentWorkflowBinding { + ret := make([]models.AIAgentWorkflowBinding, 0) + if agentID > 0 { + db.Where("ai_agent_id = ?", agentID).Order("priority ASC, id ASC").Find(&ret) + } + return ret +} + +func (r *aiAgentWorkflowBindingRepository) FindEnabledByAgentID(db *gorm.DB, agentID int64) []models.AIAgentWorkflowBinding { + ret := make([]models.AIAgentWorkflowBinding, 0) + if agentID > 0 { + db.Where("ai_agent_id = ? AND enabled = ?", agentID, true).Order("priority ASC, id ASC").Find(&ret) + } + return ret +} + +func (r *aiAgentWorkflowBindingRepository) CountByWorkflowID(db *gorm.DB, workflowID int64) int64 { + var count int64 + if workflowID > 0 { + db.Model(&models.AIAgentWorkflowBinding{}).Where("workflow_id = ?", workflowID).Count(&count) + } + return count +} + +func (r *aiAgentWorkflowBindingRepository) ReplaceByAgentID(db *gorm.DB, agentID int64, items []models.AIAgentWorkflowBinding) error { + if err := db.Where("ai_agent_id = ?", agentID).Delete(&models.AIAgentWorkflowBinding{}).Error; err != nil { + return err + } + if len(items) == 0 { + return nil + } + return db.Create(&items).Error +} diff --git a/internal/services/agent_revision_service.go b/internal/services/agent_revision_service.go index 9da5110..05d5264 100644 --- a/internal/services/agent_revision_service.go +++ b/internal/services/agent_revision_service.go @@ -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 diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index 4ad82f5..df5e8b1 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -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 { diff --git a/internal/services/ai_agent_workflow_binding_service.go b/internal/services/ai_agent_workflow_binding_service.go new file mode 100644 index 0000000..50a5024 --- /dev/null +++ b/internal/services/ai_agent_workflow_binding_service.go @@ -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 +} diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index 6d0358c..9fadaa6 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -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) diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 20be75a..c1a44c2 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -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, diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index 28bc28c..8822c03 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -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) diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go index fca0b5e..8a46796 100644 --- a/internal/services/channel_service_test.go +++ b/internal/services/channel_service_test.go @@ -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) diff --git a/web/app/dashboard/ai-agents/_components/config-workbench.tsx b/web/app/dashboard/ai-agents/_components/config-workbench.tsx index 02b26da..a1b74ea 100644 --- a/web/app/dashboard/ai-agents/_components/config-workbench.tsx +++ b/web/app/dashboard/ai-agents/_components/config-workbench.tsx @@ -45,6 +45,7 @@ import { fetchAIWorkflowNodeSpecs, fetchAIWorkflowTemplates, fetchAIWorkflowVersions, + fetchAIWorkflows, fetchAgentTeamsAll, fetchMCPCatalog, fetchSkillDefinitionsAll, @@ -56,9 +57,11 @@ import { updateAIAgent, validateAIWorkflow, type AIAgent, + type AIAgentWorkflowBindingInput, type AgentRevision, type AIConfig, type AIWorkflowDefinition, + type AIWorkflow, type AIWorkflowNodeSpec, type AIWorkflowTemplate, type AIWorkflowVersion, @@ -161,6 +164,9 @@ export function AIAgentConfigWorkbench({ const [selectedSkillIds, setSelectedSkillIds] = useState([]) const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]) const [directTools, setDirectTools] = useState([]) + const [workflowBindings, setWorkflowBindings] = useState([]) + const [publishedWorkflows, setPublishedWorkflows] = useState([]) + const [workflowToAdd, setWorkflowToAdd] = useState("") const [definition, setDefinition] = useState(fallbackDefinition) const [workflowRevision, setWorkflowRevision] = useState(0) @@ -200,6 +206,7 @@ export function AIAgentConfigWorkbench({ skillList, knowledgeBaseList, catalog, + workflowPage, ] = await Promise.all([ fetchAIWorkflowNodeSpecs(), fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition), @@ -209,6 +216,7 @@ export function AIAgentConfigWorkbench({ fetchSkillDefinitionsAll({ status: Status.Ok }), fetchKnowledgeBasesAll({ status: Status.Ok }), fetchMCPCatalog(), + fetchAIWorkflows({ limit: 100 }), ]) setNodeSpecs(specs ?? []) @@ -218,6 +226,7 @@ export function AIAgentConfigWorkbench({ setSkills(skillList ?? []) setKnowledgeBases(knowledgeBaseList ?? []) setToolCatalog(catalog ?? []) + setPublishedWorkflows((workflowPage.results ?? []).filter((item) => item.publishedVersionId > 0)) if (!currentAgentId || currentAgentId <= 0) { setAgent(null) @@ -239,24 +248,19 @@ export function AIAgentConfigWorkbench({ setSelectedSkillIds([]) setSelectedKnowledgeBaseIds([]) setDirectTools([]) + setWorkflowBindings([]) replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition) return } - const [agentDetail, workflowDetail, revisionList] = await Promise.all([ + const [agentDetail, revisionList] = await Promise.all([ fetchAIAgent(currentAgentId), - fetchAIAgentWorkflow(currentAgentId), fetchAIAgentRevisions(currentAgentId), ]) setAgent(agentDetail) setAgentRevisions(revisionList ?? []) - if (workflowDetail?.id > 0) { - const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 }) - setWorkflowVersions(versionPage.results ?? []) - } else { - setWorkflowVersions([]) - } + setWorkflowVersions([]) setName(agentDetail.name) setDescription(agentDetail.description || "") setAIConfigId(toText(agentDetail.aiConfigId)) @@ -273,7 +277,8 @@ export function AIAgentConfigWorkbench({ setSelectedSkillIds(agentDetail.skillIds ?? []) setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? []) setDirectTools(agentDetail.directTools ?? []) - replaceWorkflowDefinition(workflowDetail.draftDefinition ?? defaultDefinition ?? fallbackDefinition) + setWorkflowBindings((agentDetail.workflowBindings ?? []).map(({ workflowVersionId, toolName, triggerInstruction, priority, enabled }) => ({ workflowVersionId, toolName, triggerInstruction, priority, enabled }))) + replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition) } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to load Agent config") } finally { @@ -296,8 +301,8 @@ export function AIAgentConfigWorkbench({ const runtimeModeOptions = useMemo( () => [ { value: "autonomous", label: "自主接待" }, - { value: "hybrid", label: "自主接待 + 流程" }, - { value: "workflow", label: "高级编排 / Playbooks" }, + { value: "hybrid", label: "自主接待 + 工作流" }, + { value: "workflow", label: "仅工作流" }, ], [] ) @@ -402,6 +407,13 @@ export function AIAgentConfigWorkbench({ setDirectToolToAdd("") } + function addWorkflowBinding(value: string) { + const workflow = publishedWorkflows.find((item) => item.publishedVersionId === Number(value)) + if (!workflow || workflowBindings.some((item) => item.workflowVersionId === workflow.publishedVersionId)) return + setWorkflowBindings((current) => [...current, { workflowVersionId: workflow.publishedVersionId, toolName: workflow.name, triggerInstruction: "", priority: current.length + 1, enabled: true }]) + setWorkflowToAdd("") + } + function buildPayload(): CreateAIAgentPayload { return { name: name.trim(), @@ -419,7 +431,8 @@ export function AIAgentConfigWorkbench({ fallbackMessage: fallbackMessage.trim(), knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds), skillIds: uniqueNumbers(selectedSkillIds), - directTools, + directTools, + workflowBindings, } } @@ -447,12 +460,12 @@ export function AIAgentConfigWorkbench({ } async function publishAutonomousAgent() { - if (!agent || runtimeMode !== "autonomous") return + if (!agent || (runtimeMode !== "autonomous" && runtimeMode !== "hybrid")) return setSavingAgent(true) try { await publishAIAgent(agent.id) await loadData() - toast.success("Autonomous Agent published") + toast.success("Agent published") } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent") } finally { @@ -580,7 +593,7 @@ export function AIAgentConfigWorkbench({ const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [ { key: "basic", title: "基础信息", icon: }, { key: "capabilities", title: "能力来源", icon: }, - { key: "workflow", title: "高级编排 / Playbooks", icon: }, + { key: "workflow", title: "关联工作流", icon: }, ] const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions) @@ -615,7 +628,7 @@ export function AIAgentConfigWorkbench({ null ) : ( <> - {agent && runtimeMode === "autonomous" ? : null} + {agent && (runtimeMode === "autonomous" || runtimeMode === "hybrid") ? : null} + +
+

关联工作流

仅可关联已发布版本。工作流内容在“工作流”中心独立维护,保存 Agent 草稿后才会更新关联。

+
- setVersionDialogOpen(true)} - > - - 版本记录 - - } - /> - +
+ !workflowBindings.some((binding) => binding.workflowVersionId === item.publishedVersionId)).map((item) => ({ value: String(item.publishedVersionId), label: `${item.name} · v#${item.publishedVersionId}` }))} placeholder="选择已发布工作流" onChange={setWorkflowToAdd} /> + +
+
+ {workflowBindings.length === 0 ?
尚未关联工作流。自主接待无需配置;Hybrid 至少需要关联一个已发布工作流。
: workflowBindings.map((binding) => { + const workflow = publishedWorkflows.find((item) => item.publishedVersionId === binding.workflowVersionId) + return
{workflow?.name || binding.toolName || `工作流版本 #${binding.workflowVersionId}`}
固定版本 #{binding.workflowVersionId}
+ })} +
+
{agent && runtimeMode === "hybrid" ? : null}
+
) : null} diff --git a/web/app/dashboard/ai-agents/page.tsx b/web/app/dashboard/ai-agents/page.tsx index 6fa3313..71b795c 100644 --- a/web/app/dashboard/ai-agents/page.tsx +++ b/web/app/dashboard/ai-agents/page.tsx @@ -127,7 +127,7 @@ export default function DashboardAIAgentsPage() { }, { key: "workflow", - label: "Playbook 状态", + label: "工作流状态", render: (item) => { const published = isWorkflowPublished(item); return ( @@ -144,7 +144,7 @@ export default function DashboardAIAgentsPage() { {!published ? (
- 未发布 Playbook,AI 不会自动回复 + 未发布工作流,AI 不会自动回复
) : (
diff --git a/web/app/dashboard/ai-workflows/page.tsx b/web/app/dashboard/ai-workflows/page.tsx new file mode 100644 index 0000000..57281c2 --- /dev/null +++ b/web/app/dashboard/ai-workflows/page.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { PlusIcon } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + createAIWorkflow, + fetchAIWorkflow, + fetchAIWorkflowNodeSpecs, + fetchAIWorkflows, + publishAIWorkflow, + updateAIWorkflow, + validateAIWorkflow, + type AIWorkflow, + type AIWorkflowDefinition, + type AIWorkflowNodeSpec, +} from "@/lib/api/admin" + +import { WorkflowEditor } from "./_components/workflow-editor" + +const emptyDefinition: AIWorkflowDefinition = { + schemaVersion: 2, + nodes: [ + { id: "start_1", type: "start", meta: { position: { x: 0, y: 80 } }, data: { title: "开始", config: {}, inputsValues: {} } }, + { id: "end_1", type: "end", meta: { position: { x: 260, y: 80 } }, data: { title: "结束", config: {}, inputsValues: {} } }, + ], + edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }], +} + +export default function DashboardAIWorkflowsPage() { + const [items, setItems] = useState([]) + const [nodeSpecs, setNodeSpecs] = useState([]) + const [active, setActive] = useState(null) + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [definition, setDefinition] = useState(emptyDefinition) + const [saving, setSaving] = useState(false) + + const select = useCallback(async (id: number) => { + const item = await fetchAIWorkflow(id) + setActive(item) + setName(item.name) + setDescription(item.description) + setDefinition(item.draftDefinition) + }, []) + + const reload = useCallback(async () => { + const [page, specs] = await Promise.all([fetchAIWorkflows({ limit: 100 }), fetchAIWorkflowNodeSpecs()]) + setItems(page.results) + setNodeSpecs(specs) + if (!active && page.results[0]) await select(page.results[0].id) + }, [active, select]) + + useEffect(() => { void reload().catch((error) => toast.error(error instanceof Error ? error.message : "加载工作流失败")) }, [reload]) + + async function save() { + if (!name.trim()) { toast.error("请填写工作流名称"); return } + setSaving(true) + try { + if (active) { + await updateAIWorkflow({ id: active.id, name: name.trim(), description: description.trim(), definition }) + await select(active.id) + } else { + const created = await createAIWorkflow({ name: name.trim(), description: description.trim(), definition }) + await select(created.id) + } + await reload() + toast.success("工作流草稿已保存") + } catch (error) { toast.error(error instanceof Error ? error.message : "保存工作流失败") } finally { setSaving(false) } + } + + async function publish() { + if (!active) { toast.error("请先保存工作流草稿"); return } + setSaving(true) + try { + const version = await publishAIWorkflow(active.id, definition) + await select(active.id) + await reload() + toast.success(`已发布工作流 v${version.version}`) + } catch (error) { toast.error(error instanceof Error ? error.message : "发布工作流失败") } finally { setSaving(false) } + } + + function create() { setActive(null); setName(""); setDescription(""); setDefinition(emptyDefinition) } + + return
+ +
+
setName(event.target.value)} placeholder="工作流名称" className="max-w-sm" />