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
@@ -49,26 +49,42 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error
return nil, err return nil, err
} }
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
if req.AIAgent.WorkflowVersionID <= 0 { workflowVersionIDs := make([]int64, 0, len(snapshot.WorkflowBindings))
return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow") 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 len(workflowVersionIDs) == 0 && req.AIAgent.WorkflowVersionID > 0 {
if err != nil { workflow, resolveErr := resolveAgentWorkflow(req.AIAgent)
return nil, err 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 { if len(workflowVersionIDs) == 0 {
return nil, errorsx.InvalidParam("hybrid agent playbook validation failed") return nil, errorsx.InvalidParam("hybrid agent requires a published workflow")
} }
turn := e.autonomous.prepareTurn(ctx, req) turn := e.autonomous.prepareTurn(ctx, req)
if turn.ResponsePolicy.Enforced { 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) 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 var playbookSummary *Summary
toolCalls := make([]svc.EngineToolCallInput, 0, 1) 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" { if call.Name != "run_playbook" {
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name) 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 { if err != nil {
return "", err return "", err
} }
if workflowVersionID != req.AIAgent.WorkflowVersionID { if !containsWorkflowVersion(workflowVersionIDs, workflowVersionID) {
return "", fmt.Errorf("playbook is not allowed") return "", fmt.Errorf("playbook is not allowed")
} }
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1} 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 return "", err
} }
callStartedAt := time.Now() 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())} toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
if err != nil { if err != nil {
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error() toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
@@ -143,12 +161,28 @@ func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult,
return summary, nil return summary, nil
} }
func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition { func hybridWorkflowToolDefinition(workflowVersionID int64, toolName, instruction string) 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{ description := "Run this Agent's published deterministic workflow when the customer needs the controlled business action."
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"}, 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) { func parseHybridPlaybookCall(raw string) (int64, error) {
var input struct { var input struct {
WorkflowVersionID int64 `json:"workflowVersionId"` WorkflowVersionID int64 `json:"workflowVersionId"`
+5
View File
@@ -233,6 +233,11 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
} }
func registerDashboardAIWorkflowRoutes(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("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition) group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition)
group.GET("/template/list", dashboard.AIWorkflowGetTemplateList) group.GET("/template/list", dashboard.AIWorkflowGetTemplateList)
@@ -271,6 +271,7 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
Skills: make([]response.AIAgentSkillResponse, 0), Skills: make([]response.AIAgentSkillResponse, 0),
Teams: make([]response.AIAgentTeamResponse, 0), Teams: make([]response.AIAgentTeamResponse, 0),
DirectTools: make([]response.AIAgentMCPToolResponse, 0), DirectTools: make([]response.AIAgentMCPToolResponse, 0),
WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 0),
WorkflowVersionID: item.WorkflowVersionID, WorkflowVersionID: item.WorkflowVersionID,
PublishedRevisionID: item.PublishedRevisionID, PublishedRevisionID: item.PublishedRevisionID,
WorkflowPublished: item.WorkflowVersionID > 0, 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 return ret
} }
+16
View File
@@ -65,6 +65,7 @@ var Models = []any{
&AgentRunQualityFeedback{}, &AgentRunQualityFeedback{},
&AIWorkflow{}, &AIWorkflow{},
&AIWorkflowVersion{}, &AIWorkflowVersion{},
&AIAgentWorkflowBinding{},
&AIWorkflowRun{}, &AIWorkflowRun{},
&AIWorkflowNodeRun{}, &AIWorkflowNodeRun{},
&ConversationInterrupt{}, &ConversationInterrupt{},
@@ -664,6 +665,21 @@ type AIWorkflowVersion struct {
AuditFields 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 执行记录。 // AIWorkflowRun 表示一次会话 workflow 执行记录。
type AIWorkflowRun struct { type AIWorkflowRun struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
+9
View File
@@ -11,6 +11,14 @@ type AIAgentMCPToolRequest struct {
Arguments map[string]string `json:"arguments"` 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 { type CreateAIConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Provider enums.AIProvider `json:"provider"` Provider enums.AIProvider `json:"provider"`
@@ -63,6 +71,7 @@ type CreateAIAgentRequest struct {
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
SkillIDs []int64 `json:"skillIds"` SkillIDs []int64 `json:"skillIds"`
DirectTools []AIAgentMCPToolRequest `json:"directTools"` DirectTools []AIAgentMCPToolRequest `json:"directTools"`
WorkflowBindings []AIAgentWorkflowBindingRequest `json:"workflowBindings"`
} }
type UpdateAIAgentRequest struct { type UpdateAIAgentRequest struct {
+53 -40
View File
@@ -24,6 +24,18 @@ type AIAgentMCPToolResponse struct {
Arguments map[string]string `json:"arguments"` 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 { type AgentRevisionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AgentID int64 `json:"agentId"` AgentID int64 `json:"agentId"`
@@ -79,44 +91,45 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse {
} }
type AIAgentResponse struct { type AIAgentResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Status enums.Status `json:"status"` Status enums.Status `json:"status"`
StatusName string `json:"statusName"` StatusName string `json:"statusName"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
AIConfigName string `json:"aiConfigName"` AIConfigName string `json:"aiConfigName"`
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"` RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
RuntimeModeName string `json:"runtimeModeName"` RuntimeModeName string `json:"runtimeModeName"`
MaxSteps int `json:"maxSteps"` MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"` ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"` ToolPolicy string `json:"toolPolicy"`
KnowledgePolicy string `json:"knowledgePolicy"` KnowledgePolicy string `json:"knowledgePolicy"`
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
ServiceModeName string `json:"serviceModeName"` ServiceModeName string `json:"serviceModeName"`
SystemPrompt string `json:"systemPrompt"` SystemPrompt string `json:"systemPrompt"`
WelcomeMessage string `json:"welcomeMessage"` WelcomeMessage string `json:"welcomeMessage"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
RolloutPercent int `json:"rolloutPercent"` RolloutPercent int `json:"rolloutPercent"`
PreviousRolloutPercent int `json:"previousRolloutPercent"` PreviousRolloutPercent int `json:"previousRolloutPercent"`
Teams []AIAgentTeamResponse `json:"teams"` Teams []AIAgentTeamResponse `json:"teams"`
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
HandoffModeName string `json:"handoffModeName"` HandoffModeName string `json:"handoffModeName"`
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
FallbackModeName string `json:"fallbackModeName"` FallbackModeName string `json:"fallbackModeName"`
FallbackMessage string `json:"fallbackMessage"` FallbackMessage string `json:"fallbackMessage"`
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
SkillIDs []int64 `json:"skillIds"` SkillIDs []int64 `json:"skillIds"`
Skills []AIAgentSkillResponse `json:"skills"` Skills []AIAgentSkillResponse `json:"skills"`
DirectTools []AIAgentMCPToolResponse `json:"directTools"` DirectTools []AIAgentMCPToolResponse `json:"directTools"`
WorkflowVersionID int64 `json:"workflowVersionId"` WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"`
PublishedRevisionID int64 `json:"publishedRevisionId"` WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowPublished bool `json:"workflowPublished"` PublishedRevisionID int64 `json:"publishedRevisionId"`
WorkflowState string `json:"workflowState"` WorkflowPublished bool `json:"workflowPublished"`
WorkflowStateText string `json:"workflowStateText"` WorkflowState string `json:"workflowState"`
SortNo int `json:"sortNo"` WorkflowStateText string `json:"workflowStateText"`
CreatedAt string `json:"createdAt"` SortNo int `json:"sortNo"`
UpdatedAt string `json:"updatedAt"` CreatedAt string `json:"createdAt"`
CreateUserName string `json:"createUserName"` UpdatedAt string `json:"updatedAt"`
UpdateUserName string `json:"updateUserName"` CreateUserName string `json:"createUserName"`
UpdateUserName string `json:"updateUserName"`
} }
@@ -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
}
+21 -7
View File
@@ -38,10 +38,19 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi
} }
type agentRevisionDefinition struct { type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"` Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"` Model agentRevisionModel `json:"model"`
WorkflowVersionID int64 `json:"workflowVersionId"` WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowDefinition string `json:"workflowDefinition"` 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 // 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 // a published revision. Model credentials deliberately remain on the current
// AIConfig so credential rotation does not require republishing every Agent. // AIConfig so credential rotation does not require republishing every Agent.
type AgentRevisionSnapshot struct { type AgentRevisionSnapshot struct {
Revision models.AgentRevision Revision models.AgentRevision
Agent models.AIAgent Agent models.AIAgent
AIConfig models.AIConfig AIConfig models.AIConfig
WorkflowBindings []agentRevisionWorkflowBinding
} }
// ResolvePublishedSnapshot restores a published Agent revision for runtime // 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") return nil, errorsx.InvalidParam("published agent model config no longer matches")
} }
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent) applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
if definition.WorkflowVersionID > 0 { if definition.WorkflowVersionID > 0 {
snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID
} }
@@ -196,6 +207,9 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
WorkflowVersionID: workflowVersionID, WorkflowVersionID: workflowVersionID,
WorkflowDefinition: workflowDefinition, 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) data, err := json.Marshal(definition)
if err != nil { if err != nil {
return nil, err 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 { if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
return err return err
} }
if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid { bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator) if err != nil {
return err 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 { }); err != nil {
return nil, err return nil, err
} }
@@ -139,7 +146,35 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
// behavior. The operator must explicitly publish the new revision. // behavior. The operator must explicitly publish the new revision.
columns["published_revision_id"] = 0 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 { 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 { if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002") return errorsx.InvalidParamI18n("error.e0002")
} }
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid { if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
return errorsx.InvalidParam("workflow and hybrid agents must publish a workflow version") return errorsx.InvalidParam("workflow agents publish through their selected workflow version")
} }
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil { if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err 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 var err error
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator) revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
if err != nil { 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) { func setupAIAgentWorkflowTestDB(t *testing.T) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("open sqlite db: %v", err) 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) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) 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) { 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) { 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 == "" { if name == "" {
return errorsx.InvalidParam("workflow name is required") return errorsx.InvalidParam("workflow name is required")
} }
if req.AgentID <= 0 {
return errorsx.InvalidParam("agent id is required")
}
definition, err := marshalDefinition(req.Definition) definition, err := marshalDefinition(req.Definition)
if err != nil { if err != nil {
return err return err
@@ -315,7 +327,6 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest,
return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{ return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
"name": name, "name": name,
"description": strings.TrimSpace(req.Description), "description": strings.TrimSpace(req.Description),
"agent_id": req.AgentID,
"draft_definition": definition, "draft_definition": definition,
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
"update_user_name": operator.Username, "update_user_name": operator.Username,
@@ -330,6 +341,9 @@ func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal
if s.Get(id) == nil { if s.Get(id) == nil {
return errorsx.InvalidParamI18n("error.e0002") 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{}{ return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
"status": enums.StatusDeleted, "status": enums.StatusDeleted,
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
@@ -283,7 +283,7 @@ func setupAIWorkflowTestDB(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("open sqlite db: %v", err) 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) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
+1 -1
View File
@@ -183,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
_ = sqlDB.Close() _ = 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) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
@@ -45,6 +45,7 @@ import {
fetchAIWorkflowNodeSpecs, fetchAIWorkflowNodeSpecs,
fetchAIWorkflowTemplates, fetchAIWorkflowTemplates,
fetchAIWorkflowVersions, fetchAIWorkflowVersions,
fetchAIWorkflows,
fetchAgentTeamsAll, fetchAgentTeamsAll,
fetchMCPCatalog, fetchMCPCatalog,
fetchSkillDefinitionsAll, fetchSkillDefinitionsAll,
@@ -56,9 +57,11 @@ import {
updateAIAgent, updateAIAgent,
validateAIWorkflow, validateAIWorkflow,
type AIAgent, type AIAgent,
type AIAgentWorkflowBindingInput,
type AgentRevision, type AgentRevision,
type AIConfig, type AIConfig,
type AIWorkflowDefinition, type AIWorkflowDefinition,
type AIWorkflow,
type AIWorkflowNodeSpec, type AIWorkflowNodeSpec,
type AIWorkflowTemplate, type AIWorkflowTemplate,
type AIWorkflowVersion, type AIWorkflowVersion,
@@ -161,6 +164,9 @@ export function AIAgentConfigWorkbench({
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([]) const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<number[]>([]) const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<number[]>([])
const [directTools, setDirectTools] = useState<DirectToolItem[]>([]) const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
const [workflowBindings, setWorkflowBindings] = useState<AIAgentWorkflowBindingInput[]>([])
const [publishedWorkflows, setPublishedWorkflows] = useState<AIWorkflow[]>([])
const [workflowToAdd, setWorkflowToAdd] = useState("")
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition) const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
const [workflowRevision, setWorkflowRevision] = useState(0) const [workflowRevision, setWorkflowRevision] = useState(0)
@@ -200,6 +206,7 @@ export function AIAgentConfigWorkbench({
skillList, skillList,
knowledgeBaseList, knowledgeBaseList,
catalog, catalog,
workflowPage,
] = await Promise.all([ ] = await Promise.all([
fetchAIWorkflowNodeSpecs(), fetchAIWorkflowNodeSpecs(),
fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition), fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition),
@@ -209,6 +216,7 @@ export function AIAgentConfigWorkbench({
fetchSkillDefinitionsAll({ status: Status.Ok }), fetchSkillDefinitionsAll({ status: Status.Ok }),
fetchKnowledgeBasesAll({ status: Status.Ok }), fetchKnowledgeBasesAll({ status: Status.Ok }),
fetchMCPCatalog(), fetchMCPCatalog(),
fetchAIWorkflows({ limit: 100 }),
]) ])
setNodeSpecs(specs ?? []) setNodeSpecs(specs ?? [])
@@ -218,6 +226,7 @@ export function AIAgentConfigWorkbench({
setSkills(skillList ?? []) setSkills(skillList ?? [])
setKnowledgeBases(knowledgeBaseList ?? []) setKnowledgeBases(knowledgeBaseList ?? [])
setToolCatalog(catalog ?? []) setToolCatalog(catalog ?? [])
setPublishedWorkflows((workflowPage.results ?? []).filter((item) => item.publishedVersionId > 0))
if (!currentAgentId || currentAgentId <= 0) { if (!currentAgentId || currentAgentId <= 0) {
setAgent(null) setAgent(null)
@@ -239,24 +248,19 @@ export function AIAgentConfigWorkbench({
setSelectedSkillIds([]) setSelectedSkillIds([])
setSelectedKnowledgeBaseIds([]) setSelectedKnowledgeBaseIds([])
setDirectTools([]) setDirectTools([])
setWorkflowBindings([])
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition) replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
return return
} }
const [agentDetail, workflowDetail, revisionList] = await Promise.all([ const [agentDetail, revisionList] = await Promise.all([
fetchAIAgent(currentAgentId), fetchAIAgent(currentAgentId),
fetchAIAgentWorkflow(currentAgentId),
fetchAIAgentRevisions(currentAgentId), fetchAIAgentRevisions(currentAgentId),
]) ])
setAgent(agentDetail) setAgent(agentDetail)
setAgentRevisions(revisionList ?? []) setAgentRevisions(revisionList ?? [])
if (workflowDetail?.id > 0) { setWorkflowVersions([])
const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 })
setWorkflowVersions(versionPage.results ?? [])
} else {
setWorkflowVersions([])
}
setName(agentDetail.name) setName(agentDetail.name)
setDescription(agentDetail.description || "") setDescription(agentDetail.description || "")
setAIConfigId(toText(agentDetail.aiConfigId)) setAIConfigId(toText(agentDetail.aiConfigId))
@@ -273,7 +277,8 @@ export function AIAgentConfigWorkbench({
setSelectedSkillIds(agentDetail.skillIds ?? []) setSelectedSkillIds(agentDetail.skillIds ?? [])
setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? []) setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? [])
setDirectTools(agentDetail.directTools ?? []) 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) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to load Agent config") toast.error(error instanceof Error ? error.message : "Failed to load Agent config")
} finally { } finally {
@@ -296,8 +301,8 @@ export function AIAgentConfigWorkbench({
const runtimeModeOptions = useMemo( const runtimeModeOptions = useMemo(
() => [ () => [
{ value: "autonomous", label: "自主接待" }, { value: "autonomous", label: "自主接待" },
{ value: "hybrid", label: "自主接待 + 流" }, { value: "hybrid", label: "自主接待 + 工作流" },
{ value: "workflow", label: "高级编排 / Playbooks" }, { value: "workflow", label: "仅工作流" },
], ],
[] []
) )
@@ -402,6 +407,13 @@ export function AIAgentConfigWorkbench({
setDirectToolToAdd("") 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 { function buildPayload(): CreateAIAgentPayload {
return { return {
name: name.trim(), name: name.trim(),
@@ -419,7 +431,8 @@ export function AIAgentConfigWorkbench({
fallbackMessage: fallbackMessage.trim(), fallbackMessage: fallbackMessage.trim(),
knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds), knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds),
skillIds: uniqueNumbers(selectedSkillIds), skillIds: uniqueNumbers(selectedSkillIds),
directTools, directTools,
workflowBindings,
} }
} }
@@ -447,12 +460,12 @@ export function AIAgentConfigWorkbench({
} }
async function publishAutonomousAgent() { async function publishAutonomousAgent() {
if (!agent || runtimeMode !== "autonomous") return if (!agent || (runtimeMode !== "autonomous" && runtimeMode !== "hybrid")) return
setSavingAgent(true) setSavingAgent(true)
try { try {
await publishAIAgent(agent.id) await publishAIAgent(agent.id)
await loadData() await loadData()
toast.success("Autonomous Agent published") toast.success("Agent published")
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent") toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent")
} finally { } finally {
@@ -580,7 +593,7 @@ export function AIAgentConfigWorkbench({
const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [ const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [
{ key: "basic", title: "基础信息", icon: <SettingsIcon /> }, { key: "basic", title: "基础信息", icon: <SettingsIcon /> },
{ key: "capabilities", title: "能力来源", icon: <PlugIcon /> }, { key: "capabilities", title: "能力来源", icon: <PlugIcon /> },
{ key: "workflow", title: "高级编排 / Playbooks", icon: <GitBranchIcon /> }, { key: "workflow", title: "关联工作流", icon: <GitBranchIcon /> },
] ]
const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions) const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions)
@@ -615,7 +628,7 @@ export function AIAgentConfigWorkbench({
null null
) : ( ) : (
<> <>
{agent && runtimeMode === "autonomous" ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null} {agent && (runtimeMode === "autonomous" || runtimeMode === "hybrid") ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -633,7 +646,7 @@ export function AIAgentConfigWorkbench({
<div className="flex min-h-0 flex-1 flex-col bg-background"> <div className="flex min-h-0 flex-1 flex-col bg-background">
{agent && !runtimePublished ? ( {agent && !runtimePublished ? (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900"> <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900">
{runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本。" : "未发布 PlaybookAI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本,再绑定渠道或启用自动回复。"} {runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请关联已发布工作流,再发布 Agent。" : "未发布工作流AI 不会自动回复。请先关联一个已发布工作流。"}
</div> </div>
) : null} ) : null}
<div className="shrink-0 border-b bg-muted/30 px-4 py-2"> <div className="shrink-0 border-b bg-muted/30 px-4 py-2">
@@ -870,46 +883,23 @@ export function AIAgentConfigWorkbench({
) : null} ) : null}
{activeSection === "workflow" ? ( {activeSection === "workflow" ? (
<div className="flex min-h-0 flex-1 flex-col"> <ConfigSection>
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2"> <div className="flex items-start justify-between gap-4">
<OptionCombobox <div><h2 className="text-base font-semibold"></h2><p className="mt-1 text-sm text-muted-foreground"> Agent 稿</p></div>
value={selectedWorkflowTemplate} <Button type="button" variant="outline" onClick={() => window.location.assign("/dashboard/ai-workflows")}></Button>
options={workflowTemplates.map((item) => ({ value: item.code, label: item.name }))}
placeholder="选择 Playbook 模板"
onChange={setSelectedWorkflowTemplate}
/>
<Button type="button" variant="outline" size="sm" disabled={!selectedWorkflowTemplate || savingWorkflow || loading} onClick={applySelectedWorkflowTemplate}>
</Button>
</div> </div>
<WorkflowEditor <div className="flex max-w-xl items-center gap-2">
key={workflowRevision} <OptionCombobox value={workflowToAdd} options={publishedWorkflows.filter((item) => !workflowBindings.some((binding) => binding.workflowVersionId === item.publishedVersionId)).map((item) => ({ value: String(item.publishedVersionId), label: `${item.name} · v#${item.publishedVersionId}` }))} placeholder="选择已发布工作流" onChange={setWorkflowToAdd} />
definition={definition} <Button type="button" variant="outline" disabled={!workflowToAdd} onClick={() => addWorkflowBinding(workflowToAdd)}></Button>
nodeSpecs={nodeSpecs} </div>
onDefinitionChange={setDefinition} <div className="space-y-2">
historyDisabled={savingWorkflow || loading} {workflowBindings.length === 0 ? <div className="rounded-md border border-dashed p-5 text-sm text-muted-foreground">Hybrid </div> : workflowBindings.map((binding) => {
onRestoreDefault={restoreDefaultWorkflow} const workflow = publishedWorkflows.find((item) => item.publishedVersionId === binding.workflowVersionId)
restoreDefaultDisabled={savingWorkflow || loading} return <div key={binding.workflowVersionId} className="flex items-center gap-3 rounded-md border p-3"><GitBranchIcon className="size-4 text-muted-foreground" /><div className="min-w-0 flex-1"><div className="font-medium">{workflow?.name || binding.toolName || `工作流版本 #${binding.workflowVersionId}`}</div><div className="text-xs text-muted-foreground"> #{binding.workflowVersionId}</div></div><Button type="button" variant="ghost" size="sm" onClick={() => setWorkflowBindings((current) => current.filter((item) => item.workflowVersionId !== binding.workflowVersionId))}></Button></div>
onValidate={validateWorkflowDraft} })}
validateDisabled={savingWorkflow || loading || !currentAgentId} </div>
onSaveDraft={saveWorkflowDraft} <div className="flex justify-end gap-2"><Button type="button" disabled={savingAgent || loading} onClick={saveAgentSettings}> Agent </Button>{agent && runtimeMode === "hybrid" ? <Button type="button" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}</div>
saveDraftDisabled={savingWorkflow || loading || !currentAgentId} </ConfigSection>
onPublish={publishWorkflow}
publishDisabled={savingWorkflow || loading || !currentAgentId}
toolbarExtra={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 rounded-none px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setVersionDialogOpen(true)}
>
<HistoryIcon className="size-3.5" />
</Button>
}
/>
</div>
) : null} ) : null}
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}> <Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
+2 -2
View File
@@ -127,7 +127,7 @@ export default function DashboardAIAgentsPage() {
}, },
{ {
key: "workflow", key: "workflow",
label: "Playbook 状态", label: "工作流状态",
render: (item) => { render: (item) => {
const published = isWorkflowPublished(item); const published = isWorkflowPublished(item);
return ( return (
@@ -144,7 +144,7 @@ export default function DashboardAIAgentsPage() {
</div> </div>
{!published ? ( {!published ? (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
PlaybookAI AI
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
+99
View File
@@ -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<AIWorkflow[]>([])
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [active, setActive] = useState<AIWorkflow | null>(null)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [definition, setDefinition] = useState<AIWorkflowDefinition>(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 <div className="flex h-full min-h-0 bg-background">
<aside className="w-72 shrink-0 border-r bg-muted/20 p-3">
<div className="mb-3 flex items-center justify-between"><div><h1 className="font-semibold"></h1><p className="text-xs text-muted-foreground"> Agent </p></div><Button size="icon" variant="outline" onClick={create}><PlusIcon className="size-4" /></Button></div>
<div className="space-y-1">{items.map((item) => <button key={item.id} type="button" onClick={() => void select(item.id)} className={`w-full rounded-md p-3 text-left ${active?.id === item.id ? "bg-background shadow-sm" : "hover:bg-background/70"}`}><div className="truncate font-medium">{item.name}</div><div className="mt-1 text-xs text-muted-foreground">{item.publishedVersionId > 0 ? `已发布版本 #${item.publishedVersionId}` : "未发布"}</div></button>)}</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex shrink-0 items-center gap-3 border-b px-5 py-3"><div className="min-w-0 flex-1"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="工作流名称" className="max-w-sm" /><Textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="业务说明(可选)" className="mt-2 min-h-16 max-w-xl resize-none" /></div><Button variant="outline" disabled={saving} onClick={() => void save()}>稿</Button><Button disabled={saving || !active} onClick={() => void publish()}></Button></header>
<div className="min-h-0 flex-1"><WorkflowEditor definition={definition} nodeSpecs={nodeSpecs} onDefinitionChange={setDefinition} onValidate={() => void validateAIWorkflow(definition).then((result) => toast[result.valid ? "success" : "error"](result.valid ? "工作流校验通过" : "工作流存在校验错误"))} validateDisabled={saving} /></div>
</section>
</div>
}
+54 -3
View File
@@ -241,7 +241,7 @@ export type AIAgent = {
knowledgeBaseIds: number[] knowledgeBaseIds: number[]
skillIds: number[] skillIds: number[]
skills: { id: number; name: string }[] skills: { id: number; name: string }[]
directTools: { directTools: {
toolCode: string toolCode: string
serverCode: string serverCode: string
toolName: string toolName: string
@@ -249,6 +249,7 @@ export type AIAgent = {
description: string description: string
arguments?: Record<string, string> arguments?: Record<string, string>
}[] }[]
workflowBindings: AIAgentWorkflowBinding[]
workflowVersionId: number workflowVersionId: number
publishedRevisionId: number publishedRevisionId: number
workflowPublished: boolean workflowPublished: boolean
@@ -289,6 +290,27 @@ export type CreateAIAgentPayload = {
description: string description: string
arguments?: Record<string, string> arguments?: Record<string, string>
}[] }[]
workflowBindings: AIAgentWorkflowBindingInput[]
}
export type AIAgentWorkflowBinding = {
id: number
workflowId: number
workflowVersionId: number
workflowName: string
workflowVersion: number
toolName: string
triggerInstruction: string
priority: number
enabled: boolean
}
export type AIAgentWorkflowBindingInput = {
workflowVersionId: number
toolName: string
triggerInstruction: string
priority: number
enabled: boolean
} }
export type UpdateAIAgentPayload = CreateAIAgentPayload & { export type UpdateAIAgentPayload = CreateAIAgentPayload & {
@@ -437,10 +459,12 @@ export type AIWorkflowValidationResult = {
export type CreateAIWorkflowPayload = { export type CreateAIWorkflowPayload = {
name: string name: string
description: string description: string
agentId: number agentId?: number
definition: AIWorkflowDefinition definition: AIWorkflowDefinition
} }
export type UpdateAIWorkflowPayload = CreateAIWorkflowPayload & { id: number }
export type CreateAdminQuickReplyPayload = { export type CreateAdminQuickReplyPayload = {
groupName: string groupName: string
title: string title: string
@@ -1013,6 +1037,26 @@ export function fetchAIAgentWorkflow(agentId: number) {
return request<AIWorkflow>(`/api/dashboard/ai-agent/${agentId}/workflow`) return request<AIWorkflow>(`/api/dashboard/ai-agent/${agentId}/workflow`)
} }
export function fetchAIWorkflows(query?: Record<string, string | number | undefined>) {
return request<PageResult<AIWorkflow>>(`/api/dashboard/ai-workflow/list${toQueryString(query)}`)
}
export function fetchAIWorkflow(id: number) {
return request<AIWorkflow>(`/api/dashboard/ai-workflow/${id}`)
}
export function createAIWorkflow(payload: CreateAIWorkflowPayload) {
return request<AIWorkflow>("/api/dashboard/ai-workflow/create", { method: "POST", body: JSON.stringify(payload) })
}
export function updateAIWorkflow(payload: UpdateAIWorkflowPayload) {
return request<void>("/api/dashboard/ai-workflow/update", { method: "POST", body: JSON.stringify(payload) })
}
export function deleteAIWorkflow(id: number) {
return request<void>("/api/dashboard/ai-workflow/delete", { method: "POST", body: JSON.stringify({ id }) })
}
export function saveAIAgentWorkflow(payload: CreateAIWorkflowPayload) { export function saveAIAgentWorkflow(payload: CreateAIWorkflowPayload) {
return request<AIWorkflow>("/api/dashboard/ai-agent/workflow/save", { return request<AIWorkflow>("/api/dashboard/ai-agent/workflow/save", {
method: "POST", method: "POST",
@@ -1039,12 +1083,19 @@ export function fetchAIWorkflowVersions(query?: Record<string, string | number |
} }
export function validateAIWorkflow(definition: AIWorkflowDefinition) { export function validateAIWorkflow(definition: AIWorkflowDefinition) {
return request<AIWorkflowValidationResult>("/api/dashboard/ai-agent/workflow/validate", { return request<AIWorkflowValidationResult>("/api/dashboard/ai-workflow/validate", {
method: "POST", method: "POST",
body: JSON.stringify({ definition }), body: JSON.stringify({ definition }),
}) })
} }
export function publishAIWorkflow(workflowId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-workflow/publish", {
method: "POST",
body: JSON.stringify({ workflowId, definition }),
})
}
export function publishAIAgentWorkflow(agentId: number, definition: AIWorkflowDefinition) { export function publishAIAgentWorkflow(agentId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-agent/workflow/publish", { return request<AIWorkflowVersion>("/api/dashboard/ai-agent/workflow/publish", {
method: "POST", method: "POST",
+6
View File
@@ -193,6 +193,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
icon: <MessageSquareMoreIcon />, icon: <MessageSquareMoreIcon />,
requiredPermission: "aiAgent.view", requiredPermission: "aiAgent.view",
}, },
{
titleKey: "nav.workflows",
url: "/dashboard/ai-workflows",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{ {
titleKey: "nav.skillDefinition", titleKey: "nav.skillDefinition",
url: "/dashboard/skill-definition", url: "/dashboard/skill-definition",
+1
View File
@@ -2340,6 +2340,7 @@
"aiAgents": "Agents", "aiAgents": "Agents",
"aiWorkflows": "AI Workflows", "aiWorkflows": "AI Workflows",
"workflowRuns": "Workflow Audit", "workflowRuns": "Workflow Audit",
"workflows": "Workflows",
"agentRuns": "Agent Audit", "agentRuns": "Agent Audit",
"skillDefinition": "Skills", "skillDefinition": "Skills",
"mcp": "MCP tools", "mcp": "MCP tools",
+1
View File
@@ -2340,6 +2340,7 @@
"aiAgents": "Agent", "aiAgents": "Agent",
"aiWorkflows": "AI流程", "aiWorkflows": "AI流程",
"workflowRuns": "流程审计", "workflowRuns": "流程审计",
"workflows": "工作流",
"agentRuns": "Agent 审计", "agentRuns": "Agent 审计",
"skillDefinition": "Skills", "skillDefinition": "Skills",
"mcp": "MCP tools", "mcp": "MCP tools",