2026-06-23 09:34:08 +08:00
package workflow
import (
"context"
"encoding/json"
"fmt"
2026-06-25 22:38:34 +08:00
"html"
2026-06-23 09:34:08 +08:00
"reflect"
2026-06-25 22:38:34 +08:00
"regexp"
2026-06-23 09:34:08 +08:00
"strconv"
"strings"
2026-06-23 22:57:18 +08:00
"time"
2026-06-23 09:34:08 +08:00
"agent-desk/internal/ai"
2026-06-23 21:54:51 +08:00
"agent-desk/internal/ai/runtime/graphs"
2026-06-23 09:34:08 +08:00
"agent-desk/internal/ai/runtime/internal/impl/retrievers"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models"
2026-06-23 22:11:20 +08:00
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
2026-06-25 18:28:16 +08:00
"agent-desk/internal/pkg/enums"
2026-06-23 09:34:08 +08:00
"agent-desk/internal/pkg/utils"
2026-06-23 21:48:20 +08:00
"agent-desk/internal/services"
2026-06-23 09:34:08 +08:00
)
const maxWorkflowSteps = 128
2026-06-25 22:38:34 +08:00
var workflowHTMLTagPattern = regexp . MustCompile ( `<[^>]+>` )
2026-06-23 09:34:08 +08:00
type Input struct {
Definition dsl . Definition
Conversation models . Conversation
UserMessage models . Message
AIAgent models . AIAgent
AIConfig models . AIConfig
}
type Result struct {
Status string
ReplyText string
NodePath [] string
2026-06-23 22:57:18 +08:00
NodeTraces [] NodeTrace
2026-06-23 09:34:08 +08:00
PromptTokens int
CompletionTokens int
RetrieverCount int
TraceData string
2026-06-23 22:11:20 +08:00
CheckPointID string
CheckPointData string
Interrupted bool
Interrupts [] InterruptSummary
}
2026-06-23 22:57:18 +08:00
type NodeTrace struct {
NodeID string
NodeType string
Status string
InputPreview string
OutputPreview string
ErrorMessage string
DurationMS int
}
2026-06-23 22:11:20 +08:00
type InterruptSummary struct {
Type string
ID string
InfoPreview string
2026-06-23 09:34:08 +08:00
}
type Executor struct {}
func NewExecutor () * Executor {
return & Executor {}
}
type runState struct {
2026-06-24 17:38:57 +08:00
input Input
nodesByID map [ string ] dsl . Node
outgoing map [ string ][] dsl . Edge
vars map [ string ] map [ string ] any
branchDecisions map [ string ] branchDecision
result Result
2026-06-23 09:34:08 +08:00
}
2026-06-23 22:11:20 +08:00
type workflowCheckPoint struct {
Definition dsl . Definition `json:"definition"`
ConfirmNodeID string `json:"confirmNodeId"`
Vars map [ string ] map [ string ] any `json:"vars"`
}
2026-06-24 17:38:57 +08:00
type branchDecision struct {
SelectedEdgeID string `json:"selectedEdgeId,omitempty"`
2026-06-25 21:08:59 +08:00
SelectedBranchID string `json:"selectedBranchId,omitempty"`
SelectedBranchName string `json:"selectedBranchName,omitempty"`
2026-06-24 17:38:57 +08:00
SelectedTargetNodeID string `json:"selectedTargetNodeId,omitempty"`
Reason string `json:"reason"`
Evaluations [] conditionEvaluation `json:"evaluations,omitempty"`
}
type conditionEvaluation struct {
EdgeID string `json:"edgeId"`
2026-06-25 21:08:59 +08:00
BranchID string `json:"branchId,omitempty"`
BranchName string `json:"branchName,omitempty"`
2026-06-24 17:38:57 +08:00
TargetNodeID string `json:"targetNodeId"`
SourceNodeID string `json:"sourceNodeId,omitempty"`
SourceField string `json:"sourceField,omitempty"`
Operator string `json:"operator,omitempty"`
LeftValue any `json:"leftValue,omitempty"`
RightValue any `json:"rightValue,omitempty"`
Matched bool `json:"matched"`
}
2026-06-23 09:34:08 +08:00
func ( e * Executor ) Execute ( ctx context . Context , input Input ) ( * Result , error ) {
state := newRunState ( input )
currentID := strings . TrimSpace ( input . Definition . EntryNodeID )
if currentID == "" {
return nil , fmt . Errorf ( "workflow entry node is required" )
}
2026-06-23 22:11:20 +08:00
return e . executeFrom ( ctx , state , currentID )
}
func ( e * Executor ) Resume ( ctx context . Context , input Input , checkPointData string , resumeText string ) ( * Result , error ) {
var checkpoint workflowCheckPoint
if err := json . Unmarshal ([] byte ( strings . TrimSpace ( checkPointData )), & checkpoint ); err != nil {
return nil , fmt . Errorf ( "invalid workflow checkpoint: %w" , err )
}
if len ( checkpoint . Definition . Nodes ) > 0 {
input . Definition = checkpoint . Definition
}
state := newRunState ( input )
state . vars = checkpoint . Vars
if state . vars == nil {
state . vars = make ( map [ string ] map [ string ] any )
}
confirmNodeID := strings . TrimSpace ( checkpoint . ConfirmNodeID )
if confirmNodeID == "" {
return nil , fmt . Errorf ( "workflow checkpoint confirm node is required" )
}
decision := graphs . ParseConfirmationDecision ( resumeText )
if decision == "" {
node , ok := state . nodesByID [ confirmNodeID ]
if ! ok {
return nil , fmt . Errorf ( "workflow node does not exist: %s" , confirmNodeID )
}
if err := e . executeHumanConfirm ( state , node ); err != nil {
return nil , err
}
state . result . Status = "interrupted"
return & state . result , nil
}
state . setNodeVars ( confirmNodeID , map [ string ] any {
"confirmed" : decision == graphs . ConfirmationDecisionConfirm ,
"responseText" : strings . TrimSpace ( resumeText ),
})
nextID , ok , err := state . nextNodeID ( confirmNodeID )
if err != nil {
return nil , err
}
if ! ok {
state . result . Status = "completed"
return & state . result , nil
}
return e . executeFrom ( ctx , state , nextID )
}
func ( e * Executor ) executeFrom ( ctx context . Context , state * runState , currentID string ) ( * Result , error ) {
2026-06-23 09:34:08 +08:00
for step := 0 ; step < maxWorkflowSteps ; step ++ {
node , ok := state . nodesByID [ currentID ]
if ! ok {
2026-06-23 22:57:18 +08:00
err := fmt . Errorf ( "workflow node does not exist: %s" , currentID )
state . result . Status = "error"
return & state . result , err
2026-06-23 09:34:08 +08:00
}
state . result . NodePath = append ( state . result . NodePath , node . ID )
2026-06-23 22:57:18 +08:00
trace := NodeTrace {
NodeID : node . ID ,
NodeType : node . Type ,
Status : "running" ,
InputPreview : workflowPreviewJSON ( state . nodeInputPreview ( node )),
}
startedAt := time . Now ()
2026-06-23 09:34:08 +08:00
if err := e . executeNode ( ctx , state , node ); err != nil {
2026-06-23 22:57:18 +08:00
trace . Status = "failed"
trace . ErrorMessage = err . Error ()
trace . DurationMS = int ( time . Since ( startedAt ). Milliseconds ())
state . result . NodeTraces = append ( state . result . NodeTraces , trace )
state . result . Status = "error"
return & state . result , err
2026-06-23 09:34:08 +08:00
}
2026-06-23 22:57:18 +08:00
trace . DurationMS = int ( time . Since ( startedAt ). Milliseconds ())
2026-06-23 22:11:20 +08:00
if state . result . Interrupted {
2026-06-24 17:38:57 +08:00
trace . OutputPreview = workflowPreviewJSON ( state . nodeOutputPreview ( node . ID ))
2026-06-23 22:57:18 +08:00
trace . Status = "interrupted"
state . result . NodeTraces = append ( state . result . NodeTraces , trace )
2026-06-23 22:11:20 +08:00
state . result . Status = "interrupted"
return & state . result , nil
}
2026-06-23 09:34:08 +08:00
if node . Type == workflowregistry . NodeTypeEnd {
2026-06-24 17:38:57 +08:00
trace . OutputPreview = workflowPreviewJSON ( state . nodeOutputPreview ( node . ID ))
trace . Status = "completed"
state . result . NodeTraces = append ( state . result . NodeTraces , trace )
2026-06-23 09:34:08 +08:00
state . result . Status = "completed"
return & state . result , nil
}
nextID , ok , err := state . nextNodeID ( node . ID )
if err != nil {
2026-06-24 17:38:57 +08:00
trace . OutputPreview = workflowPreviewJSON ( state . nodeOutputPreview ( node . ID ))
trace . Status = "failed"
trace . ErrorMessage = err . Error ()
trace . DurationMS = int ( time . Since ( startedAt ). Milliseconds ())
state . result . NodeTraces = append ( state . result . NodeTraces , trace )
2026-06-23 22:57:18 +08:00
state . result . Status = "error"
return & state . result , err
2026-06-23 09:34:08 +08:00
}
2026-06-24 17:38:57 +08:00
trace . OutputPreview = workflowPreviewJSON ( state . nodeOutputPreview ( node . ID ))
trace . Status = "completed"
state . result . NodeTraces = append ( state . result . NodeTraces , trace )
2026-06-23 09:34:08 +08:00
if ! ok {
state . result . Status = "completed"
return & state . result , nil
}
currentID = nextID
}
2026-06-23 22:57:18 +08:00
err := fmt . Errorf ( "workflow exceeded max steps" )
state . result . Status = "error"
return & state . result , err
2026-06-23 09:34:08 +08:00
}
func newRunState ( input Input ) * runState {
state := & runState {
2026-06-24 17:38:57 +08:00
input : input ,
nodesByID : make ( map [ string ] dsl . Node , len ( input . Definition . Nodes )),
outgoing : make ( map [ string ][] dsl . Edge ),
vars : make ( map [ string ] map [ string ] any ),
branchDecisions : make ( map [ string ] branchDecision ),
2026-06-23 09:34:08 +08:00
result : Result {
2026-06-23 22:57:18 +08:00
Status : "started" ,
NodePath : make ([] string , 0 ),
NodeTraces : make ([] NodeTrace , 0 ),
2026-06-23 09:34:08 +08:00
},
}
for _ , node := range input . Definition . Nodes {
node . ID = strings . TrimSpace ( node . ID )
node . Type = strings . TrimSpace ( node . Type )
if node . ID != "" {
state . nodesByID [ node . ID ] = node
}
}
for _ , edge := range input . Definition . Edges {
state . outgoing [ edge . Source ] = append ( state . outgoing [ edge . Source ], edge )
}
return state
}
func ( e * Executor ) executeNode ( ctx context . Context , state * runState , node dsl . Node ) error {
switch node . Type {
case workflowregistry . NodeTypeStart :
state . setNodeVars ( node . ID , map [ string ] any {
"conversationId" : state . input . Conversation . ID ,
"messageId" : state . input . UserMessage . ID ,
"aiAgentId" : state . input . AIAgent . ID ,
"userMessage" : strings . TrimSpace ( state . input . UserMessage . Content ),
"knowledgeBaseIds" : utils . SplitInt64s ( state . input . AIAgent . KnowledgeIDs ),
"conversationState" : state . input . Conversation . Status ,
})
2026-06-25 22:38:34 +08:00
case workflowregistry . NodeTypeConversationUnderstanding :
return e . executeConversationUnderstanding ( state , node )
case workflowregistry . NodeTypeReplyPolicy :
return e . executeReplyPolicy ( state , node )
2026-06-23 09:34:08 +08:00
case workflowregistry . NodeTypeKnowledgeRetrieve :
return e . executeKnowledgeRetrieve ( ctx , state , node )
case workflowregistry . NodeTypeAnswerabilityGate :
return e . executeAnswerabilityGate ( state , node )
case workflowregistry . NodeTypeCondition :
state . setNodeVars ( node . ID , map [ string ] any { "matched" : true })
2026-06-23 21:54:51 +08:00
case workflowregistry . NodeTypeAnalyzeConversation :
return e . executeAnalyzeConversation ( ctx , state , node )
2026-06-23 22:11:20 +08:00
case workflowregistry . NodeTypePrepareTicketDraft :
return e . executePrepareTicketDraft ( ctx , state , node )
case workflowregistry . NodeTypeHumanConfirm :
return e . executeHumanConfirm ( state , node )
case workflowregistry . NodeTypeCreateTicket :
return e . executeCreateTicket ( state , node )
2026-06-23 09:34:08 +08:00
case workflowregistry . NodeTypeLLMReply :
return e . executeLLMReply ( ctx , state , node )
case workflowregistry . NodeTypeSendReply :
replyText := strings . TrimSpace ( toString ( state . resolveInput ( node , "replyText" )))
state . result . ReplyText = replyText
state . setNodeVars ( node . ID , map [ string ] any {
"sent" : replyText != "" ,
"replyMessageId" : int64 ( 0 ),
})
case workflowregistry . NodeTypeHandoffToHuman :
2026-06-23 21:48:20 +08:00
return e . executeHandoffToHuman ( state , node )
2026-06-23 09:34:08 +08:00
case workflowregistry . NodeTypeEnd :
state . setNodeVars ( node . ID , map [ string ] any { "status" : "completed" })
default :
return fmt . Errorf ( "unsupported workflow node type: %s" , node . Type )
}
return nil
}
2026-06-25 22:38:34 +08:00
func ( e * Executor ) executeConversationUnderstanding ( state * runState , node dsl . Node ) error {
rawMessage := strings . TrimSpace ( toString ( state . resolveInput ( node , "userMessage" )))
if rawMessage == "" {
rawMessage = state . input . UserMessage . Content
}
understanding := understandConversationMessage ( rawMessage )
state . setNodeVars ( node . ID , map [ string ] any {
"normalizedMessage" : understanding . NormalizedMessage ,
"messageIntent" : understanding . MessageIntent ,
"answerScope" : understanding . AnswerScope ,
"confidence" : understanding . Confidence ,
"riskSignals" : understanding . RiskSignals ,
"reason" : understanding . Reason ,
})
return nil
}
func ( e * Executor ) executeReplyPolicy ( state * runState , node dsl . Node ) error {
intent := strings . TrimSpace ( toString ( state . resolveInput ( node , "messageIntent" )))
scope := strings . TrimSpace ( toString ( state . resolveInput ( node , "answerScope" )))
userMessage := normalizeWorkflowUserMessage ( toString ( state . resolveInput ( node , "userMessage" )))
decision := decideWorkflowReplyPolicy ( state . input . AIAgent , workflowReplyPolicyInput {
MessageIntent : intent ,
AnswerScope : scope ,
UserMessage : userMessage ,
Answerability : strings . TrimSpace ( toString ( state . resolveInput ( node , "answerability" ))),
})
state . setNodeVars ( node . ID , map [ string ] any {
"action" : decision . Action ,
"replyText" : decision . ReplyText ,
"reason" : decision . Reason ,
"requiresFlow" : decision . RequiresFlow ,
"targetFlow" : decision . TargetFlow ,
"finalReplySource" : decision . FinalReplySource ,
})
return nil
}
2026-06-23 22:11:20 +08:00
func ( e * Executor ) executeCreateTicket ( state * runState , node dsl . Node ) error {
confirmed := truthy ( state . resolveInput ( node , "confirmed" ))
if ! confirmed {
state . setNodeVars ( node . ID , map [ string ] any {
"ticketId" : int64 ( 0 ),
"created" : false ,
})
return nil
}
draft := asMap ( state . resolveInput ( node , "ticketDraft" ))
title := strings . TrimSpace ( toString ( draft [ "title" ]))
description := strings . TrimSpace ( toString ( draft [ "description" ]))
item , err := services . TicketService . CreateFromConversation ( request . CreateTicketFromConversationRequest {
ConversationID : state . input . Conversation . ID ,
Title : title ,
Description : description ,
}, workflowAIPrincipal ( state . input . AIAgent ))
if err != nil {
return err
}
state . setNodeVars ( node . ID , map [ string ] any {
"ticketId" : item . ID ,
"ticketNo" : item . TicketNo ,
"created" : true ,
2026-06-25 18:28:16 +08:00
"message" : buildTicketCreatedMessage ( item ),
2026-06-23 22:11:20 +08:00
})
return nil
}
2026-06-25 18:28:16 +08:00
func buildTicketCreatedMessage ( item * models . Ticket ) string {
if item == nil {
return "工单已创建。"
}
ticketNo := strings . TrimSpace ( item . TicketNo )
if ticketNo == "" {
return fmt . Sprintf ( "工单已创建,工单 ID: %d。" , item . ID )
}
return "工单已创建,工单号:" + ticketNo + "。"
}
2026-06-23 22:11:20 +08:00
func workflowAIPrincipal ( aiAgent models . AIAgent ) * dto . AuthPrincipal {
username := strings . TrimSpace ( aiAgent . Name )
if username == "" {
username = "AI"
}
return & dto . AuthPrincipal {
UserID : 0 ,
Username : username ,
Nickname : username ,
}
}
2026-06-25 22:38:34 +08:00
type workflowConversationUnderstanding struct {
NormalizedMessage string
MessageIntent string
AnswerScope string
Confidence float64
RiskSignals [] string
Reason string
}
type workflowReplyPolicyInput struct {
MessageIntent string
AnswerScope string
UserMessage string
Answerability string
}
type workflowReplyPolicyDecision struct {
Action string
ReplyText string
Reason string
RequiresFlow bool
TargetFlow string
FinalReplySource string
}
func understandConversationMessage ( rawMessage string ) workflowConversationUnderstanding {
message := normalizeWorkflowUserMessage ( rawMessage )
ret := workflowConversationUnderstanding {
NormalizedMessage : message ,
MessageIntent : "unknown" ,
AnswerScope : "needs_clarification" ,
Confidence : 0.5 ,
Reason : "message intent is unclear" ,
}
if message == "" {
ret . MessageIntent = "unknown"
ret . AnswerScope = "needs_clarification"
ret . Confidence = 0.9
ret . Reason = "empty message"
return ret
}
lower := strings . ToLower ( message )
switch {
case isGreetingMessage ( lower ):
ret . MessageIntent = "greeting"
ret . AnswerScope = "direct_reply"
ret . Confidence = 0.98
ret . Reason = "matched greeting phrase"
case containsAnyWorkflowText ( lower , "谢谢" , "感谢" , "多谢" , "辛苦了" , "thank" ):
ret . MessageIntent = "thanks"
ret . AnswerScope = "direct_reply"
ret . Confidence = 0.95
ret . Reason = "matched thanks phrase"
case containsAnyWorkflowText ( lower , "再见" , "拜拜" , "不用了" , "没事了" , "结束" ):
ret . MessageIntent = "end_conversation"
ret . AnswerScope = "direct_reply"
ret . Confidence = 0.9
ret . Reason = "matched ending phrase"
case containsAnyWorkflowText ( lower , "人工" , "转人工" , "真人" , "客服" ):
ret . MessageIntent = "handoff_request"
ret . AnswerScope = "needs_handoff"
ret . Confidence = 0.95
ret . RiskSignals = append ( ret . RiskSignals , "handoff_requested" )
ret . Reason = "matched handoff phrase"
case containsAnyWorkflowText ( lower , "投诉" , "举报" , "差评" , "曝光" , "起诉" , "律师" , "12315" ):
ret . MessageIntent = "complaint"
ret . AnswerScope = "needs_handoff"
ret . Confidence = 0.92
ret . RiskSignals = append ( ret . RiskSignals , "complaint_escalation" )
ret . Reason = "matched complaint phrase"
case containsAnyWorkflowText ( lower , "工单" , "报障" , "售后" , "登记问题" , "记录问题" ):
ret . MessageIntent = "ticket_request"
ret . AnswerScope = "needs_ticket"
ret . Confidence = 0.9
ret . RiskSignals = append ( ret . RiskSignals , "ticket_expected" )
ret . Reason = "matched ticket phrase"
case containsAnyWorkflowText ( lower , "确认" , "可以" , "好的" , "好" , "是的" , "取消" ):
ret . MessageIntent = "confirmation"
ret . AnswerScope = "direct_reply"
ret . Confidence = 0.8
ret . Reason = "matched confirmation phrase"
case isAmbiguousWorkflowQuestion ( lower ):
ret . MessageIntent = "ambiguous_question"
ret . AnswerScope = "needs_clarification"
ret . Confidence = 0.82
ret . Reason = "message lacks a concrete business object"
default :
ret . MessageIntent = "business_question"
ret . AnswerScope = "needs_knowledge"
ret . Confidence = 0.7
ret . Reason = "default business question policy"
}
return ret
}
func decideWorkflowReplyPolicy ( aiAgent models . AIAgent , input workflowReplyPolicyInput ) workflowReplyPolicyDecision {
intent := strings . TrimSpace ( input . MessageIntent )
scope := strings . TrimSpace ( input . AnswerScope )
if answerability := strings . TrimSpace ( input . Answerability ); answerability != "" && answerability != "answerable" {
return workflowReplyPolicyDecision {
Action : "knowledge_fallback" ,
ReplyText : workflowKnowledgeFallbackReply ( aiAgent ),
Reason : "knowledge is not sufficient for business answer" ,
FinalReplySource : "knowledge_fallback" ,
}
}
switch {
case intent == "greeting" :
return workflowReplyPolicyDecision { Action : "direct_reply" , ReplyText : "您好,请问有什么可以帮您?" , Reason : "greeting can be answered directly" , FinalReplySource : "direct_reply" }
case intent == "thanks" :
return workflowReplyPolicyDecision { Action : "direct_reply" , ReplyText : "不客气,如有其他问题可以继续告诉我。" , Reason : "thanks can be answered directly" , FinalReplySource : "direct_reply" }
case intent == "end_conversation" :
return workflowReplyPolicyDecision { Action : "end_conversation" , ReplyText : "好的,如后续还有问题可以随时联系。" , Reason : "conversation ending phrase" , FinalReplySource : "direct_reply" }
case intent == "confirmation" :
return workflowReplyPolicyDecision { Action : "direct_reply" , ReplyText : "好的,请继续补充需要处理的问题。" , Reason : "confirmation without pending interrupt" , FinalReplySource : "direct_reply" }
case intent == "handoff_request" || scope == "needs_handoff" :
return workflowReplyPolicyDecision { Action : "handoff_to_human" , Reason : "user requested human support or risk requires handoff" , RequiresFlow : true , TargetFlow : "handoff_to_human" , FinalReplySource : "handoff_notice" }
case intent == "ticket_request" || scope == "needs_ticket" :
return workflowReplyPolicyDecision { Action : "prepare_ticket" , Reason : "user requested ticket handling" , RequiresFlow : true , TargetFlow : "prepare_ticket" , FinalReplySource : "ticket_result" }
case intent == "ambiguous_question" || scope == "needs_clarification" :
return workflowReplyPolicyDecision { Action : "clarify" , ReplyText : "请补充具体的产品、场景、报错信息或你希望处理的结果,我再继续帮你确认。" , Reason : "message needs clarification" , FinalReplySource : "clarification" }
case scope == "needs_knowledge" :
return workflowReplyPolicyDecision { Action : "retrieve_knowledge" , Reason : "business question should be answered with knowledge evidence" , RequiresFlow : true , TargetFlow : "knowledge" , FinalReplySource : "knowledge_answer" }
default :
return workflowReplyPolicyDecision { Action : "clarify" , ReplyText : "请补充更具体的问题,我再继续帮你处理。" , Reason : "fallback to clarification for unclear policy input" , FinalReplySource : "clarification" }
}
}
func normalizeWorkflowUserMessage ( value string ) string {
value = strings . TrimSpace ( value )
if value == "" {
return ""
}
value = workflowHTMLTagPattern . ReplaceAllString ( value , " " )
value = html . UnescapeString ( value )
value = strings . Join ( strings . Fields ( value ), " " )
return strings . TrimSpace ( value )
}
func isGreetingMessage ( value string ) bool {
trimmed := strings . Trim ( value , " \r\n。.!! ?? ~~ " )
return containsAnyWorkflowText ( trimmed , "你好" , "您好" , "在吗" , "在不在" ) || trimmed == "hello" || trimmed == "hi"
}
func isAmbiguousWorkflowQuestion ( value string ) bool {
trimmed := strings . Trim ( value , " \r\n。.!! ?? ~~ " )
return containsAnyWorkflowText ( trimmed , "怎么弄" , "怎么办" , "怎么处理" , "帮我看看" , "有问题" ) || len ([] rune ( trimmed )) <= 3
}
func containsAnyWorkflowText ( value string , needles ... string ) bool {
for _ , needle := range needles {
if strings . Contains ( value , needle ) {
return true
}
}
return false
}
2026-06-23 22:11:20 +08:00
func ( e * Executor ) executeHumanConfirm ( state * runState , node dsl . Node ) error {
prompt := strings . TrimSpace ( toString ( state . resolveInput ( node , "prompt" )))
if prompt == "" {
prompt = "请确认是否继续。"
}
infoPreview , err := json . Marshal ( map [ string ] string { "message" : prompt })
if err != nil {
return err
}
state . result . Interrupted = true
state . result . CheckPointID = buildWorkflowCheckPointID ( state . input , node . ID )
checkpoint , err := json . Marshal ( workflowCheckPoint {
Definition : state . input . Definition ,
ConfirmNodeID : node . ID ,
Vars : state . vars ,
})
if err != nil {
return err
}
state . result . CheckPointData = string ( checkpoint )
state . result . Interrupts = [] InterruptSummary {
{
Type : workflowregistry . NodeTypeHumanConfirm ,
ID : node . ID ,
InfoPreview : string ( infoPreview ),
},
}
return nil
}
func buildWorkflowCheckPointID ( input Input , nodeID string ) string {
return fmt . Sprintf ( "workflow:%d:%d:%s" , input . Conversation . ID , input . UserMessage . ID , strings . TrimSpace ( nodeID ))
}
func ( e * Executor ) executePrepareTicketDraft ( ctx context . Context , state * runState , node dsl . Node ) error {
issue := strings . TrimSpace ( toString ( state . resolveInput ( node , "issue" )))
input := graphs . PrepareTicketDraftInput {
Issue : issue ,
}
if title := strings . TrimSpace ( readStringConfig ( node . Config , "title" )); title != "" {
input . Title = title
}
if description := strings . TrimSpace ( readStringConfig ( node . Config , "description" )); description != "" {
input . Description = description
}
if impact := strings . TrimSpace ( readStringConfig ( node . Config , "impact" )); impact != "" {
input . Impact = impact
}
if expectedOutcome := strings . TrimSpace ( readStringConfig ( node . Config , "expectedOutcome" )); expectedOutcome != "" {
input . ExpectedOutcome = expectedOutcome
}
if currentAttempt := strings . TrimSpace ( readStringConfig ( node . Config , "currentAttempt" )); currentAttempt != "" {
input . CurrentAttempt = currentAttempt
}
args , err := json . Marshal ( input )
if err != nil {
return err
}
raw , err := graphs . NewPrepareTicketDraftGraph ( state . input . Conversation ). Run ( ctx , string ( args ))
if err != nil {
return err
}
var result graphs . PrepareTicketDraftResult
if err := json . Unmarshal ([] byte ( raw ), & result ); err != nil {
return err
}
state . setNodeVars ( node . ID , map [ string ] any {
"ticketDraft" : map [ string ] any {
"ready" : result . Ready ,
"title" : strings . TrimSpace ( result . Title ),
"description" : strings . TrimSpace ( result . Description ),
"missingFields" : result . MissingFields ,
"followUpQuestions" : result . FollowUpQuestions ,
"conversationFacts" : result . ConversationFacts ,
},
})
return nil
}
2026-06-23 21:54:51 +08:00
func ( e * Executor ) executeAnalyzeConversation ( ctx context . Context , state * runState , node dsl . Node ) error {
userMessage := strings . TrimSpace ( toString ( state . resolveInput ( node , "userMessage" )))
input := graphs . AnalyzeConversationInput {
ObservedIssue : userMessage ,
}
if strings . TrimSpace ( readStringConfig ( node . Config , "goal" )) != "" {
input . Goal = strings . TrimSpace ( readStringConfig ( node . Config , "goal" ))
}
if readBoolConfig ( node . Config , "needTicket" ) {
input . NeedTicket = true
}
if readBoolConfig ( node . Config , "needHumanHandoff" ) {
input . NeedHumanHandoff = true
}
if readBoolConfig ( node . Config , "needQualityCheck" ) {
input . NeedQualityCheck = true
}
if strings . TrimSpace ( readStringConfig ( node . Config , "additionalContext" )) != "" {
input . AdditionalContext = strings . TrimSpace ( readStringConfig ( node . Config , "additionalContext" ))
}
args , err := json . Marshal ( input )
if err != nil {
return err
}
raw , err := graphs . NewAnalyzeConversationGraph ( state . input . Conversation ). Run ( ctx , string ( args ))
if err != nil {
return err
}
var result graphs . AnalyzeConversationResult
if err := json . Unmarshal ([] byte ( raw ), & result ); err != nil {
return err
}
nextAction := strings . TrimSpace ( result . RecommendedNextAction )
state . setNodeVars ( node . ID , map [ string ] any {
"intent" : strings . TrimSpace ( result . UserIntent ),
"riskLevel" : strings . TrimSpace ( result . RiskLevel ),
"needTicket" : nextAction == "prepare_ticket" ,
"needHumanHandoff" : nextAction == "handoff_to_human" ,
})
return nil
}
2026-06-23 21:48:20 +08:00
func ( e * Executor ) executeHandoffToHuman ( state * runState , node dsl . Node ) error {
2026-06-24 11:02:16 +08:00
if _ , hasConfirmedInput := node . Inputs [ "confirmed" ]; hasConfirmedInput && ! truthy ( state . resolveInput ( node , "confirmed" )) {
state . setNodeVars ( node . ID , map [ string ] any {
"handoffId" : int64 ( 0 ),
"reason" : strings . TrimSpace ( toString ( state . resolveInput ( node , "reason" ))),
"decision" : "cancelled" ,
"teamId" : int64 ( 0 ),
"assigneeId" : int64 ( 0 ),
"message" : "" ,
"skipped" : true ,
})
return nil
}
2026-06-23 21:48:20 +08:00
reason := strings . TrimSpace ( toString ( state . resolveInput ( node , "reason" )))
result , err := services . ConversationHumanDispatchService . HandoffByAIWithRequestID (
state . input . Conversation . ID ,
state . input . AIAgent ,
reason ,
strings . TrimSpace ( state . input . UserMessage . RequestID ),
)
if err != nil {
return err
}
output := map [ string ] any {
"handoffId" : int64 ( 0 ),
"reason" : reason ,
"decision" : "" ,
"teamId" : int64 ( 0 ),
"assigneeId" : int64 ( 0 ),
"message" : "" ,
}
if result != nil {
output [ "decision" ] = string ( result . Decision )
output [ "teamId" ] = result . TeamID
output [ "assigneeId" ] = result . AssigneeID
output [ "message" ] = strings . TrimSpace ( result . Message )
}
state . setNodeVars ( node . ID , output )
return nil
}
2026-06-23 09:34:08 +08:00
func ( e * Executor ) executeKnowledgeRetrieve ( ctx context . Context , state * runState , node dsl . Node ) error {
query := strings . TrimSpace ( toString ( state . resolveInput ( node , "query" )))
retriever := retrievers . NewKnowledgeRetriever ( state . input . AIAgent )
result , err := retriever . RetrieveContext ( ctx , query )
if err != nil {
return err
}
items := make ([] map [ string ] any , 0 , len ( result . ContextResults ))
for _ , item := range result . ContextResults {
items = append ( items , map [ string ] any {
"knowledgeBaseId" : item . KnowledgeBaseID ,
"documentId" : item . DocumentID ,
"chunkId" : item . ChunkID ,
"content" : item . Content ,
"score" : item . Score ,
})
}
state . result . RetrieverCount = len ( result . Hits )
state . setNodeVars ( node . ID , map [ string ] any {
"items" : items ,
"summary" : result . ContextText ,
})
return nil
}
func ( e * Executor ) executeAnswerabilityGate ( state * runState , node dsl . Node ) error {
items := state . resolveInput ( node , "knowledgeItems" )
answerability := "unanswerable"
reason := "no retrieved knowledge items"
if hasItems ( items ) {
answerability = "answerable"
reason = "retrieved knowledge items are available"
}
state . setNodeVars ( node . ID , map [ string ] any {
"answerability" : answerability ,
"reason" : reason ,
})
return nil
}
func ( e * Executor ) executeLLMReply ( ctx context . Context , state * runState , node dsl . Node ) error {
if staticReply := strings . TrimSpace ( readStringConfig ( node . Config , "staticReply" )); staticReply != "" {
state . setNodeVars ( node . ID , map [ string ] any { "replyText" : staticReply })
return nil
}
userPrompt := strings . TrimSpace ( toString ( state . resolveInput ( node , "userMessage" )))
if userPrompt == "" {
userPrompt = strings . TrimSpace ( state . input . UserMessage . Content )
}
knowledgeItems := toString ( state . resolveInput ( node , "knowledgeItems" ))
systemPrompt := strings . TrimSpace ( state . input . AIAgent . SystemPrompt )
if prompt := strings . TrimSpace ( readStringConfig ( node . Config , "prompt" )); prompt != "" {
systemPrompt = strings . TrimSpace ( systemPrompt + "\n\n" + prompt )
}
2026-06-25 18:28:16 +08:00
if _ , declaresKnowledge := node . Inputs [ "knowledgeItems" ]; declaresKnowledge && len ( utils . SplitInt64s ( state . input . AIAgent . KnowledgeIDs )) > 0 && ! hasItems ( state . resolveInput ( node , "knowledgeItems" )) {
state . setNodeVars ( node . ID , map [ string ] any { "replyText" : workflowKnowledgeFallbackReply ( state . input . AIAgent )})
return nil
}
2026-06-23 09:34:08 +08:00
if knowledgeItems != "" {
userPrompt = userPrompt + "\n\nKnowledge context:\n" + knowledgeItems
}
result , err := ai . LLM . ChatWithConfig ( ctx , state . input . AIConfig , systemPrompt , userPrompt )
if err != nil {
return err
}
state . result . PromptTokens += result . PromptTokens
state . result . CompletionTokens += result . CompletionTokens
state . setNodeVars ( node . ID , map [ string ] any { "replyText" : result . Content })
return nil
}
2026-06-25 18:28:16 +08:00
func workflowKnowledgeFallbackReply ( aiAgent models . AIAgent ) string {
if reply := strings . TrimSpace ( aiAgent . FallbackMessage ); reply != "" {
return reply
}
if aiAgent . FallbackMode == enums . AIAgentFallbackModeSuggestRetry {
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
}
return "当前知识库暂无明确信息。"
}
2026-06-23 09:34:08 +08:00
func ( s * runState ) nextNodeID ( sourceNodeID string ) ( string , bool , error ) {
edges := s . outgoing [ sourceNodeID ]
if len ( edges ) == 0 {
return "" , false , nil
}
2026-06-25 21:08:59 +08:00
node := s . nodesByID [ sourceNodeID ]
if strings . TrimSpace ( node . Type ) != workflowregistry . NodeTypeCondition {
return strings . TrimSpace ( edges [ 0 ]. Target ), true , nil
}
config := dsl . ConditionConfig {}
if len ( node . Config ) > 0 {
if err := json . Unmarshal ( node . Config , & config ); err != nil {
return "" , false , fmt . Errorf ( "invalid condition node config: %w" , err )
}
}
2026-06-24 17:38:57 +08:00
evaluations := make ([] conditionEvaluation , 0 )
2026-06-25 21:08:59 +08:00
for _ , branch := range config . Branches {
if branch . Default {
2026-06-23 09:34:08 +08:00
continue
}
2026-06-25 21:08:59 +08:00
matched , evaluation , err := s . evaluateConditionBranch ( sourceNodeID , branch )
2026-06-23 09:34:08 +08:00
if err != nil {
return "" , false , err
}
2026-06-24 17:38:57 +08:00
evaluations = append ( evaluations , evaluation )
2026-06-23 09:34:08 +08:00
if matched {
2026-06-25 21:08:59 +08:00
targetNodeID := strings . TrimSpace ( branch . TargetNodeID )
2026-06-24 17:38:57 +08:00
s . branchDecisions [ sourceNodeID ] = branchDecision {
2026-06-25 21:08:59 +08:00
SelectedEdgeID : s . edgeIDForTarget ( sourceNodeID , targetNodeID ),
SelectedBranchID : strings . TrimSpace ( branch . ID ),
SelectedBranchName : strings . TrimSpace ( branch . Name ),
SelectedTargetNodeID : targetNodeID ,
Reason : "condition branch matched" ,
2026-06-24 17:38:57 +08:00
Evaluations : evaluations ,
}
2026-06-25 21:08:59 +08:00
return targetNodeID , true , nil
2026-06-23 09:34:08 +08:00
}
}
2026-06-25 21:08:59 +08:00
for _ , branch := range config . Branches {
if ! branch . Default {
continue
2026-06-23 09:34:08 +08:00
}
2026-06-25 21:08:59 +08:00
targetNodeID := strings . TrimSpace ( branch . TargetNodeID )
2026-06-24 17:38:57 +08:00
s . branchDecisions [ sourceNodeID ] = branchDecision {
2026-06-25 21:08:59 +08:00
SelectedEdgeID : s . edgeIDForTarget ( sourceNodeID , targetNodeID ),
SelectedBranchID : strings . TrimSpace ( branch . ID ),
SelectedBranchName : strings . TrimSpace ( branch . Name ),
SelectedTargetNodeID : targetNodeID ,
Reason : "no condition branch matched; selected default branch" ,
Evaluations : evaluations ,
2026-06-24 17:38:57 +08:00
}
2026-06-25 21:08:59 +08:00
return targetNodeID , true , nil
}
s . branchDecisions [ sourceNodeID ] = branchDecision {
Reason : "no condition branch matched and no default branch exists" ,
Evaluations : evaluations ,
2026-06-24 17:38:57 +08:00
}
2026-06-23 09:34:08 +08:00
return "" , false , nil
}
2026-06-25 21:08:59 +08:00
func ( s * runState ) evaluateConditionBranch ( sourceNodeID string , branch dsl . ConditionBranch ) ( bool , conditionEvaluation , error ) {
condition := branch . Condition
targetNodeID := strings . TrimSpace ( branch . TargetNodeID )
2026-06-24 17:38:57 +08:00
evaluation := conditionEvaluation {
2026-06-25 21:08:59 +08:00
EdgeID : s . edgeIDForTarget ( sourceNodeID , targetNodeID ),
BranchID : strings . TrimSpace ( branch . ID ),
BranchName : strings . TrimSpace ( branch . Name ),
TargetNodeID : targetNodeID ,
2026-06-24 17:38:57 +08:00
}
2026-06-23 09:34:08 +08:00
if condition == nil {
2026-06-24 17:38:57 +08:00
evaluation . Matched = true
return true , evaluation , nil
2026-06-23 09:34:08 +08:00
}
left := s . resolveSelector ( condition . Left )
operator := strings . TrimSpace ( condition . Operator )
2026-06-24 17:38:57 +08:00
if condition . Left != nil {
evaluation . SourceNodeID = strings . TrimSpace ( condition . Left . NodeID )
evaluation . SourceField = strings . TrimSpace ( condition . Left . Field )
}
evaluation . Operator = operator
evaluation . LeftValue = left
evaluation . RightValue = condition . Right
2026-06-23 09:34:08 +08:00
if operator == "" && strings . TrimSpace ( condition . Expression ) != "" {
2026-06-24 17:38:57 +08:00
return false , evaluation , fmt . Errorf ( "free-form workflow condition expressions are not supported" )
2026-06-23 09:34:08 +08:00
}
2026-06-24 17:38:57 +08:00
var matched bool
2026-06-23 09:34:08 +08:00
switch operator {
case "eq" , "equals" :
2026-06-24 17:38:57 +08:00
matched = compareString ( left , condition . Right ) == 0
2026-06-23 09:34:08 +08:00
case "neq" , "not_equals" :
2026-06-24 17:38:57 +08:00
matched = compareString ( left , condition . Right ) != 0
2026-06-23 09:34:08 +08:00
case "contains" :
2026-06-24 17:38:57 +08:00
matched = strings . Contains ( toString ( left ), toString ( condition . Right ))
2026-06-23 09:34:08 +08:00
case "exists" :
2026-06-24 17:38:57 +08:00
matched = exists ( left )
2026-06-23 09:34:08 +08:00
case "not_exists" :
2026-06-24 17:38:57 +08:00
matched = ! exists ( left )
2026-06-23 09:34:08 +08:00
case "truthy" , "is_true" :
2026-06-24 17:38:57 +08:00
matched = truthy ( left )
2026-06-23 09:34:08 +08:00
case "falsy" , "is_false" :
2026-06-24 17:38:57 +08:00
matched = ! truthy ( left )
2026-06-23 09:34:08 +08:00
case "gt" :
2026-06-24 17:38:57 +08:00
matched = compareNumber ( left , condition . Right ) > 0
2026-06-23 09:34:08 +08:00
case "gte" :
2026-06-24 17:38:57 +08:00
matched = compareNumber ( left , condition . Right ) >= 0
2026-06-23 09:34:08 +08:00
case "lt" :
2026-06-24 17:38:57 +08:00
matched = compareNumber ( left , condition . Right ) < 0
2026-06-23 09:34:08 +08:00
case "lte" :
2026-06-24 17:38:57 +08:00
matched = compareNumber ( left , condition . Right ) <= 0
2026-06-23 09:34:08 +08:00
default :
2026-06-24 17:38:57 +08:00
return false , evaluation , fmt . Errorf ( "unsupported workflow condition operator: %s" , operator )
2026-06-23 09:34:08 +08:00
}
2026-06-24 17:38:57 +08:00
evaluation . Matched = matched
return matched , evaluation , nil
2026-06-23 09:34:08 +08:00
}
2026-06-25 21:08:59 +08:00
func ( s * runState ) edgeIDForTarget ( sourceNodeID string , targetNodeID string ) string {
for _ , edge := range s . outgoing [ sourceNodeID ] {
if strings . TrimSpace ( edge . Target ) == targetNodeID {
return strings . TrimSpace ( edge . ID )
}
}
return ""
}
2026-06-23 09:34:08 +08:00
func ( s * runState ) setNodeVars ( nodeID string , values map [ string ] any ) {
s . vars [ nodeID ] = values
}
func ( s * runState ) resolveInput ( node dsl . Node , inputName string ) any {
selector , ok := node . Inputs [ inputName ]
if ! ok {
return nil
}
return s . resolveSelector ( & selector )
}
2026-06-23 22:57:18 +08:00
func ( s * runState ) nodeInputPreview ( node dsl . Node ) map [ string ] any {
inputs := make ( map [ string ] any , len ( node . Inputs ))
for name , selector := range node . Inputs {
inputs [ name ] = s . resolveSelector ( & selector )
}
ret := map [ string ] any {
"inputs" : inputs ,
}
if len ( node . Config ) > 0 {
var cfg any
if err := json . Unmarshal ( node . Config , & cfg ); err == nil {
ret [ "config" ] = cfg
} else {
ret [ "config" ] = string ( node . Config )
}
}
return ret
}
2026-06-24 17:38:57 +08:00
func ( s * runState ) nodeOutputPreview ( nodeID string ) map [ string ] any {
ret := map [ string ] any {
"outputs" : s . vars [ nodeID ],
}
if decision , ok := s . branchDecisions [ nodeID ]; ok {
ret [ "branchDecision" ] = decision
}
return ret
}
2026-06-23 22:57:18 +08:00
func workflowPreviewJSON ( value any ) string {
raw , err := json . Marshal ( value )
if err != nil {
return ""
}
const maxPreviewBytes = 2000
if len ( raw ) <= maxPreviewBytes {
return string ( raw )
}
return string ( raw [: maxPreviewBytes ])
}
2026-06-23 09:34:08 +08:00
func ( s * runState ) resolveSelector ( selector * dsl . VariableSelector ) any {
if selector == nil {
return nil
}
fields := s . vars [ strings . TrimSpace ( selector . NodeID )]
if fields == nil {
return nil
}
return fields [ strings . TrimSpace ( selector . Field )]
}
func readStringConfig ( raw json . RawMessage , key string ) string {
if len ( raw ) == 0 {
return ""
}
var cfg map [ string ] any
if err := json . Unmarshal ( raw , & cfg ); err != nil {
return ""
}
return toString ( cfg [ key ])
}
2026-06-23 21:54:51 +08:00
func readBoolConfig ( raw json . RawMessage , key string ) bool {
if len ( raw ) == 0 {
return false
}
var cfg map [ string ] any
if err := json . Unmarshal ( raw , & cfg ); err != nil {
return false
}
return truthy ( cfg [ key ])
}
2026-06-23 09:34:08 +08:00
func compareString ( left any , right any ) int {
return strings . Compare ( toString ( left ), toString ( right ))
}
func compareNumber ( left any , right any ) int {
leftNum := toFloat ( left )
rightNum := toFloat ( right )
switch {
case leftNum > rightNum :
return 1
case leftNum < rightNum :
return - 1
default :
return 0
}
}
func toString ( value any ) string {
switch v := value .( type ) {
case nil :
return ""
case string :
return v
case fmt . Stringer :
return v . String ()
case [] map [ string ] any :
buf , _ := json . Marshal ( v )
return string ( buf )
default :
return strings . TrimSpace ( fmt . Sprint ( v ))
}
}
func toFloat ( value any ) float64 {
switch v := value .( type ) {
case int :
return float64 ( v )
case int64 :
return float64 ( v )
case float64 :
return v
case float32 :
return float64 ( v )
case json . Number :
f , _ := v . Float64 ()
return f
case string :
f , _ := strconv . ParseFloat ( strings . TrimSpace ( v ), 64 )
return f
default :
return 0
}
}
2026-06-23 22:11:20 +08:00
func asMap ( value any ) map [ string ] any {
switch v := value .( type ) {
case map [ string ] any :
return v
case map [ string ] string :
ret := make ( map [ string ] any , len ( v ))
for key , item := range v {
ret [ key ] = item
}
return ret
case string :
var ret map [ string ] any
if err := json . Unmarshal ([] byte ( strings . TrimSpace ( v )), & ret ); err == nil {
return ret
}
}
return map [ string ] any {}
}
2026-06-23 09:34:08 +08:00
func truthy ( value any ) bool {
switch v := value .( type ) {
case nil :
return false
case bool :
return v
case string :
normalized := strings . ToLower ( strings . TrimSpace ( v ))
return normalized != "" && normalized != "false" && normalized != "0"
default :
return ! reflect . ValueOf ( value ). IsZero ()
}
}
func exists ( value any ) bool {
if value == nil {
return false
}
switch v := value .( type ) {
case string :
return strings . TrimSpace ( v ) != ""
default :
return true
}
}
func hasItems ( value any ) bool {
if value == nil {
return false
}
rv := reflect . ValueOf ( value )
switch rv . Kind () {
case reflect . Array , reflect . Slice , reflect . Map :
return rv . Len () > 0
default :
return exists ( value )
}
}