新版本流程编辑器

This commit is contained in:
mlogclub
2026-07-27 19:04:15 +08:00
parent d33960961a
commit e072141953
281 changed files with 25145 additions and 3405 deletions
+128 -2
View File
@@ -265,11 +265,13 @@ func (s *runState) startNodeID() string {
func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.Node) error {
switch node.Type {
case workflowregistry.NodeTypeStart:
userMessage := strings.TrimSpace(state.input.UserMessage.Content)
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),
"userMessage": userMessage,
"query": userMessage,
"conversationState": state.input.Conversation.Status,
})
case workflowregistry.NodeTypeConversationUnderstanding:
@@ -292,6 +294,8 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No
return e.executeCreateTicket(state, node)
case workflowregistry.NodeTypeLLMReply:
return e.executeLLMReply(ctx, state, node)
case workflowregistry.NodeTypeLLM:
return e.executeOfficialLLM(ctx, state, node)
case workflowregistry.NodeTypeSendReply:
replyText := strings.TrimSpace(toString(state.resolveInput(node, "replyText")))
state.result.ReplyText = replyText
@@ -302,13 +306,38 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No
case workflowregistry.NodeTypeHandoffToHuman:
return e.executeHandoffToHuman(state, node)
case workflowregistry.NodeTypeEnd:
state.setNodeVars(node.ID, map[string]any{"status": "completed"})
outputs := state.resolvedInputs(node)
outputs["status"] = "completed"
state.setNodeVars(node.ID, outputs)
if state.result.ReplyText == "" {
state.result.ReplyText = strings.TrimSpace(toString(outputs["result"]))
}
default:
return fmt.Errorf("unsupported workflow node type: %s", node.Type)
}
return nil
}
func (e *Executor) executeOfficialLLM(ctx context.Context, state *runState, node dsl.Node) error {
systemPrompt := strings.TrimSpace(toString(state.resolveInput(node, "systemPrompt")))
if systemPrompt == "" {
systemPrompt = strings.TrimSpace(state.input.AIAgent.SystemPrompt)
}
userPrompt := strings.TrimSpace(toString(state.resolveInput(node, "prompt")))
if userPrompt == "" {
userPrompt = strings.TrimSpace(state.input.UserMessage.Content)
}
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.result.ReplyText = strings.TrimSpace(result.Content)
state.setNodeVars(node.ID, map[string]any{"result": result.Content})
return nil
}
func (e *Executor) executeConversationUnderstanding(state *runState, node dsl.Node) error {
rawMessage := strings.TrimSpace(toString(state.resolveInput(node, "userMessage")))
if rawMessage == "" {
@@ -862,6 +891,9 @@ func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) {
if strings.TrimSpace(node.Type) != workflowregistry.NodeTypeCondition {
return strings.TrimSpace(edges[0].TargetNodeID), true, nil
}
if rawConditions, ok := node.Data.Extra["conditions"]; ok {
return s.nextFlowGramConditionNodeID(sourceNodeID, rawConditions)
}
config := dsl.ConditionConfig{}
if len(node.Data.Config) > 0 {
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
@@ -913,6 +945,100 @@ func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) {
return "", false, nil
}
func (s *runState) nextFlowGramConditionNodeID(sourceNodeID string, raw json.RawMessage) (string, bool, error) {
var conditions []dsl.FlowGramConditionItem
if err := json.Unmarshal(raw, &conditions); err != nil {
return "", false, fmt.Errorf("invalid FlowGram condition data: %w", err)
}
evaluations := make([]conditionEvaluation, 0, len(conditions))
for _, item := range conditions {
matched, evaluation, err := s.evaluateFlowGramCondition(sourceNodeID, item)
if err != nil {
return "", false, err
}
evaluations = append(evaluations, evaluation)
if !matched {
continue
}
if edge, ok := s.edgeForSourcePort(sourceNodeID, item.Key); ok {
targetNodeID := strings.TrimSpace(edge.TargetNodeID)
s.branchDecisions[sourceNodeID] = branchDecision{
SelectedEdgeID: strings.TrimSpace(item.Key),
SelectedBranchID: strings.TrimSpace(item.Key),
SelectedTargetNodeID: targetNodeID,
Reason: "condition branch matched",
Evaluations: evaluations,
}
return targetNodeID, true, nil
}
}
if edge, ok := s.edgeForSourcePort(sourceNodeID, "else"); ok {
targetNodeID := strings.TrimSpace(edge.TargetNodeID)
s.branchDecisions[sourceNodeID] = branchDecision{
SelectedEdgeID: "else",
SelectedBranchID: "else",
SelectedTargetNodeID: targetNodeID,
Reason: "no condition branch matched; selected else branch",
Evaluations: evaluations,
}
return targetNodeID, true, nil
}
return "", false, nil
}
func (s *runState) evaluateFlowGramCondition(sourceNodeID string, item dsl.FlowGramConditionItem) (bool, conditionEvaluation, error) {
left := s.resolveValue(item.Value.Left)
right := s.resolveValue(item.Value.Right)
operator := strings.TrimSpace(item.Value.Operator)
evaluation := conditionEvaluation{
EdgeID: strings.TrimSpace(item.Key),
BranchID: strings.TrimSpace(item.Key),
SourceNodeID: sourceNodeID,
Operator: operator,
LeftValue: left,
RightValue: right,
}
evaluation.SourceNodeID, evaluation.SourceField, _ = item.Value.Left.Ref()
var matched bool
switch operator {
case "eq", "equals":
matched = compareString(left, right) == 0
case "neq", "not_equals":
matched = compareString(left, right) != 0
case "contains":
matched = strings.Contains(toString(left), toString(right))
case "exists":
matched = exists(left)
case "not_exists":
matched = !exists(left)
case "truthy", "is_true":
matched = truthy(left)
case "falsy", "is_false":
matched = !truthy(left)
case "gt":
matched = compareNumber(left, right) > 0
case "gte":
matched = compareNumber(left, right) >= 0
case "lt":
matched = compareNumber(left, right) < 0
case "lte":
matched = compareNumber(left, right) <= 0
default:
return false, evaluation, fmt.Errorf("unsupported workflow condition operator: %s", operator)
}
evaluation.Matched = matched
return matched, evaluation, nil
}
func (s *runState) edgeForSourcePort(sourceNodeID string, sourcePortID string) (dsl.Edge, bool) {
for _, edge := range s.outgoing[sourceNodeID] {
if strings.TrimSpace(edge.SourcePortID) == strings.TrimSpace(sourcePortID) {
return edge, true
}
}
return dsl.Edge{}, false
}
func (s *runState) evaluateConditionBranch(sourceNodeID string, branch dsl.ConditionBranch) (bool, conditionEvaluation, error) {
condition := branch.Condition
targetNodeID := strings.TrimSpace(branch.TargetNodeID)
@@ -44,6 +44,61 @@ func TestExecutorRoutesByConditionNodeBranch(t *testing.T) {
assertPath(t, result.NodePath, []string{"start_1", "condition_1", "vip_reply", "send_vip", "end_1"})
}
func TestExecutorRoutesOfficialFlowGramConditionPorts(t *testing.T) {
definition := officialFlowGramConditionDefinition()
result, err := NewExecutor().Execute(context.Background(), Input{
Definition: definition,
UserMessage: models.Message{Content: "hello FlowGram"},
})
if err != nil {
t.Fatalf("execute workflow: %v", err)
}
assertPath(t, result.NodePath, []string{"start_0", "condition_0", "matched_end"})
result, err = NewExecutor().Execute(context.Background(), Input{
Definition: definition,
UserMessage: models.Message{Content: "goodbye"},
})
if err != nil {
t.Fatalf("execute workflow else branch: %v", err)
}
assertPath(t, result.NodePath, []string{"start_0", "condition_0", "else_end"})
}
func officialFlowGramConditionDefinition() dsl.Definition {
return dsl.Definition{
Nodes: []dsl.Node{
{ID: "start_0", Type: workflowregistry.NodeTypeStart, Data: dsl.NodeData{Title: "Start"}},
{
ID: "condition_0",
Type: workflowregistry.NodeTypeCondition,
Data: dsl.NodeData{
Title: "Condition",
Extra: map[string]json.RawMessage{
"conditions": mustMarshalWorkflowTestConfig([]dsl.FlowGramConditionItem{
{
Key: "if_0",
Value: dsl.FlowGramCondition{
Left: dsl.RefValue("start_0", "query"),
Operator: "contains",
Right: dsl.ConstantValue("hello"),
},
},
}),
},
},
},
{ID: "matched_end", Type: workflowregistry.NodeTypeEnd, Data: dsl.NodeData{Title: "End"}},
{ID: "else_end", Type: workflowregistry.NodeTypeEnd, Data: dsl.NodeData{Title: "End"}},
},
Edges: []dsl.Edge{
{SourceNodeID: "start_0", TargetNodeID: "condition_0"},
{SourceNodeID: "condition_0", TargetNodeID: "matched_end", SourcePortID: "if_0"},
{SourceNodeID: "condition_0", TargetNodeID: "else_end", SourcePortID: "else"},
},
}
}
func TestExecutorConditionNodeTraceExplainsMatchedEdge(t *testing.T) {
result, err := NewExecutor().Execute(context.Background(), Input{
Definition: conditionalReplyDefinition(),
+16 -4
View File
@@ -5,10 +5,11 @@ import "encoding/json"
const SchemaVersion = 2
type Definition struct {
SchemaVersion int `json:"schemaVersion"`
Nodes []Node `json:"nodes"`
Annotations []Node `json:"annotations,omitempty"`
Edges []Edge `json:"edges"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Nodes []Node `json:"nodes"`
Annotations []Node `json:"annotations,omitempty"`
Edges []Edge `json:"edges"`
GlobalVariable json.RawMessage `json:"globalVariable,omitempty"`
}
type Node struct {
@@ -79,6 +80,17 @@ type Condition struct {
Right any `json:"right,omitempty"`
}
type FlowGramConditionItem struct {
Key string `json:"key"`
Value FlowGramCondition `json:"value"`
}
type FlowGramCondition struct {
Left Value `json:"left"`
Operator string `json:"operator"`
Right Value `json:"right"`
}
func RefValue(nodeID string, field string) Value {
return Value{Type: ValueTypeRef, Content: []string{nodeID, field}}
}
+42
View File
@@ -9,6 +9,18 @@ const (
NodeTypeKnowledgeRetrieve = "knowledge_retrieve"
NodeTypeAnswerabilityGate = "answerability_gate"
NodeTypeLLMReply = "llm_reply"
NodeTypeLLM = "llm"
NodeTypeHTTP = "http"
NodeTypeCode = "code"
NodeTypeVariable = "variable"
NodeTypeMultiCondition = "multi-condition"
NodeTypeLoop = "loop"
NodeTypeBlockStart = "block-start"
NodeTypeBlockEnd = "block-end"
NodeTypeComment = "comment"
NodeTypeContinue = "continue"
NodeTypeBreak = "break"
NodeTypeGroup = "group"
NodeTypeCondition = "condition"
NodeTypeAnalyzeConversation = "analyze_conversation"
NodeTypePrepareTicketDraft = "prepare_ticket_draft"
@@ -297,9 +309,39 @@ func DefaultRegistry() *Registry {
output("status", "结束状态", VariableTypeString, "工作流执行结束时的状态。"),
},
},
NodeSpec{
Type: NodeTypeLLM,
Title: "LLM",
Description: "Call the large language model and generate responses.",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("result", "Result", VariableTypeString, "The generated model response."),
},
},
officialNodeSpec(NodeTypeHTTP, "HTTP", "Send an HTTP request."),
officialNodeSpec(NodeTypeCode, "Code", "Run JavaScript code."),
officialNodeSpec(NodeTypeVariable, "Variable", "Assign workflow variables."),
officialNodeSpec(NodeTypeMultiCondition, "Multi Condition", "Route through multiple condition branches."),
officialNodeSpec(NodeTypeLoop, "Loop", "Iterate over an array in a sub-canvas."),
officialNodeSpec(NodeTypeBlockStart, "Block Start", "Start a container block."),
officialNodeSpec(NodeTypeBlockEnd, "Block End", "End a container block."),
officialNodeSpec(NodeTypeComment, "Comment", "Add a canvas annotation."),
officialNodeSpec(NodeTypeContinue, "Continue", "Continue the current loop."),
officialNodeSpec(NodeTypeBreak, "Break", "Break the current loop."),
officialNodeSpec(NodeTypeGroup, "Group", "Group related workflow nodes."),
)
}
func officialNodeSpec(nodeType string, title string, description string) NodeSpec {
return NodeSpec{
Type: nodeType,
Title: title,
Description: description,
Icon: "",
RiskLevel: NodeRiskLevelLow,
}
}
func requiredInput(name string, label string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Label: label, Type: variableType, Required: true, Description: description}
}
@@ -257,6 +257,10 @@ func (v *definitionValidator) validateConditions() {
if strings.TrimSpace(node.Type) != registry.NodeTypeCondition {
continue
}
if rawConditions, ok := node.Data.Extra["conditions"]; ok {
v.validateFlowGramConditions(index, node, rawConditions)
continue
}
field := fmt.Sprintf("nodes[%d].config.branches", index)
config := dsl.ConditionConfig{}
if len(node.Data.Config) > 0 {
@@ -307,6 +311,72 @@ func (v *definitionValidator) validateConditions() {
}
}
func (v *definitionValidator) validateFlowGramConditions(index int, node dsl.Node, raw json.RawMessage) {
field := fmt.Sprintf("nodes[%d].data.conditions", index)
var conditions []dsl.FlowGramConditionItem
if err := json.Unmarshal(raw, &conditions); err != nil {
v.addError(field, "condition data must be valid JSON")
return
}
if len(conditions) == 0 {
v.addError(field, "condition node must include at least one condition")
return
}
seenKeys := make(map[string]struct{}, len(conditions))
for conditionIndex, item := range conditions {
itemField := fmt.Sprintf("%s[%d]", field, conditionIndex)
key := strings.TrimSpace(item.Key)
if key == "" {
v.addError(itemField+".key", "condition key is required")
} else if _, exists := seenKeys[key]; exists {
v.addError(itemField+".key", "duplicate condition key: "+key)
}
seenKeys[key] = struct{}{}
if !v.hasConditionPortEdge(strings.TrimSpace(node.ID), key) {
v.addError(itemField+".key", "condition output port must have an outgoing edge: "+key)
}
v.validateFlowGramCondition(itemField+".value", strings.TrimSpace(node.ID), item.Value)
}
if !v.hasConditionPortEdge(strings.TrimSpace(node.ID), "else") {
v.addError(field, "condition else port must have an outgoing edge")
}
}
func (v *definitionValidator) validateFlowGramCondition(field string, sourceNodeID string, condition dsl.FlowGramCondition) {
operator := strings.TrimSpace(condition.Operator)
if !isSupportedConditionOperator(operator) {
v.addError(field+".operator", "unsupported condition operator: "+operator)
return
}
sourceSelectorNodeID, sourceField, ok := condition.Left.Ref()
sourceSelectorNodeID = strings.TrimSpace(sourceSelectorNodeID)
sourceField = strings.TrimSpace(sourceField)
if !ok || sourceSelectorNodeID == "" || sourceField == "" {
v.addError(field+".left", "condition left variable is required")
return
}
if _, exists := v.nodesByID[sourceSelectorNodeID]; !exists {
v.addError(field+".left", "condition source node does not exist: "+sourceSelectorNodeID)
return
}
if sourceNodeID != "" && !v.hasPath(sourceSelectorNodeID, sourceNodeID, make(map[string]struct{})) && sourceSelectorNodeID != sourceNodeID {
v.addError(field+".left", "condition source node is not available before branch: "+sourceSelectorNodeID)
}
if !conditionOperatorWithoutRight(operator) && condition.Right.Type == "" {
v.addError(field+".right", "condition comparison value is required")
}
}
func (v *definitionValidator) hasConditionPortEdge(sourceID string, sourcePortID string) bool {
for _, edge := range v.def.Edges {
if strings.TrimSpace(edge.SourceNodeID) == sourceID &&
strings.TrimSpace(edge.SourcePortID) == sourcePortID {
return true
}
}
return false
}
func (v *definitionValidator) validateKnowledgeRetrieveConfigs() {
for index, node := range v.def.Nodes {
if strings.TrimSpace(node.Type) != registry.NodeTypeKnowledgeRetrieve {
@@ -18,6 +18,46 @@ func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
}
}
func TestValidateDefinitionAcceptsOfficialFlowGramCondition(t *testing.T) {
def := dsl.Definition{
Nodes: []dsl.Node{
node("start_0", "start", nil, nil),
{
ID: "condition_0",
Type: "condition",
Data: dsl.NodeData{
Title: "Condition",
Extra: map[string]json.RawMessage{
"conditions": mustJSON([]dsl.FlowGramConditionItem{
{
Key: "if_0",
Value: dsl.FlowGramCondition{
Left: dsl.RefValue("start_0", "query"),
Operator: "contains",
Right: dsl.ConstantValue("hello"),
},
},
}),
},
},
},
node("matched_end", "end", nil, nil),
node("else_end", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_0", "condition_0"),
portEdge("condition_0", "matched_end", "if_0"),
portEdge("condition_0", "else_end", "else"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected official FlowGram condition to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
+57
View File
@@ -339,6 +339,63 @@ func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest
}
func defaultAgentWorkflowDefinition() dsl.Definition {
return officialDefaultAgentWorkflowDefinition()
}
func officialDefaultAgentWorkflowDefinition() dsl.Definition {
return dsl.Definition{
Nodes: []dsl.Node{
{
ID: "start_0",
Type: workflowregistry.NodeTypeStart,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 180, Y: 300}},
Data: dsl.NodeData{
Title: "Start",
Outputs: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","default":"Hello Flow."}}}`),
},
},
{
ID: "llm_0",
Type: workflowregistry.NodeTypeLLM,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 640, Y: 220}},
Data: dsl.NodeData{
Title: "LLM",
InputsValues: map[string]dsl.Value{
"modelName": dsl.ConstantValue("gpt-3.5-turbo"),
"apiKey": dsl.ConstantValue("sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"),
"apiHost": dsl.ConstantValue("https://mock-ai-url/api/v3"),
"temperature": dsl.ConstantValue(0.5),
"systemPrompt": dsl.TemplateValue(
"# Role\nYou are an AI assistant.\n",
),
"prompt": dsl.TemplateValue(""),
},
Inputs: json.RawMessage(`{"type":"object","required":["modelName","apiKey","apiHost","temperature","prompt"],"properties":{"modelName":{"type":"string"},"apiKey":{"type":"string"},"apiHost":{"type":"string"},"temperature":{"type":"number"},"systemPrompt":{"type":"string","extra":{"formComponent":"prompt-editor"}},"prompt":{"type":"string","extra":{"formComponent":"prompt-editor"}}}}`),
Outputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
},
},
{
ID: "end_0",
Type: workflowregistry.NodeTypeEnd,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 1100, Y: 300}},
Data: dsl.NodeData{
Title: "End",
InputsValues: map[string]dsl.Value{
"result": dsl.RefValue("llm_0", "result"),
},
Inputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
},
},
},
Edges: []dsl.Edge{
{SourceNodeID: "start_0", TargetNodeID: "llm_0"},
{SourceNodeID: "llm_0", TargetNodeID: "end_0"},
},
GlobalVariable: json.RawMessage(`{"type":"object","properties":{}}`),
}
}
func legacyDefaultAgentWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
@@ -2,6 +2,7 @@ package services
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -40,6 +41,26 @@ func TestAIWorkflowServiceValidateDefinitionReportsErrors(t *testing.T) {
}
}
func TestAIWorkflowServiceDefaultDefinitionUsesOfficialFlowGramModel(t *testing.T) {
definition := defaultAgentWorkflowDefinition()
if definition.SchemaVersion != 0 {
t.Fatalf("official FlowGram definition must not contain the legacy schemaVersion, got %d", definition.SchemaVersion)
}
if len(definition.Nodes) != 3 {
t.Fatalf("default node count = %d, want 3", len(definition.Nodes))
}
nodeTypes := []string{definition.Nodes[0].Type, definition.Nodes[1].Type, definition.Nodes[2].Type}
if strings.Join(nodeTypes, ",") != "start,llm,end" {
t.Fatalf("default node types = %v, want [start llm end]", nodeTypes)
}
if len(definition.GlobalVariable) == 0 {
t.Fatalf("official FlowGram globalVariable is required")
}
if result := AIWorkflowService.ValidateDefinition(definition); !result.Valid {
t.Fatalf("default official FlowGram definition is invalid: %#v", result.Errors)
}
}
func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
setupAIWorkflowTestDB(t)
operator := aiWorkflowTestOperator()