xyflow change to flowgraam
This commit is contained in:
@@ -49,13 +49,12 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||
func TestPrepareWorkflowAgentDoesNotInjectWorkflowAppendix(t *testing.T) {
|
||||
db := setupWorkflowResumeTestDB(t)
|
||||
definitionJSON := mustMarshalDefinition(t, dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start",
|
||||
SchemaVersion: 2,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"},
|
||||
runtimeTestNode("start", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
runtimeTestNode("handoff", workflowregistry.NodeTypeHandoffToHuman, "Handoff", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{{ID: "edge_start_handoff", Source: "start", Target: "handoff"}},
|
||||
Edges: []dsl.Edge{runtimeTestEdge("edge_start_handoff", "start", "handoff")},
|
||||
})
|
||||
version := models.AIWorkflowVersion{
|
||||
WorkflowID: 1,
|
||||
@@ -219,14 +218,13 @@ func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
|
||||
func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
|
||||
db := setupWorkflowResumeTestDB(t)
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: 2,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "bad_1", Type: "unsupported_node", Name: "Bad"},
|
||||
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
runtimeTestNode("bad_1", "unsupported_node", "Bad", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_bad", Source: "start_1", Target: "bad_1"},
|
||||
runtimeTestEdge("edge_start_bad", "start_1", "bad_1"),
|
||||
},
|
||||
}
|
||||
version := models.AIWorkflowVersion{
|
||||
@@ -335,24 +333,39 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func runtimeHumanConfirmDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: 2,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认"}`)},
|
||||
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
runtimeTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", []byte(`{"staticReply":"请确认"}`), nil),
|
||||
runtimeTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", nil, map[string]dsl.Value{
|
||||
"prompt": dsl.RefValue("prompt_1", "replyText"),
|
||||
}),
|
||||
runtimeTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
|
||||
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||
{ID: "edge_confirm_end", Source: "confirm_1", Target: "end_1"},
|
||||
runtimeTestEdge("edge_start_prompt", "start_1", "prompt_1"),
|
||||
runtimeTestEdge("edge_prompt_confirm", "prompt_1", "confirm_1"),
|
||||
runtimeTestEdge("edge_confirm_end", "confirm_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeTestNode(id string, nodeType string, title string, config []byte, inputs map[string]dsl.Value) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Data: dsl.NodeData{
|
||||
Title: title,
|
||||
Config: config,
|
||||
InputsValues: inputs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeTestEdge(id string, source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id}
|
||||
}
|
||||
|
||||
func mustMarshalDefinition(t *testing.T, def dsl.Definition) string {
|
||||
t.Helper()
|
||||
buf, err := json.Marshal(def)
|
||||
|
||||
@@ -112,7 +112,7 @@ type conditionEvaluation struct {
|
||||
|
||||
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
||||
state := newRunState(input)
|
||||
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
|
||||
currentID := state.startNodeID()
|
||||
if currentID == "" {
|
||||
return nil, fmt.Errorf("workflow entry node is required")
|
||||
}
|
||||
@@ -247,11 +247,20 @@ func newRunState(input Input) *runState {
|
||||
}
|
||||
}
|
||||
for _, edge := range input.Definition.Edges {
|
||||
state.outgoing[edge.Source] = append(state.outgoing[edge.Source], edge)
|
||||
state.outgoing[strings.TrimSpace(edge.SourceNodeID)] = append(state.outgoing[strings.TrimSpace(edge.SourceNodeID)], edge)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *runState) startNodeID() string {
|
||||
for _, node := range s.nodesByID {
|
||||
if strings.TrimSpace(node.Type) == workflowregistry.NodeTypeStart {
|
||||
return strings.TrimSpace(node.ID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.Node) error {
|
||||
switch node.Type {
|
||||
case workflowregistry.NodeTypeStart:
|
||||
@@ -587,19 +596,19 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat
|
||||
input := graphs.PrepareTicketDraftInput{
|
||||
Issue: issue,
|
||||
}
|
||||
if title := strings.TrimSpace(readStringConfig(node.Config, "title")); title != "" {
|
||||
if title := strings.TrimSpace(readStringConfig(node.Data.Config, "title")); title != "" {
|
||||
input.Title = title
|
||||
}
|
||||
if description := strings.TrimSpace(readStringConfig(node.Config, "description")); description != "" {
|
||||
if description := strings.TrimSpace(readStringConfig(node.Data.Config, "description")); description != "" {
|
||||
input.Description = description
|
||||
}
|
||||
if impact := strings.TrimSpace(readStringConfig(node.Config, "impact")); impact != "" {
|
||||
if impact := strings.TrimSpace(readStringConfig(node.Data.Config, "impact")); impact != "" {
|
||||
input.Impact = impact
|
||||
}
|
||||
if expectedOutcome := strings.TrimSpace(readStringConfig(node.Config, "expectedOutcome")); expectedOutcome != "" {
|
||||
if expectedOutcome := strings.TrimSpace(readStringConfig(node.Data.Config, "expectedOutcome")); expectedOutcome != "" {
|
||||
input.ExpectedOutcome = expectedOutcome
|
||||
}
|
||||
if currentAttempt := strings.TrimSpace(readStringConfig(node.Config, "currentAttempt")); currentAttempt != "" {
|
||||
if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" {
|
||||
input.CurrentAttempt = currentAttempt
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
@@ -632,20 +641,20 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
input := graphs.AnalyzeConversationInput{
|
||||
ObservedIssue: userMessage,
|
||||
}
|
||||
if strings.TrimSpace(readStringConfig(node.Config, "goal")) != "" {
|
||||
input.Goal = strings.TrimSpace(readStringConfig(node.Config, "goal"))
|
||||
if strings.TrimSpace(readStringConfig(node.Data.Config, "goal")) != "" {
|
||||
input.Goal = strings.TrimSpace(readStringConfig(node.Data.Config, "goal"))
|
||||
}
|
||||
if readBoolConfig(node.Config, "needTicket") {
|
||||
if readBoolConfig(node.Data.Config, "needTicket") {
|
||||
input.NeedTicket = true
|
||||
}
|
||||
if readBoolConfig(node.Config, "needHumanHandoff") {
|
||||
if readBoolConfig(node.Data.Config, "needHumanHandoff") {
|
||||
input.NeedHumanHandoff = true
|
||||
}
|
||||
if readBoolConfig(node.Config, "needQualityCheck") {
|
||||
if readBoolConfig(node.Data.Config, "needQualityCheck") {
|
||||
input.NeedQualityCheck = true
|
||||
}
|
||||
if strings.TrimSpace(readStringConfig(node.Config, "additionalContext")) != "" {
|
||||
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Config, "additionalContext"))
|
||||
if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" {
|
||||
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext"))
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
@@ -670,7 +679,7 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
}
|
||||
|
||||
func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
if _, hasConfirmedInput := node.Inputs["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
|
||||
if _, hasConfirmedInput := node.Data.InputsValues["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"))),
|
||||
@@ -751,7 +760,7 @@ func (e *Executor) executeAnswerabilityGate(state *runState, node dsl.Node) erro
|
||||
}
|
||||
|
||||
func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node dsl.Node) error {
|
||||
if staticReply := strings.TrimSpace(readStringConfig(node.Config, "staticReply")); staticReply != "" {
|
||||
if staticReply := strings.TrimSpace(readStringConfig(node.Data.Config, "staticReply")); staticReply != "" {
|
||||
state.setNodeVars(node.ID, map[string]any{"replyText": staticReply})
|
||||
return nil
|
||||
}
|
||||
@@ -761,10 +770,10 @@ func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node ds
|
||||
}
|
||||
knowledgeItems := toString(state.resolveInput(node, "knowledgeItems"))
|
||||
systemPrompt := strings.TrimSpace(state.input.AIAgent.SystemPrompt)
|
||||
if prompt := strings.TrimSpace(readStringConfig(node.Config, "prompt")); prompt != "" {
|
||||
if prompt := strings.TrimSpace(readStringConfig(node.Data.Config, "prompt")); prompt != "" {
|
||||
systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + prompt)
|
||||
}
|
||||
if _, declaresKnowledge := node.Inputs["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) {
|
||||
if _, declaresKnowledge := node.Data.InputsValues["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
|
||||
}
|
||||
@@ -798,11 +807,11 @@ func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) {
|
||||
}
|
||||
node := s.nodesByID[sourceNodeID]
|
||||
if strings.TrimSpace(node.Type) != workflowregistry.NodeTypeCondition {
|
||||
return strings.TrimSpace(edges[0].Target), true, nil
|
||||
return strings.TrimSpace(edges[0].TargetNodeID), true, nil
|
||||
}
|
||||
config := dsl.ConditionConfig{}
|
||||
if len(node.Config) > 0 {
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
if len(node.Data.Config) > 0 {
|
||||
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
|
||||
return "", false, fmt.Errorf("invalid condition node config: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -864,11 +873,13 @@ func (s *runState) evaluateConditionBranch(sourceNodeID string, branch dsl.Condi
|
||||
evaluation.Matched = true
|
||||
return true, evaluation, nil
|
||||
}
|
||||
left := s.resolveSelector(condition.Left)
|
||||
operator := strings.TrimSpace(condition.Operator)
|
||||
var left any
|
||||
if condition.Left != nil {
|
||||
evaluation.SourceNodeID = strings.TrimSpace(condition.Left.NodeID)
|
||||
evaluation.SourceField = strings.TrimSpace(condition.Left.Field)
|
||||
left = s.resolveValue(*condition.Left)
|
||||
evaluation.SourceNodeID, evaluation.SourceField, _ = condition.Left.Ref()
|
||||
evaluation.SourceNodeID = strings.TrimSpace(evaluation.SourceNodeID)
|
||||
evaluation.SourceField = strings.TrimSpace(evaluation.SourceField)
|
||||
}
|
||||
evaluation.Operator = operator
|
||||
evaluation.LeftValue = left
|
||||
@@ -909,8 +920,11 @@ func (s *runState) evaluateConditionBranch(sourceNodeID string, branch dsl.Condi
|
||||
|
||||
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)
|
||||
if strings.TrimSpace(edge.TargetNodeID) == targetNodeID {
|
||||
if edge.SourcePortID != "" {
|
||||
return strings.TrimSpace(edge.SourcePortID)
|
||||
}
|
||||
return strings.TrimSpace(edge.SourceNodeID + "->" + edge.TargetNodeID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -921,27 +935,27 @@ func (s *runState) setNodeVars(nodeID string, values map[string]any) {
|
||||
}
|
||||
|
||||
func (s *runState) resolveInput(node dsl.Node, inputName string) any {
|
||||
selector, ok := node.Inputs[inputName]
|
||||
value, ok := node.Data.InputsValues[inputName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return s.resolveSelector(&selector)
|
||||
return s.resolveValue(value)
|
||||
}
|
||||
|
||||
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)
|
||||
inputs := make(map[string]any, len(node.Data.InputsValues))
|
||||
for name, value := range node.Data.InputsValues {
|
||||
inputs[name] = s.resolveValue(value)
|
||||
}
|
||||
ret := map[string]any{
|
||||
"inputs": inputs,
|
||||
}
|
||||
if len(node.Config) > 0 {
|
||||
if len(node.Data.Config) > 0 {
|
||||
var cfg any
|
||||
if err := json.Unmarshal(node.Config, &cfg); err == nil {
|
||||
if err := json.Unmarshal(node.Data.Config, &cfg); err == nil {
|
||||
ret["config"] = cfg
|
||||
} else {
|
||||
ret["config"] = string(node.Config)
|
||||
ret["config"] = string(node.Data.Config)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
@@ -969,15 +983,28 @@ func workflowPreviewJSON(value any) string {
|
||||
return string(raw[:maxPreviewBytes])
|
||||
}
|
||||
|
||||
func (s *runState) resolveSelector(selector *dsl.VariableSelector) any {
|
||||
if selector == nil {
|
||||
func (s *runState) resolveValue(value dsl.Value) any {
|
||||
switch value.Type {
|
||||
case dsl.ValueTypeRef:
|
||||
nodeID, field, ok := value.Ref()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
fields := s.vars[strings.TrimSpace(nodeID)]
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
return fields[strings.TrimSpace(field)]
|
||||
case dsl.ValueTypeConstant:
|
||||
return value.ConstantContent
|
||||
case dsl.ValueTypeTemplate:
|
||||
if len(value.Content) > 0 {
|
||||
return value.Content[0]
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
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 {
|
||||
|
||||
@@ -435,286 +435,254 @@ func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
|
||||
}
|
||||
|
||||
func emptyKnowledgeReplyDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Reply", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"knowledgeItems": {NodeID: "missing_retrieve", Field: "items"},
|
||||
}},
|
||||
{ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "Send", Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "reply_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("reply_1", workflowregistry.NodeTypeLLMReply, "Reply", map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("missing_retrieve", "items"),
|
||||
}, nil),
|
||||
wfTestNode("send_1", workflowregistry.NodeTypeSendReply, "Send", wfTestInputs("replyText", "reply_1", "replyText"), nil),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_reply", Source: "start_1", Target: "reply_1"},
|
||||
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
|
||||
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "reply_1", "edge_start_reply"),
|
||||
wfTestEdge("reply_1", "send_1", "edge_reply_send"),
|
||||
wfTestEdge("send_1", "end_1", "edge_send_end"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func policyFirstWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "understanding_1", Type: workflowregistry.NodeTypeConversationUnderstanding, Name: "Understanding", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "policy_1", Type: workflowregistry.NodeTypeReplyPolicy, Name: "Policy", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
|
||||
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
|
||||
"riskSignals": {NodeID: "understanding_1", Field: "riskSignals"},
|
||||
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
|
||||
}},
|
||||
{ID: "policy_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Policy Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "direct", Name: "Direct", TargetNodeID: "send_direct_1", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
|
||||
Operator: "eq",
|
||||
Right: "direct_reply",
|
||||
}},
|
||||
{ID: "knowledge", Name: "Knowledge", TargetNodeID: "retrieve_end", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
|
||||
Operator: "eq",
|
||||
Right: "retrieve_knowledge",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "Understanding", wfTestInputs("userMessage", "start_1", "userMessage"), nil),
|
||||
wfTestNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "Policy", map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
|
||||
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
|
||||
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
wfTestNode("policy_route_1", workflowregistry.NodeTypeCondition, "Policy Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("direct", "Direct", "send_direct_1", "policy_1", "action", "eq", "direct_reply"),
|
||||
wfTestConditionBranch("knowledge", "Knowledge", "retrieve_end", "policy_1", "action", "eq", "retrieve_knowledge"),
|
||||
{ID: "default", Name: "Default", TargetNodeID: "end_1", Default: true},
|
||||
}})},
|
||||
{ID: "send_direct_1", Type: workflowregistry.NodeTypeSendReply, Name: "Send Direct", Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "policy_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "retrieve_end", Type: workflowregistry.NodeTypeEnd, Name: "Retrieve"},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
}}),
|
||||
wfTestNode("send_direct_1", workflowregistry.NodeTypeSendReply, "Send Direct", wfTestInputs("replyText", "policy_1", "replyText"), nil),
|
||||
wfTestNode("retrieve_end", workflowregistry.NodeTypeEnd, "Retrieve", nil, nil),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_understanding", Source: "start_1", Target: "understanding_1"},
|
||||
{ID: "edge_understanding_policy", Source: "understanding_1", Target: "policy_1"},
|
||||
{ID: "edge_policy_route", Source: "policy_1", Target: "policy_route_1"},
|
||||
{ID: "edge_policy_direct", Source: "policy_route_1", Target: "send_direct_1"},
|
||||
{ID: "edge_policy_knowledge", Source: "policy_route_1", Target: "retrieve_end"},
|
||||
{ID: "edge_policy_default", Source: "policy_route_1", Target: "end_1"},
|
||||
{ID: "edge_send_direct_end", Source: "send_direct_1", Target: "end_1"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "understanding_1", "edge_start_understanding"),
|
||||
wfTestEdge("understanding_1", "policy_1", "edge_understanding_policy"),
|
||||
wfTestEdge("policy_1", "policy_route_1", "edge_policy_route"),
|
||||
wfTestEdge("policy_route_1", "send_direct_1", "edge_policy_direct"),
|
||||
wfTestEdge("policy_route_1", "retrieve_end", "edge_policy_knowledge"),
|
||||
wfTestEdge("policy_route_1", "end_1", "edge_policy_default"),
|
||||
wfTestEdge("send_direct_1", "end_1", "edge_send_direct_end"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func conditionalReplyDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "condition_1", Type: workflowregistry.NodeTypeCondition, Name: "Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "vip", Name: "VIP", TargetNodeID: "vip_reply", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"},
|
||||
Operator: "eq",
|
||||
Right: "vip",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("condition_1", workflowregistry.NodeTypeCondition, "Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("vip", "VIP", "vip_reply", "start_1", "userMessage", "eq", "vip"),
|
||||
{ID: "default", Name: "Default", TargetNodeID: "normal_reply", Default: true},
|
||||
}})},
|
||||
{ID: "vip_reply", Type: workflowregistry.NodeTypeLLMReply, Name: "VIP", Config: []byte(`{"staticReply":"VIP reply"}`)},
|
||||
{ID: "normal_reply", Type: workflowregistry.NodeTypeLLMReply, Name: "Normal", Config: []byte(`{"staticReply":"Normal reply"}`)},
|
||||
{ID: "send_vip", Type: workflowregistry.NodeTypeSendReply, Name: "Send VIP", Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "vip_reply", Field: "replyText"},
|
||||
}},
|
||||
{ID: "send_normal", Type: workflowregistry.NodeTypeSendReply, Name: "Send Normal", Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "normal_reply", Field: "replyText"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
}}),
|
||||
wfTestNode("vip_reply", workflowregistry.NodeTypeLLMReply, "VIP", nil, map[string]any{"staticReply": "VIP reply"}),
|
||||
wfTestNode("normal_reply", workflowregistry.NodeTypeLLMReply, "Normal", nil, map[string]any{"staticReply": "Normal reply"}),
|
||||
wfTestNode("send_vip", workflowregistry.NodeTypeSendReply, "Send VIP", wfTestInputs("replyText", "vip_reply", "replyText"), nil),
|
||||
wfTestNode("send_normal", workflowregistry.NodeTypeSendReply, "Send Normal", wfTestInputs("replyText", "normal_reply", "replyText"), nil),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_condition", Source: "start_1", Target: "condition_1"},
|
||||
{ID: "edge_condition_vip", Source: "condition_1", Target: "vip_reply"},
|
||||
{ID: "edge_condition_default", Source: "condition_1", Target: "normal_reply"},
|
||||
{ID: "edge_vip_send", Source: "vip_reply", Target: "send_vip"},
|
||||
{ID: "edge_normal_send", Source: "normal_reply", Target: "send_normal"},
|
||||
{ID: "edge_send_vip_end", Source: "send_vip", Target: "end_1"},
|
||||
{ID: "edge_send_normal_end", Source: "send_normal", Target: "end_1"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "condition_1", "edge_start_condition"),
|
||||
wfTestEdge("condition_1", "vip_reply", "edge_condition_vip"),
|
||||
wfTestEdge("condition_1", "normal_reply", "edge_condition_default"),
|
||||
wfTestEdge("vip_reply", "send_vip", "edge_vip_send"),
|
||||
wfTestEdge("normal_reply", "send_normal", "edge_normal_send"),
|
||||
wfTestEdge("send_vip", "end_1", "edge_send_vip_end"),
|
||||
wfTestEdge("send_normal", "end_1", "edge_send_normal_end"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func createTicketWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
|
||||
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
|
||||
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Confirm Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "yes", Name: "Yes", TargetNodeID: "create_ticket_1", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
|
||||
Operator: "is_true",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "Draft", wfTestInputs("issue", "start_1", "userMessage"), nil),
|
||||
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认创建工单"}),
|
||||
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
wfTestNode("confirm_route_1", workflowregistry.NodeTypeCondition, "Confirm Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("yes", "Yes", "create_ticket_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "Cancel", TargetNodeID: "cancel_end", Default: true},
|
||||
}})},
|
||||
{ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create Ticket", Inputs: map[string]dsl.VariableSelector{
|
||||
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
|
||||
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
|
||||
}}),
|
||||
wfTestNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "Create Ticket", map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
wfTestNode("cancel_end", workflowregistry.NodeTypeEnd, "Cancel", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
|
||||
{ID: "edge_draft_prompt", Source: "draft_1", Target: "prompt_1"},
|
||||
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||
{ID: "edge_confirm_route", Source: "confirm_1", Target: "confirm_route_1"},
|
||||
{ID: "edge_confirm_create", Source: "confirm_route_1", Target: "create_ticket_1"},
|
||||
{ID: "edge_confirm_cancel", Source: "confirm_route_1", Target: "cancel_end"},
|
||||
{ID: "edge_create_end", Source: "create_ticket_1", Target: "end_1"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "draft_1", "edge_start_draft"),
|
||||
wfTestEdge("draft_1", "prompt_1", "edge_draft_prompt"),
|
||||
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
|
||||
wfTestEdge("confirm_1", "confirm_route_1", "edge_confirm_route"),
|
||||
wfTestEdge("confirm_route_1", "create_ticket_1", "edge_confirm_create"),
|
||||
wfTestEdge("confirm_route_1", "cancel_end", "edge_confirm_cancel"),
|
||||
wfTestEdge("create_ticket_1", "end_1", "edge_create_end"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func humanConfirmWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
|
||||
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Confirm Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "yes", Name: "Yes", TargetNodeID: "end_1", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
|
||||
Operator: "is_true",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认创建工单"}),
|
||||
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
wfTestNode("confirm_route_1", workflowregistry.NodeTypeCondition, "Confirm Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("yes", "Yes", "end_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "Cancel", TargetNodeID: "cancel_end", Default: true},
|
||||
}})},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
|
||||
}}),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
wfTestNode("cancel_end", workflowregistry.NodeTypeEnd, "Cancel", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
|
||||
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||
{ID: "edge_confirm_route", Source: "confirm_1", Target: "confirm_route_1"},
|
||||
{ID: "edge_confirm_yes", Source: "confirm_route_1", Target: "end_1"},
|
||||
{ID: "edge_confirm_cancel", Source: "confirm_route_1", Target: "cancel_end"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "prompt_1", "edge_start_prompt"),
|
||||
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
|
||||
wfTestEdge("confirm_1", "confirm_route_1", "edge_confirm_route"),
|
||||
wfTestEdge("confirm_route_1", "end_1", "edge_confirm_yes"),
|
||||
wfTestEdge("confirm_route_1", "cancel_end", "edge_confirm_cancel"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func prepareTicketDraftWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
|
||||
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "draft_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Draft Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "ready", Name: "Ready", TargetNodeID: "ready_end", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "draft_1", Field: "ticketDraft"},
|
||||
Operator: "exists",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "Draft", wfTestInputs("issue", "start_1", "userMessage"), nil),
|
||||
wfTestNode("draft_route_1", workflowregistry.NodeTypeCondition, "Draft Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("ready", "Ready", "ready_end", "draft_1", "ticketDraft", "exists", nil),
|
||||
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
|
||||
}})},
|
||||
{ID: "ready_end", Type: workflowregistry.NodeTypeEnd, Name: "Ready"},
|
||||
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
|
||||
}}),
|
||||
wfTestNode("ready_end", workflowregistry.NodeTypeEnd, "Ready", nil, nil),
|
||||
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
|
||||
{ID: "edge_draft_route", Source: "draft_1", Target: "draft_route_1"},
|
||||
{ID: "edge_draft_ready", Source: "draft_route_1", Target: "ready_end"},
|
||||
{ID: "edge_draft_default", Source: "draft_route_1", Target: "default_end"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "draft_1", "edge_start_draft"),
|
||||
wfTestEdge("draft_1", "draft_route_1", "edge_draft_route"),
|
||||
wfTestEdge("draft_route_1", "ready_end", "edge_draft_ready"),
|
||||
wfTestEdge("draft_route_1", "default_end", "edge_draft_default"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func analyzeConversationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "analyze_1", Type: workflowregistry.NodeTypeAnalyzeConversation, Name: "Analyze", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "analyze_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Analyze Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "handoff", Name: "Handoff", TargetNodeID: "handoff_end", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "analyze_1", Field: "needHumanHandoff"},
|
||||
Operator: "is_true",
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("analyze_1", workflowregistry.NodeTypeAnalyzeConversation, "Analyze", wfTestInputs("userMessage", "start_1", "userMessage"), nil),
|
||||
wfTestNode("analyze_route_1", workflowregistry.NodeTypeCondition, "Analyze Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("handoff", "Handoff", "handoff_end", "analyze_1", "needHumanHandoff", "is_true", nil),
|
||||
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
|
||||
}})},
|
||||
{ID: "handoff_end", Type: workflowregistry.NodeTypeEnd, Name: "Handoff"},
|
||||
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
|
||||
}}),
|
||||
wfTestNode("handoff_end", workflowregistry.NodeTypeEnd, "Handoff", nil, nil),
|
||||
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_analyze", Source: "start_1", Target: "analyze_1"},
|
||||
{ID: "edge_analyze_route", Source: "analyze_1", Target: "analyze_route_1"},
|
||||
{ID: "edge_analyze_handoff", Source: "analyze_route_1", Target: "handoff_end"},
|
||||
{ID: "edge_analyze_default", Source: "analyze_route_1", Target: "default_end"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "analyze_1", "edge_start_analyze"),
|
||||
wfTestEdge("analyze_1", "analyze_route_1", "edge_analyze_route"),
|
||||
wfTestEdge("analyze_route_1", "handoff_end", "edge_analyze_handoff"),
|
||||
wfTestEdge("analyze_route_1", "default_end", "edge_analyze_default"),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func handoffWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff", Inputs: map[string]dsl.VariableSelector{
|
||||
"reason": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "handoff_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Handoff Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "assigned", Name: "Assigned", TargetNodeID: "assigned_end", Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "handoff_1", Field: "decision"},
|
||||
Operator: "eq",
|
||||
Right: string(services.HandoffDecisionAssigned),
|
||||
}},
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "Handoff", wfTestInputs("reason", "start_1", "userMessage"), nil),
|
||||
wfTestNode("handoff_route_1", workflowregistry.NodeTypeCondition, "Handoff Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
wfTestConditionBranch("assigned", "Assigned", "assigned_end", "handoff_1", "decision", "eq", string(services.HandoffDecisionAssigned)),
|
||||
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
|
||||
}})},
|
||||
{ID: "assigned_end", Type: workflowregistry.NodeTypeEnd, Name: "Assigned"},
|
||||
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
|
||||
}}),
|
||||
wfTestNode("assigned_end", workflowregistry.NodeTypeEnd, "Assigned", nil, nil),
|
||||
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_handoff", Source: "start_1", Target: "handoff_1"},
|
||||
{ID: "edge_handoff_route", Source: "handoff_1", Target: "handoff_route_1"},
|
||||
{ID: "edge_handoff_assigned", Source: "handoff_route_1", Target: "assigned_end"},
|
||||
{ID: "edge_handoff_default", Source: "handoff_route_1", Target: "default_end"},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "handoff_1", "edge_start_handoff"),
|
||||
wfTestEdge("handoff_1", "handoff_route_1", "edge_handoff_route"),
|
||||
wfTestEdge("handoff_route_1", "assigned_end", "edge_handoff_assigned"),
|
||||
wfTestEdge("handoff_route_1", "default_end", "edge_handoff_default"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func handoffAfterConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return wfTestDefinition(
|
||||
[]dsl.Node{
|
||||
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
|
||||
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认转人工"}),
|
||||
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
wfTestNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "Handoff", map[string]dsl.Value{
|
||||
"reason": dsl.RefValue("start_1", "userMessage"),
|
||||
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
|
||||
},
|
||||
[]dsl.Edge{
|
||||
wfTestEdge("start_1", "prompt_1", "edge_start_prompt"),
|
||||
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
|
||||
wfTestEdge("confirm_1", "handoff_1", "edge_confirm_handoff"),
|
||||
wfTestEdge("handoff_1", "end_1", "edge_handoff_end"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func wfTestDefinition(nodes []dsl.Node, edges []dsl.Edge) dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion, Nodes: nodes, Edges: edges}
|
||||
}
|
||||
|
||||
func wfTestNode(id string, nodeType string, title string, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
|
||||
Data: dsl.NodeData{
|
||||
Title: title,
|
||||
InputsValues: inputs,
|
||||
Config: mustMarshalWorkflowTestConfig(config),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handoffAfterConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认转人工"}`)},
|
||||
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff", Inputs: map[string]dsl.VariableSelector{
|
||||
"reason": {NodeID: "start_1", Field: "userMessage"},
|
||||
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
|
||||
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||
{ID: "edge_confirm_handoff", Source: "confirm_1", Target: "handoff_1"},
|
||||
{ID: "edge_handoff_end", Source: "handoff_1", Target: "end_1"},
|
||||
func wfTestInputs(name string, nodeID string, field string) map[string]dsl.Value {
|
||||
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
|
||||
}
|
||||
|
||||
func wfTestEdge(source string, target string, id string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id}
|
||||
}
|
||||
|
||||
func wfTestConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
|
||||
return dsl.ConditionBranch{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TargetNodeID: targetNodeID,
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
|
||||
Operator: operator,
|
||||
Right: right,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,34 @@ package dsl
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const SchemaVersion = 2
|
||||
|
||||
type Definition struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
EntryNodeID string `json:"entryNodeId"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Position Position `json:"position"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Inputs map[string]VariableSelector `json:"inputs,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Meta NodeMeta `json:"meta"`
|
||||
Data NodeData `json:"data"`
|
||||
Blocks []Node `json:"blocks,omitempty"`
|
||||
Edges []Edge `json:"edges,omitempty"`
|
||||
}
|
||||
|
||||
type NodeMeta struct {
|
||||
Position Position `json:"position"`
|
||||
}
|
||||
|
||||
type NodeData struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Inputs json.RawMessage `json:"inputs,omitempty"`
|
||||
Outputs json.RawMessage `json:"outputs,omitempty"`
|
||||
InputsValues map[string]Value `json:"inputsValues,omitempty"`
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
@@ -24,9 +38,25 @@ type Position struct {
|
||||
}
|
||||
|
||||
type Edge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceNodeID string `json:"sourceNodeID"`
|
||||
TargetNodeID string `json:"targetNodeID"`
|
||||
SourcePortID string `json:"sourcePortID,omitempty"`
|
||||
TargetPortID string `json:"targetPortID,omitempty"`
|
||||
}
|
||||
|
||||
type ValueType string
|
||||
|
||||
const (
|
||||
ValueTypeConstant ValueType = "constant"
|
||||
ValueTypeRef ValueType = "ref"
|
||||
ValueTypeTemplate ValueType = "template"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
Type ValueType `json:"type"`
|
||||
Content []string `json:"content,omitempty"`
|
||||
ConstantContent any `json:"-"`
|
||||
RawContent json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type ConditionConfig struct {
|
||||
@@ -42,13 +72,118 @@ type ConditionBranch struct {
|
||||
}
|
||||
|
||||
type Condition struct {
|
||||
Expression string `json:"expression,omitempty"`
|
||||
Left *VariableSelector `json:"left,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
Expression string `json:"expression,omitempty"`
|
||||
Left *Value `json:"left,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
type VariableSelector struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Field string `json:"field"`
|
||||
func RefValue(nodeID string, field string) Value {
|
||||
return Value{Type: ValueTypeRef, Content: []string{nodeID, field}}
|
||||
}
|
||||
|
||||
func ConstantValue(value any) Value {
|
||||
raw, _ := json.Marshal(value)
|
||||
return Value{Type: ValueTypeConstant, ConstantContent: value, RawContent: raw}
|
||||
}
|
||||
|
||||
func TemplateValue(value string) Value {
|
||||
return Value{Type: ValueTypeTemplate, Content: []string{value}}
|
||||
}
|
||||
|
||||
func (v Value) Ref() (nodeID string, field string, ok bool) {
|
||||
if v.Type != ValueTypeRef || len(v.Content) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
return v.Content[0], v.Content[1], true
|
||||
}
|
||||
|
||||
func (v *Value) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Type ValueType `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
var parsed alias
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Type = parsed.Type
|
||||
v.RawContent = append(v.RawContent[:0], parsed.Content...)
|
||||
switch parsed.Type {
|
||||
case ValueTypeRef:
|
||||
var content []string
|
||||
if len(parsed.Content) > 0 {
|
||||
if err := json.Unmarshal(parsed.Content, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
v.Content = content
|
||||
case ValueTypeTemplate:
|
||||
var content string
|
||||
if len(parsed.Content) > 0 {
|
||||
if err := json.Unmarshal(parsed.Content, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
v.Content = []string{content}
|
||||
case ValueTypeConstant:
|
||||
if len(parsed.Content) > 0 {
|
||||
if err := json.Unmarshal(parsed.Content, &v.ConstantContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
if len(parsed.Content) > 0 {
|
||||
var content []string
|
||||
if err := json.Unmarshal(parsed.Content, &content); err == nil {
|
||||
v.Content = content
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v Value) MarshalJSON() ([]byte, error) {
|
||||
type alias struct {
|
||||
Type ValueType `json:"type"`
|
||||
Content any `json:"content,omitempty"`
|
||||
}
|
||||
var content any
|
||||
switch v.Type {
|
||||
case ValueTypeRef:
|
||||
content = v.Content
|
||||
case ValueTypeTemplate:
|
||||
if len(v.Content) > 0 {
|
||||
content = v.Content[0]
|
||||
}
|
||||
case ValueTypeConstant:
|
||||
content = v.ConstantContent
|
||||
default:
|
||||
if len(v.Content) > 0 {
|
||||
content = v.Content
|
||||
}
|
||||
}
|
||||
return json.Marshal(alias{Type: v.Type, Content: content})
|
||||
}
|
||||
|
||||
func (d *NodeData) UnmarshalJSON(data []byte) error {
|
||||
type alias NodeData
|
||||
var parsed alias
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
extra := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal(data, &extra); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(extra, "title")
|
||||
delete(extra, "config")
|
||||
delete(extra, "inputs")
|
||||
delete(extra, "outputs")
|
||||
delete(extra, "inputsValues")
|
||||
*d = NodeData(parsed)
|
||||
if len(extra) > 0 {
|
||||
d.Extra = extra
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package dsl_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
)
|
||||
|
||||
func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"schemaVersion": 2,
|
||||
"nodes": [{
|
||||
"id": "send_1",
|
||||
"type": "send_reply",
|
||||
"meta": {
|
||||
"position": { "x": 360, "y": 120 }
|
||||
},
|
||||
"data": {
|
||||
"title": "发送回复",
|
||||
"config": { "text": "hello" },
|
||||
"inputs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"replyText": { "type": "string" }
|
||||
},
|
||||
"required": ["replyText"]
|
||||
},
|
||||
"outputs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"inputsValues": {
|
||||
"replyText": {
|
||||
"type": "ref",
|
||||
"content": ["start_1", "userMessage"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"edges": [{
|
||||
"sourceNodeID": "start_1",
|
||||
"targetNodeID": "send_1",
|
||||
"sourcePortID": "default"
|
||||
}]
|
||||
}`)
|
||||
|
||||
var def dsl.Definition
|
||||
if err := json.Unmarshal(raw, &def); err != nil {
|
||||
t.Fatalf("unmarshal definition: %v", err)
|
||||
}
|
||||
|
||||
if def.SchemaVersion != 2 {
|
||||
t.Fatalf("unexpected schema version: %d", def.SchemaVersion)
|
||||
}
|
||||
node := def.Nodes[0]
|
||||
if node.ID != "send_1" || node.Type != "send_reply" {
|
||||
t.Fatalf("unexpected node identity: %#v", node)
|
||||
}
|
||||
if node.Meta.Position.X != 360 || node.Meta.Position.Y != 120 {
|
||||
t.Fatalf("unexpected node position: %#v", node.Meta.Position)
|
||||
}
|
||||
if node.Data.Title != "发送回复" {
|
||||
t.Fatalf("unexpected node title: %q", node.Data.Title)
|
||||
}
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
if config["text"] != "hello" {
|
||||
t.Fatalf("unexpected config: %s", node.Data.Config)
|
||||
}
|
||||
replyText := node.Data.InputsValues["replyText"]
|
||||
if replyText.Type != dsl.ValueTypeRef || len(replyText.Content) != 2 || replyText.Content[0] != "start_1" || replyText.Content[1] != "userMessage" {
|
||||
t.Fatalf("unexpected replyText value: %#v", replyText)
|
||||
}
|
||||
edge := def.Edges[0]
|
||||
if edge.SourceNodeID != "start_1" || edge.TargetNodeID != "send_1" || edge.SourcePortID != "default" {
|
||||
t.Fatalf("unexpected edge: %#v", edge)
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ func DefaultRegistry() *Registry {
|
||||
output("riskSignals", VariableTypeStringArray, "Detected risk signals."),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
},
|
||||
DefaultInputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
DefaultInputs: map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
@@ -123,8 +123,8 @@ func DefaultRegistry() *Registry {
|
||||
output("items", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
output("summary", VariableTypeString, "Short retrieval summary."),
|
||||
},
|
||||
DefaultInputs: map[string]dsl.VariableSelector{
|
||||
"query": {NodeID: "start_1", Field: "userMessage"},
|
||||
DefaultInputs: map[string]dsl.Value{
|
||||
"query": dsl.RefValue("start_1", "userMessage"),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
|
||||
@@ -41,16 +41,16 @@ type VariableValueOption struct {
|
||||
}
|
||||
|
||||
type NodeSpec struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
ConfigSchema any `json:"configSchema,omitempty"`
|
||||
InputSchema []VariableSpec `json:"inputSchema,omitempty"`
|
||||
OutputSchema []VariableSpec `json:"outputSchema,omitempty"`
|
||||
DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
ConfigSchema any `json:"configSchema,omitempty"`
|
||||
InputSchema []VariableSpec `json:"inputSchema,omitempty"`
|
||||
OutputSchema []VariableSpec `json:"outputSchema,omitempty"`
|
||||
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
|
||||
@@ -53,7 +53,6 @@ type definitionValidator struct {
|
||||
func (v *definitionValidator) validate() {
|
||||
v.validateNodes()
|
||||
v.validateEdges()
|
||||
v.validateEntry()
|
||||
v.validateReachability()
|
||||
v.validateConfirmationGuards()
|
||||
v.validateVariableMappings()
|
||||
@@ -98,53 +97,29 @@ func (v *definitionValidator) validateNodes() {
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateEdges() {
|
||||
seen := make(map[string]struct{}, len(v.def.Edges))
|
||||
for index, edge := range v.def.Edges {
|
||||
edge.ID = strings.TrimSpace(edge.ID)
|
||||
edge.Source = strings.TrimSpace(edge.Source)
|
||||
edge.Target = strings.TrimSpace(edge.Target)
|
||||
source := strings.TrimSpace(edge.SourceNodeID)
|
||||
target := strings.TrimSpace(edge.TargetNodeID)
|
||||
field := fmt.Sprintf("edges[%d]", index)
|
||||
if edge.ID == "" {
|
||||
v.addError(field+".id", "edge id is required")
|
||||
} else if _, exists := seen[edge.ID]; exists {
|
||||
v.addError(field+".id", "duplicate edge id: "+edge.ID)
|
||||
if source == "" {
|
||||
v.addError(field+".sourceNodeID", "edge source node is required")
|
||||
} else if _, ok := v.nodesByID[source]; !ok {
|
||||
v.addError(field+".sourceNodeID", "edge source node does not exist: "+source)
|
||||
}
|
||||
seen[edge.ID] = struct{}{}
|
||||
if edge.Source == "" {
|
||||
v.addError(field+".source", "edge source is required")
|
||||
} else if _, ok := v.nodesByID[edge.Source]; !ok {
|
||||
v.addError(field+".source", "edge source node does not exist: "+edge.Source)
|
||||
if target == "" {
|
||||
v.addError(field+".targetNodeID", "edge target node is required")
|
||||
} else if _, ok := v.nodesByID[target]; !ok {
|
||||
v.addError(field+".targetNodeID", "edge target node does not exist: "+target)
|
||||
}
|
||||
if edge.Target == "" {
|
||||
v.addError(field+".target", "edge target is required")
|
||||
} else if _, ok := v.nodesByID[edge.Target]; !ok {
|
||||
v.addError(field+".target", "edge target node does not exist: "+edge.Target)
|
||||
if source != "" && target != "" {
|
||||
v.outgoing[source] = append(v.outgoing[source], target)
|
||||
v.incoming[target] = append(v.incoming[target], source)
|
||||
}
|
||||
if edge.Source != "" && edge.Target != "" {
|
||||
v.outgoing[edge.Source] = append(v.outgoing[edge.Source], edge.Target)
|
||||
v.incoming[edge.Target] = append(v.incoming[edge.Target], edge.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateEntry() {
|
||||
entryNodeID := strings.TrimSpace(v.def.EntryNodeID)
|
||||
if entryNodeID == "" {
|
||||
v.addError("entryNodeId", "entry node id is required")
|
||||
return
|
||||
}
|
||||
entry, ok := v.nodesByID[entryNodeID]
|
||||
if !ok {
|
||||
v.addError("entryNodeId", "entry node does not exist: "+entryNodeID)
|
||||
return
|
||||
}
|
||||
if entry.Type != registry.NodeTypeStart {
|
||||
v.addError("entryNodeId", "entry node must be the start node")
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateReachability() {
|
||||
entryNodeID := strings.TrimSpace(v.def.EntryNodeID)
|
||||
entryNodeID := v.entryNodeID()
|
||||
if entryNodeID == "" {
|
||||
return
|
||||
}
|
||||
@@ -187,17 +162,17 @@ func (v *definitionValidator) validateConfirmationGuards() {
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateConfirmedInput(nodeID string, node dsl.Node) {
|
||||
selector, ok := node.Inputs["confirmed"]
|
||||
if !ok || strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
|
||||
value, ok := node.Data.InputsValues["confirmed"]
|
||||
sourceNodeID, sourceField, refOK := value.Ref()
|
||||
if !ok || !refOK || strings.TrimSpace(sourceNodeID) == "" || strings.TrimSpace(sourceField) == "" {
|
||||
return
|
||||
}
|
||||
sourceNodeID := strings.TrimSpace(selector.NodeID)
|
||||
sourceNode, ok := v.nodesByID[sourceNodeID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sourceNode.Type != registry.NodeTypeHumanConfirm || strings.TrimSpace(selector.Field) != "confirmed" {
|
||||
v.addError("nodes."+nodeID+".inputs.confirmed", "confirmed input must come from human_confirm.confirmed")
|
||||
if sourceNode.Type != registry.NodeTypeHumanConfirm || strings.TrimSpace(sourceField) != "confirmed" {
|
||||
v.addError("nodes."+nodeID+".data.inputsValues.confirmed", "confirmed input must come from human_confirm.confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,47 +186,55 @@ func (v *definitionValidator) validateVariableMappings() {
|
||||
if !input.Required {
|
||||
continue
|
||||
}
|
||||
selector, ok := node.Inputs[input.Name]
|
||||
if !ok || strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
|
||||
v.addError("nodes."+id+".inputs."+input.Name, "required input mapping is missing: "+input.Name)
|
||||
value, ok := node.Data.InputsValues[input.Name]
|
||||
if !ok {
|
||||
v.addError("nodes."+id+".data.inputsValues."+input.Name, "required input mapping is missing: "+input.Name)
|
||||
continue
|
||||
}
|
||||
v.validateInputSelector(id, input, selector)
|
||||
v.validateInputValue(id, input, value)
|
||||
}
|
||||
for inputName, selector := range node.Inputs {
|
||||
if strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
|
||||
v.addError("nodes."+id+".inputs."+inputName, "input mapping source is required")
|
||||
continue
|
||||
}
|
||||
for inputName, value := range node.Data.InputsValues {
|
||||
if _, ok := findInputSpec(spec.InputSchema, inputName); ok {
|
||||
continue
|
||||
}
|
||||
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(selector.NodeID)]
|
||||
sourceNodeID, sourceField, refOK := value.Ref()
|
||||
if !refOK {
|
||||
continue
|
||||
}
|
||||
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(sourceNodeID)]
|
||||
if !sourceOK {
|
||||
v.addError("nodes."+id+".inputs."+inputName, "input source node does not exist: "+selector.NodeID)
|
||||
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source node does not exist: "+sourceNodeID)
|
||||
continue
|
||||
}
|
||||
sourceSpec, sourceSpecOK := v.registry.Get(sourceNode.Type)
|
||||
if !sourceSpecOK {
|
||||
continue
|
||||
}
|
||||
if _, ok := findOutputSpec(sourceSpec.OutputSchema, selector.Field); !ok {
|
||||
v.addError("nodes."+id+".inputs."+inputName, "input source field does not exist: "+selector.NodeID+"."+selector.Field)
|
||||
if _, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField); !ok {
|
||||
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source field does not exist: "+sourceNodeID+"."+sourceField)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateInputSelector(nodeID string, input registry.VariableSpec, selector dsl.VariableSelector) {
|
||||
sourceNodeID := strings.TrimSpace(selector.NodeID)
|
||||
sourceField := strings.TrimSpace(selector.Field)
|
||||
func (v *definitionValidator) validateInputValue(nodeID string, input registry.VariableSpec, value dsl.Value) {
|
||||
sourceNodeID, sourceField, ok := value.Ref()
|
||||
if !ok {
|
||||
if value.Type == dsl.ValueTypeConstant || value.Type == dsl.ValueTypeTemplate {
|
||||
return
|
||||
}
|
||||
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input mapping source is required")
|
||||
return
|
||||
}
|
||||
sourceNodeID = strings.TrimSpace(sourceNodeID)
|
||||
sourceField = strings.TrimSpace(sourceField)
|
||||
sourceNode, ok := v.nodesByID[sourceNodeID]
|
||||
if !ok {
|
||||
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source node does not exist: "+sourceNodeID)
|
||||
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node does not exist: "+sourceNodeID)
|
||||
return
|
||||
}
|
||||
if !v.hasPath(sourceNodeID, nodeID, make(map[string]struct{})) {
|
||||
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source node is not available before current node: "+sourceNodeID)
|
||||
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node is not available before current node: "+sourceNodeID)
|
||||
return
|
||||
}
|
||||
sourceSpec, ok := v.registry.Get(sourceNode.Type)
|
||||
@@ -260,11 +243,11 @@ func (v *definitionValidator) validateInputSelector(nodeID string, input registr
|
||||
}
|
||||
output, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
|
||||
if !ok {
|
||||
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source field does not exist: "+sourceNodeID+"."+sourceField)
|
||||
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source field does not exist: "+sourceNodeID+"."+sourceField)
|
||||
return
|
||||
}
|
||||
if !variableTypesCompatible(input.Type, output.Type) {
|
||||
v.addError("nodes."+nodeID+".inputs."+input.Name, fmt.Sprintf("input type mismatch: %s expects %s but %s.%s is %s", input.Name, input.Type, sourceNodeID, sourceField, output.Type))
|
||||
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, fmt.Sprintf("input type mismatch: %s expects %s but %s.%s is %s", input.Name, input.Type, sourceNodeID, sourceField, output.Type))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,8 +258,8 @@ func (v *definitionValidator) validateConditions() {
|
||||
}
|
||||
field := fmt.Sprintf("nodes[%d].config.branches", index)
|
||||
config := dsl.ConditionConfig{}
|
||||
if len(node.Config) > 0 {
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
if len(node.Data.Config) > 0 {
|
||||
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
|
||||
v.addError(field, "condition branches config must be valid JSON")
|
||||
continue
|
||||
}
|
||||
@@ -341,9 +324,10 @@ func (v *definitionValidator) validateCondition(field string, sourceNodeID strin
|
||||
v.addError(field+".left", "condition left variable is required")
|
||||
return
|
||||
}
|
||||
sourceSelectorNodeID := strings.TrimSpace(condition.Left.NodeID)
|
||||
sourceField := strings.TrimSpace(condition.Left.Field)
|
||||
if sourceSelectorNodeID == "" || sourceField == "" {
|
||||
sourceSelectorNodeID, sourceField, leftOK := condition.Left.Ref()
|
||||
sourceSelectorNodeID = strings.TrimSpace(sourceSelectorNodeID)
|
||||
sourceField = strings.TrimSpace(sourceField)
|
||||
if !leftOK || sourceSelectorNodeID == "" || sourceField == "" {
|
||||
v.addError(field+".left", "condition left variable is required")
|
||||
return
|
||||
}
|
||||
@@ -462,13 +446,20 @@ func (v *definitionValidator) hasEdgeTo(sourceID string, targetID string) bool {
|
||||
return true
|
||||
}
|
||||
for _, edge := range v.def.Edges {
|
||||
if strings.TrimSpace(edge.Source) == sourceID && strings.TrimSpace(edge.Target) == targetID {
|
||||
if strings.TrimSpace(edge.SourceNodeID) == sourceID && strings.TrimSpace(edge.TargetNodeID) == targetID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *definitionValidator) entryNodeID() string {
|
||||
if len(v.startNodeIDs) != 1 {
|
||||
return ""
|
||||
}
|
||||
return v.startNodeIDs[0]
|
||||
}
|
||||
|
||||
func findInputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, item := range items {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"agent-desk/internal/ai/workflow/validator"
|
||||
)
|
||||
|
||||
func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
|
||||
func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
|
||||
result := validator.ValidateDefinition(minimalDefinition(), registry.DefaultRegistry())
|
||||
|
||||
if !result.Valid {
|
||||
@@ -21,8 +21,8 @@ func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
|
||||
func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes = []dsl.Node{
|
||||
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
|
||||
{ID: "end_1", Type: "end"},
|
||||
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("end_1", "end", nil, nil),
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
@@ -35,148 +35,9 @@ func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownNodeType(t *testing.T) {
|
||||
func TestValidateDefinitionRejectsMissingRequiredInputValue(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes = append(def.Nodes, dsl.Node{ID: "unknown_1", Type: "unknown_node"})
|
||||
def.Edges = append(def.Edges, dsl.Edge{ID: "e3", Source: "reply_1", Target: "unknown_1"})
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected unknown node type to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "unknown node type") {
|
||||
t.Fatalf("expected unknown-node error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnguardedCreateTicket(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "draft_1", Type: "prepare_ticket_draft"},
|
||||
{ID: "create_1", Type: "create_ticket"},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "draft_1"},
|
||||
{ID: "e2", Source: "draft_1", Target: "create_1"},
|
||||
{ID: "e3", Source: "create_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected unguarded create_ticket to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "requires human_confirm") {
|
||||
t.Fatalf("expected confirmation guard error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
|
||||
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
|
||||
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
|
||||
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "draft_1"},
|
||||
{ID: "e2", Source: "draft_1", Target: "confirm_1"},
|
||||
{ID: "e3", Source: "confirm_1", Target: "create_1"},
|
||||
{ID: "e4", Source: "create_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionAcceptsDirectHandoffToHuman(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "handoff_1", Type: "handoff_to_human", Inputs: map[string]dsl.VariableSelector{
|
||||
"reason": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "handoff_1"},
|
||||
{ID: "e2", Source: "handoff_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected direct handoff_to_human to be valid, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "analysis_1", Type: "analyze_conversation", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
|
||||
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
|
||||
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
|
||||
"confirmed": {NodeID: "analysis_1", Field: "needTicket"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "analysis_1"},
|
||||
{ID: "e2", Source: "analysis_1", Target: "draft_1"},
|
||||
{ID: "e3", Source: "draft_1", Target: "confirm_1"},
|
||||
{ID: "e4", Source: "confirm_1", Target: "create_1"},
|
||||
{ID: "e5", Source: "create_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
|
||||
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Inputs = nil
|
||||
def.Nodes[1].Data.InputsValues = nil
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
@@ -189,8 +50,8 @@ func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "missing_1", Field: "replyText"}
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("missing_1", "replyText")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
@@ -202,9 +63,25 @@ func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnavailableInputSourceNode(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes = append(def.Nodes, node("late_1", "llm_reply", inputs("userMessage", dsl.RefValue("reply_1", "sent")), nil))
|
||||
def.Edges = append(def.Edges, edge("reply_1", "late_1"))
|
||||
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("late_1", "replyText")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected downstream input source to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "input source node is not available before current node") {
|
||||
t.Fatalf("expected source availability error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "missing"}
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "missing")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
@@ -217,8 +94,8 @@ func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "conversationId"}
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "conversationId")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
@@ -230,69 +107,83 @@ func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionAcceptsMappedKnowledgeFlow(t *testing.T) {
|
||||
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "retrieve_1", Type: "knowledge_retrieve", Inputs: map[string]dsl.VariableSelector{
|
||||
"query": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "reply_1", Type: "send_reply", Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
node("start_1", "start", nil, nil),
|
||||
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("create_1", "create_ticket", map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
node("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "retrieve_1"},
|
||||
{ID: "e2", Source: "retrieve_1", Target: "reply_1"},
|
||||
{ID: "e3", Source: "reply_1", Target: "end_1"},
|
||||
edge("start_1", "draft_1"),
|
||||
edge("draft_1", "confirm_1"),
|
||||
edge("confirm_1", "create_1"),
|
||||
edge("create_1", "end_1"),
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected mapped knowledge flow to be valid, got %#v", result.Errors)
|
||||
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownConditionOperator(t *testing.T) {
|
||||
def := conditionDefinition()
|
||||
var config dsl.ConditionConfig
|
||||
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
|
||||
t.Fatalf("unmarshal condition config: %v", err)
|
||||
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes = []dsl.Node{
|
||||
node("start_1", "start", nil, nil),
|
||||
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("create_1", "create_ticket", map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("start_1", "userMessage"),
|
||||
}, nil),
|
||||
node("end_1", "end", nil, nil),
|
||||
}
|
||||
config.Branches[0].Condition.Operator = "regex"
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal condition config: %v", err)
|
||||
def.Edges = []dsl.Edge{
|
||||
edge("start_1", "draft_1"),
|
||||
edge("draft_1", "create_1"),
|
||||
edge("create_1", "end_1"),
|
||||
}
|
||||
def.Nodes[1].Config = raw
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected unknown condition operator to be invalid")
|
||||
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "unsupported condition operator") {
|
||||
t.Fatalf("expected condition operator error, got %#v", result.Errors)
|
||||
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
|
||||
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
|
||||
def := conditionDefinition()
|
||||
def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected condition branch target without edge to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "condition branch target must have an outgoing edge") {
|
||||
t.Fatalf("expected branch edge error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
|
||||
def := conditionDefinition()
|
||||
var config dsl.ConditionConfig
|
||||
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
|
||||
if err := json.Unmarshal(def.Nodes[1].Data.Config, &config); err != nil {
|
||||
t.Fatalf("unmarshal condition config: %v", err)
|
||||
}
|
||||
config.Branches[0].Condition.Left.Field = "missing"
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal condition config: %v", err)
|
||||
}
|
||||
def.Nodes[1].Config = raw
|
||||
config.Branches[0].Condition.Left = &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "missing"}}
|
||||
def.Nodes[1].Data.Config = mustJSON(config)
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
@@ -304,123 +195,30 @@ func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
|
||||
def := policyConditionDefinition("unknown_action")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected invalid enum condition value to be rejected")
|
||||
}
|
||||
if !hasValidationMessage(result, "condition comparison value is not allowed") {
|
||||
t.Fatalf("expected condition enum value error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsConditionDefaultBranchBeforeLast(t *testing.T) {
|
||||
def := conditionDefinition()
|
||||
var config dsl.ConditionConfig
|
||||
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
|
||||
t.Fatalf("unmarshal condition config: %v", err)
|
||||
}
|
||||
config.Branches[0], config.Branches[1] = config.Branches[1], config.Branches[0]
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal condition config: %v", err)
|
||||
}
|
||||
def.Nodes[1].Config = raw
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected default branch before last to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "default condition branch must be last") {
|
||||
t.Fatalf("expected default branch order error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`), Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
node("start_1", "start", nil, nil),
|
||||
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), map[string]any{"text": "hello"}),
|
||||
node("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "reply_1"},
|
||||
{ID: "e2", Source: "reply_1", Target: "end_1"},
|
||||
edge("start_1", "reply_1"),
|
||||
edge("reply_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func policyConditionDefinition(action any) dsl.Definition {
|
||||
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
|
||||
Branches: []dsl.ConditionBranch{
|
||||
{
|
||||
ID: "direct",
|
||||
Name: "Direct",
|
||||
TargetNodeID: "end_1",
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
|
||||
Operator: "eq",
|
||||
Right: action,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "default",
|
||||
Name: "Default",
|
||||
TargetNodeID: "end_1",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "understanding_1", Type: "conversation_understanding", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "policy_1", Type: "reply_policy", Inputs: map[string]dsl.VariableSelector{
|
||||
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
|
||||
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
|
||||
}},
|
||||
{ID: "condition_1", Type: "condition", Config: conditionConfig},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "understanding_1"},
|
||||
{ID: "e2", Source: "understanding_1", Target: "policy_1"},
|
||||
{ID: "e3", Source: "policy_1", Target: "condition_1"},
|
||||
{ID: "e4", Source: "condition_1", Target: "end_1"},
|
||||
{ID: "e5", Source: "condition_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mappedReplyDefinition() dsl.Definition {
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "start_1", Field: "userMessage"},
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func conditionDefinition() dsl.Definition {
|
||||
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
|
||||
conditionConfig := dsl.ConditionConfig{
|
||||
Branches: []dsl.ConditionBranch{
|
||||
{
|
||||
ID: "hello",
|
||||
Name: "Hello",
|
||||
TargetNodeID: "end_1",
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"},
|
||||
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "userMessage"}},
|
||||
Operator: "eq",
|
||||
Right: "hello",
|
||||
},
|
||||
@@ -432,23 +230,54 @@ func conditionDefinition() dsl.Definition {
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "condition_1", Type: "condition", Config: conditionConfig},
|
||||
{ID: "end_1", Type: "end"},
|
||||
node("start_1", "start", nil, nil),
|
||||
node("condition_1", "condition", nil, conditionConfig),
|
||||
node("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "condition_1"},
|
||||
{ID: "e2", Source: "condition_1", Target: "end_1"},
|
||||
{ID: "e3", Source: "condition_1", Target: "end_1"},
|
||||
edge("start_1", "condition_1"),
|
||||
edge("condition_1", "end_1"),
|
||||
edge("condition_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func node(id string, nodeType string, inputValues map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
|
||||
Data: dsl.NodeData{
|
||||
Title: nodeType,
|
||||
Config: mustJSON(config),
|
||||
InputsValues: inputValues,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func edge(source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
|
||||
}
|
||||
|
||||
func inputs(name string, value dsl.Value) map[string]dsl.Value {
|
||||
return map[string]dsl.Value{name: value}
|
||||
}
|
||||
|
||||
func mustJSON(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func hasValidationMessage(result validator.Result, want string) bool {
|
||||
for _, item := range result.Errors {
|
||||
if strings.Contains(item.Message, want) {
|
||||
|
||||
@@ -99,13 +99,12 @@ func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
|
||||
|
||||
func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.T) {
|
||||
definition := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始"},
|
||||
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "运行时回复"},
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Data: dsl.NodeData{Title: "开始"}},
|
||||
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Data: dsl.NodeData{Title: "运行时回复"}},
|
||||
},
|
||||
Edges: []dsl.Edge{{ID: "edge_start_reply", Source: "start_1", Target: "reply_1"}},
|
||||
Edges: []dsl.Edge{{SourceNodeID: "start_1", TargetNodeID: "reply_1", SourcePortID: "edge_start_reply"}},
|
||||
}
|
||||
buf, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
@@ -120,13 +119,13 @@ func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.
|
||||
&models.AIAgent{Name: "售后 Agent"},
|
||||
)
|
||||
|
||||
if resp.Definition.EntryNodeID != "start_1" {
|
||||
if resp.Definition.SchemaVersion != dsl.SchemaVersion {
|
||||
t.Fatalf("expected run detail definition from published version, got %#v", resp.Definition)
|
||||
}
|
||||
if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Name != "运行时回复" {
|
||||
if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Data.Title != "运行时回复" {
|
||||
t.Fatalf("expected published definition nodes, got %#v", resp.Definition.Nodes)
|
||||
}
|
||||
if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].ID != "edge_start_reply" {
|
||||
if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].SourcePortID != "edge_start_reply" {
|
||||
t.Fatalf("expected published definition edges, got %#v", resp.Definition.Edges)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ type AIWorkflowNodeSpecResponse struct {
|
||||
ConfigSchema any `json:"configSchema,omitempty"`
|
||||
InputSchema []workflowregistry.VariableSpec `json:"inputSchema,omitempty"`
|
||||
OutputSchema []workflowregistry.VariableSpec `json:"outputSchema,omitempty"`
|
||||
DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"`
|
||||
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
|
||||
}
|
||||
|
||||
type AIWorkflowRunResponse struct {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(workflow.DraftDefinition), &stored); err != nil {
|
||||
t.Fatalf("unmarshal draft definition: %v", err)
|
||||
}
|
||||
if stored.EntryNodeID == "" {
|
||||
if stored.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(stored, "start_1") != workflowregistry.NodeTypeStart {
|
||||
t.Fatalf("expected default draft definition")
|
||||
}
|
||||
validation := workflowvalidator.ValidateDefinition(stored, workflowregistry.DefaultRegistry())
|
||||
@@ -93,7 +93,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
|
||||
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionIsValid(t *testing.T) {
|
||||
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
|
||||
if definition.EntryNodeID == "" {
|
||||
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
|
||||
t.Fatalf("expected default workflow definition")
|
||||
}
|
||||
validation := workflowvalidator.ValidateDefinition(definition, workflowregistry.DefaultRegistry())
|
||||
@@ -296,7 +296,7 @@ func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.Co
|
||||
continue
|
||||
}
|
||||
var config dsl.ConditionConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
|
||||
t.Fatalf("unmarshal condition config for %s: %v", nodeID, err)
|
||||
}
|
||||
return config.Branches
|
||||
@@ -307,7 +307,7 @@ func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.Co
|
||||
|
||||
func workflowEdgeExists(def dsl.Definition, sourceID string, targetID string) bool {
|
||||
for _, edge := range def.Edges {
|
||||
if edge.Source == sourceID && edge.Target == targetID {
|
||||
if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -329,10 +329,10 @@ func assertWorkflowLayoutDoesNotOverlap(t *testing.T, def dsl.Definition) {
|
||||
width, height := defaultWorkflowNodeRenderSize(node.Type)
|
||||
boxes = append(boxes, workflowLayoutBox{
|
||||
NodeID: node.ID,
|
||||
Left: node.Position.X,
|
||||
Top: node.Position.Y,
|
||||
Right: node.Position.X + width,
|
||||
Bottom: node.Position.Y + height,
|
||||
Left: node.Meta.Position.X,
|
||||
Top: node.Meta.Position.Y,
|
||||
Right: node.Meta.Position.X + width,
|
||||
Bottom: node.Meta.Position.Y + height,
|
||||
})
|
||||
}
|
||||
const minGap = 32.0
|
||||
|
||||
@@ -423,120 +423,132 @@ func (s *aiWorkflowService) createDefaultAgentWorkflow(db *gorm.DB, agent *model
|
||||
|
||||
func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 520}},
|
||||
{ID: "understanding_1", Type: workflowregistry.NodeTypeConversationUnderstanding, Name: "会话理解", Position: dsl.Position{X: 320, Y: 520}, Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "policy_1", Type: workflowregistry.NodeTypeReplyPolicy, Name: "回复策略", Position: dsl.Position{X: 640, Y: 520}, Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
|
||||
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
|
||||
"riskSignals": {NodeID: "understanding_1", Field: "riskSignals"},
|
||||
}},
|
||||
{ID: "policy_route_1", Type: workflowregistry.NodeTypeCondition, Name: "策略分流", Position: dsl.Position{X: 960, Y: 520}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "direct", Name: "直接回复", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "direct_reply"}},
|
||||
{ID: "clarify", Name: "追问澄清", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "clarify"}},
|
||||
{ID: "end_conversation", Name: "结束语", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "end_conversation"}},
|
||||
{ID: "handoff", Name: "转人工", TargetNodeID: "handoff_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "handoff_to_human"}},
|
||||
{ID: "ticket", Name: "创建工单", TargetNodeID: "draft_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "prepare_ticket"}},
|
||||
{ID: "knowledge", Name: "知识库回复", TargetNodeID: "retrieve_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "retrieve_knowledge"}},
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 0, 520, nil, nil),
|
||||
workflowNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "会话理解", 320, 520, workflowInputs("userMessage", "start_1", "userMessage"), nil),
|
||||
workflowNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "回复策略", 640, 520, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
|
||||
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
|
||||
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
|
||||
}, nil),
|
||||
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 960, 520, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
|
||||
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
|
||||
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
|
||||
workflowConditionBranch("handoff", "转人工", "handoff_1", "policy_1", "action", "eq", "handoff_to_human"),
|
||||
workflowConditionBranch("ticket", "创建工单", "draft_ticket_1", "policy_1", "action", "eq", "prepare_ticket"),
|
||||
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
|
||||
{ID: "default", Name: "默认澄清", TargetNodeID: "policy_reply_1", Default: true},
|
||||
}})},
|
||||
{ID: "policy_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送策略回复", Position: dsl.Position{X: 1280, Y: 0}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "policy_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "转人工", Position: dsl.Position{X: 1280, Y: 220}, Inputs: map[string]dsl.VariableSelector{
|
||||
"reason": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "handoff_end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1600, Y: 220}},
|
||||
{ID: "draft_ticket_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "整理工单草稿", Position: dsl.Position{X: 1280, Y: 440}, Inputs: map[string]dsl.VariableSelector{
|
||||
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "ticket_confirm_prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "建单确认文案", Position: dsl.Position{X: 1600, Y: 440}, Config: json.RawMessage(`{"staticReply":"我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}`), Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "ticket_confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "确认建单", Position: dsl.Position{X: 1920, Y: 440}, Inputs: map[string]dsl.VariableSelector{
|
||||
"prompt": {NodeID: "ticket_confirm_prompt_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "ticket_confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "建单确认分流", Position: dsl.Position{X: 2240, Y: 440}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "confirmed", Name: "已确认", TargetNodeID: "create_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "ticket_confirm_1", Field: "confirmed"}, Operator: "is_true"}},
|
||||
}}),
|
||||
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 1280, 0, workflowInputs("replyText", "policy_1", "replyText"), nil),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 1280, 220, workflowInputs("reason", "start_1", "userMessage"), nil),
|
||||
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 1600, 220, nil, nil),
|
||||
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 1280, 440, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 1600, 440, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}),
|
||||
workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1920, 440, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 2240, 440, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true},
|
||||
}})},
|
||||
{ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "创建工单", Position: dsl.Position{X: 2560, Y: 320}, Inputs: map[string]dsl.VariableSelector{
|
||||
"ticketDraft": {NodeID: "draft_ticket_1", Field: "ticketDraft"},
|
||||
"confirmed": {NodeID: "ticket_confirm_1", Field: "confirmed"},
|
||||
}},
|
||||
{ID: "ticket_result_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送建单结果", Position: dsl.Position{X: 2880, Y: 320}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "create_ticket_1", Field: "message"},
|
||||
}},
|
||||
{ID: "ticket_cancel_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "取消建单提示", Position: dsl.Position{X: 2560, Y: 560}, Config: json.RawMessage(`{"staticReply":"已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}`), Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "send_ticket_cancel_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送取消提示", Position: dsl.Position{X: 2880, Y: 560}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "ticket_cancel_reply_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 1280, Y: 860}, Inputs: map[string]dsl.VariableSelector{
|
||||
"query": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "answerability_1", Type: workflowregistry.NodeTypeAnswerabilityGate, Name: "可回答判断", Position: dsl.Position{X: 1600, Y: 860}, Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
|
||||
}},
|
||||
{ID: "answerability_route_1", Type: workflowregistry.NodeTypeCondition, Name: "可回答分流", Position: dsl.Position{X: 1920, Y: 860}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
{ID: "answerable", Name: "可以回答", TargetNodeID: "reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "answerability_1", Field: "answerability"}, Operator: "eq", Right: "answerable"}},
|
||||
}}),
|
||||
workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2560, 320, map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 2880, 320, workflowInputs("replyText", "create_ticket_1", "message"), nil),
|
||||
workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 2560, 560, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}),
|
||||
workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2880, 560, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 1280, 860, workflowInputs("query", "start_1", "userMessage"), nil),
|
||||
workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 1600, 860, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("answerability_route_1", workflowregistry.NodeTypeCondition, "可回答分流", 1920, 860, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("answerable", "可以回答", "reply_1", "answerability_1", "answerability", "eq", "answerable"),
|
||||
{ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true},
|
||||
}})},
|
||||
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 2240, Y: 780}, Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
|
||||
}},
|
||||
{ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送回复", Position: dsl.Position{X: 2560, Y: 780}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "reply_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "fallback_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "兜底追问", Position: dsl.Position{X: 2240, Y: 1040}, Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
|
||||
}},
|
||||
{ID: "send_fallback_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送兜底", Position: dsl.Position{X: 2560, Y: 1040}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "fallback_reply_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 3200, Y: 780}},
|
||||
}}),
|
||||
workflowNode("reply_1", workflowregistry.NodeTypeLLMReply, "AI 回复", 2240, 780, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_1", workflowregistry.NodeTypeSendReply, "发送回复", 2560, 780, workflowInputs("replyText", "reply_1", "replyText"), nil),
|
||||
workflowNode("fallback_reply_1", workflowregistry.NodeTypeLLMReply, "兜底追问", 2240, 1040, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 2560, 1040, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3200, 780, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_understanding", Source: "start_1", Target: "understanding_1"},
|
||||
{ID: "edge_understanding_policy", Source: "understanding_1", Target: "policy_1"},
|
||||
{ID: "edge_policy_route", Source: "policy_1", Target: "policy_route_1"},
|
||||
{ID: "edge_policy_reply", Source: "policy_route_1", Target: "policy_reply_1"},
|
||||
{ID: "edge_policy_handoff", Source: "policy_route_1", Target: "handoff_1"},
|
||||
{ID: "edge_policy_ticket", Source: "policy_route_1", Target: "draft_ticket_1"},
|
||||
{ID: "edge_policy_knowledge", Source: "policy_route_1", Target: "retrieve_1"},
|
||||
{ID: "edge_policy_reply_end", Source: "policy_reply_1", Target: "end_1"},
|
||||
{ID: "edge_handoff_end", Source: "handoff_1", Target: "handoff_end_1"},
|
||||
{ID: "edge_draft_ticket_confirm_prompt", Source: "draft_ticket_1", Target: "ticket_confirm_prompt_1"},
|
||||
{ID: "edge_ticket_prompt_confirm", Source: "ticket_confirm_prompt_1", Target: "ticket_confirm_1"},
|
||||
{ID: "edge_ticket_confirm_route", Source: "ticket_confirm_1", Target: "ticket_confirm_route_1"},
|
||||
{ID: "edge_ticket_confirm_create", Source: "ticket_confirm_route_1", Target: "create_ticket_1"},
|
||||
{ID: "edge_ticket_confirm_cancel", Source: "ticket_confirm_route_1", Target: "ticket_cancel_reply_1"},
|
||||
{ID: "edge_create_ticket_result", Source: "create_ticket_1", Target: "ticket_result_reply_1"},
|
||||
{ID: "edge_ticket_result_end", Source: "ticket_result_reply_1", Target: "end_1"},
|
||||
{ID: "edge_ticket_cancel_send", Source: "ticket_cancel_reply_1", Target: "send_ticket_cancel_1"},
|
||||
{ID: "edge_ticket_cancel_end", Source: "send_ticket_cancel_1", Target: "end_1"},
|
||||
{ID: "edge_retrieve_answerability", Source: "retrieve_1", Target: "answerability_1"},
|
||||
{ID: "edge_answerability_route", Source: "answerability_1", Target: "answerability_route_1"},
|
||||
{ID: "edge_answerability_reply", Source: "answerability_route_1", Target: "reply_1"},
|
||||
{ID: "edge_answerability_fallback", Source: "answerability_route_1", Target: "fallback_reply_1"},
|
||||
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
|
||||
{ID: "edge_fallback_send", Source: "fallback_reply_1", Target: "send_fallback_1"},
|
||||
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
|
||||
{ID: "edge_send_fallback_end", Source: "send_fallback_1", Target: "end_1"},
|
||||
workflowEdge("start_1", "understanding_1"),
|
||||
workflowEdge("understanding_1", "policy_1"),
|
||||
workflowEdge("policy_1", "policy_route_1"),
|
||||
workflowEdge("policy_route_1", "policy_reply_1"),
|
||||
workflowEdge("policy_route_1", "handoff_1"),
|
||||
workflowEdge("policy_route_1", "draft_ticket_1"),
|
||||
workflowEdge("policy_route_1", "retrieve_1"),
|
||||
workflowEdge("policy_reply_1", "end_1"),
|
||||
workflowEdge("handoff_1", "handoff_end_1"),
|
||||
workflowEdge("draft_ticket_1", "ticket_confirm_prompt_1"),
|
||||
workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"),
|
||||
workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"),
|
||||
workflowEdge("ticket_confirm_route_1", "create_ticket_1"),
|
||||
workflowEdge("ticket_confirm_route_1", "ticket_cancel_reply_1"),
|
||||
workflowEdge("create_ticket_1", "ticket_result_reply_1"),
|
||||
workflowEdge("ticket_result_reply_1", "end_1"),
|
||||
workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"),
|
||||
workflowEdge("send_ticket_cancel_1", "end_1"),
|
||||
workflowEdge("retrieve_1", "answerability_1"),
|
||||
workflowEdge("answerability_1", "answerability_route_1"),
|
||||
workflowEdge("answerability_route_1", "reply_1"),
|
||||
workflowEdge("answerability_route_1", "fallback_reply_1"),
|
||||
workflowEdge("reply_1", "send_1"),
|
||||
workflowEdge("fallback_reply_1", "send_fallback_1"),
|
||||
workflowEdge("send_1", "end_1"),
|
||||
workflowEdge("send_fallback_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: x, Y: y}},
|
||||
Data: dsl.NodeData{
|
||||
Title: title,
|
||||
Config: mustMarshalWorkflowConfig(config),
|
||||
InputsValues: inputs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowInputs(name string, nodeID string, field string) map[string]dsl.Value {
|
||||
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
|
||||
}
|
||||
|
||||
func workflowConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
|
||||
return dsl.ConditionBranch{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TargetNodeID: targetNodeID,
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
|
||||
Operator: operator,
|
||||
Right: right,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowEdge(source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
|
||||
}
|
||||
|
||||
func mustMarshalWorkflowConfig(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -20,16 +20,15 @@ import (
|
||||
func TestAIWorkflowServiceValidateDefinitionReportsErrors(t *testing.T) {
|
||||
setupAIWorkflowTestDB(t)
|
||||
result := AIWorkflowService.ValidateDefinition(dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "create_1", Type: "create_ticket"},
|
||||
{ID: "end_1", Type: "end"},
|
||||
workflowServiceTestNode("start_1", "start", nil, nil),
|
||||
workflowServiceTestNode("create_1", "create_ticket", nil, nil),
|
||||
workflowServiceTestNode("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "create_1"},
|
||||
{ID: "e2", Source: "create_1", Target: "end_1"},
|
||||
workflowServiceTestEdge("start_1", "create_1"),
|
||||
workflowServiceTestEdge("create_1", "end_1"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -79,7 +78,7 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(version.Definition), &stored); err != nil {
|
||||
t.Fatalf("unmarshal stored definition: %v", err)
|
||||
}
|
||||
if stored.EntryNodeID != "start_1" {
|
||||
if stored.SchemaVersion != dsl.SchemaVersion || len(stored.Nodes) == 0 {
|
||||
t.Fatalf("unexpected stored definition: %+v", stored)
|
||||
}
|
||||
}
|
||||
@@ -131,16 +130,15 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) {
|
||||
_, err = AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{
|
||||
WorkflowID: workflow.ID,
|
||||
Definition: dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "create_1", Type: "create_ticket"},
|
||||
{ID: "end_1", Type: "end"},
|
||||
workflowServiceTestNode("start_1", "start", nil, nil),
|
||||
workflowServiceTestNode("create_1", "create_ticket", nil, nil),
|
||||
workflowServiceTestNode("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "create_1"},
|
||||
{ID: "e2", Source: "create_1", Target: "end_1"},
|
||||
workflowServiceTestEdge("start_1", "create_1"),
|
||||
workflowServiceTestEdge("create_1", "end_1"),
|
||||
},
|
||||
},
|
||||
}, operator)
|
||||
@@ -164,7 +162,7 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
versionDefinition := validAIWorkflowDefinition()
|
||||
versionDefinition.Nodes[1].Name = "运行时回复"
|
||||
versionDefinition.Nodes[1].Data.Title = "运行时回复"
|
||||
versionDefinitionJSON, err := json.Marshal(versionDefinition)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal version definition: %v", err)
|
||||
@@ -278,22 +276,49 @@ func setupAIWorkflowTestDB(t *testing.T) {
|
||||
|
||||
func validAIWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`), Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "end_1", Type: "end"},
|
||||
workflowServiceTestNode("start_1", "start", nil, nil),
|
||||
workflowServiceTestNode("reply_1", "send_reply", map[string]dsl.Value{
|
||||
"replyText": dsl.RefValue("start_1", "userMessage"),
|
||||
}, map[string]any{"text": "hello"}),
|
||||
workflowServiceTestNode("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "reply_1"},
|
||||
{ID: "e2", Source: "reply_1", Target: "end_1"},
|
||||
workflowServiceTestEdge("start_1", "reply_1"),
|
||||
workflowServiceTestEdge("reply_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowServiceTestNode(id string, nodeType string, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
|
||||
Data: dsl.NodeData{
|
||||
Title: nodeType,
|
||||
InputsValues: inputs,
|
||||
Config: mustMarshalWorkflowServiceTestConfig(config),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowServiceTestEdge(source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
|
||||
}
|
||||
|
||||
func mustMarshalWorkflowServiceTestConfig(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func aiWorkflowTestOperator() *dto.AuthPrincipal {
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 1,
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
IMConversationServiceMode,
|
||||
Status,
|
||||
} from "@/lib/generated/enums"
|
||||
import { useWorkflowDefinitionHistory } from "../../ai-workflows/_components/use-workflow-definition-history"
|
||||
import { WorkflowEditor } from "../../ai-workflows/_components/workflow-editor"
|
||||
|
||||
type DirectToolItem = CreateAIAgentPayload["directTools"][number]
|
||||
@@ -88,25 +89,22 @@ type SectionKey =
|
||||
| "workflow"
|
||||
|
||||
const fallbackDefinition: AIWorkflowDefinition = {
|
||||
schemaVersion: 1,
|
||||
entryNodeId: "start_1",
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
name: "开始",
|
||||
position: { x: 0, y: 80 },
|
||||
config: {},
|
||||
meta: { position: { x: 0, y: 80 } },
|
||||
data: { title: "开始", config: {}, inputsValues: {} },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
name: "结束",
|
||||
position: { x: 260, y: 80 },
|
||||
config: {},
|
||||
meta: { position: { x: 260, y: 80 } },
|
||||
data: { title: "结束", config: {}, inputsValues: {} },
|
||||
},
|
||||
],
|
||||
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
|
||||
edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }],
|
||||
}
|
||||
|
||||
function toText(value: string | number | undefined | null) {
|
||||
@@ -156,8 +154,16 @@ export function AIAgentConfigWorkbench({
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
|
||||
const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
|
||||
|
||||
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
|
||||
const [workflowEditorKey, setWorkflowEditorKey] = useState(0)
|
||||
const {
|
||||
definition,
|
||||
revision: workflowRevision,
|
||||
canUndo: canUndoWorkflow,
|
||||
canRedo: canRedoWorkflow,
|
||||
replace: replaceWorkflowHistory,
|
||||
update: updateWorkflowDefinition,
|
||||
undo: undoWorkflowDefinition,
|
||||
redo: redoWorkflowDefinition,
|
||||
} = useWorkflowDefinitionHistory(fallbackDefinition)
|
||||
|
||||
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([])
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
@@ -175,9 +181,8 @@ export function AIAgentConfigWorkbench({
|
||||
}, [agentId])
|
||||
|
||||
const replaceWorkflowDefinition = useCallback((nextDefinition: AIWorkflowDefinition) => {
|
||||
setDefinition(nextDefinition)
|
||||
setWorkflowEditorKey((current) => current + 1)
|
||||
}, [])
|
||||
replaceWorkflowHistory(nextDefinition)
|
||||
}, [replaceWorkflowHistory])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -811,10 +816,14 @@ export function AIAgentConfigWorkbench({
|
||||
|
||||
{activeSection === "workflow" ? (
|
||||
<WorkflowEditor
|
||||
key={workflowEditorKey}
|
||||
key={workflowRevision}
|
||||
definition={definition}
|
||||
nodeSpecs={nodeSpecs}
|
||||
onDefinitionChange={setDefinition}
|
||||
onDefinitionChange={updateWorkflowDefinition}
|
||||
onUndo={undoWorkflowDefinition}
|
||||
undoDisabled={!canUndoWorkflow || savingWorkflow || loading}
|
||||
onRedo={redoWorkflowDefinition}
|
||||
redoDisabled={!canRedoWorkflow || savingWorkflow || loading}
|
||||
onRestoreDefault={restoreDefaultWorkflow}
|
||||
restoreDefaultDisabled={savingWorkflow || loading}
|
||||
onValidate={validateWorkflowDraft}
|
||||
|
||||
@@ -1,455 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import "@xyflow/react/dist/style.css"
|
||||
|
||||
import {
|
||||
Background,
|
||||
BaseEdge,
|
||||
Controls,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlow,
|
||||
getBezierPath,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react"
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
GitBranchIcon,
|
||||
InfoIcon,
|
||||
TimerIcon,
|
||||
} from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor"
|
||||
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
|
||||
|
||||
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
AIWorkflowDefinition,
|
||||
AIWorkflowNodeRun,
|
||||
AIWorkflowRun,
|
||||
} from "@/lib/api/admin"
|
||||
|
||||
type AuditNodeData = Record<string, unknown> & {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
name: string
|
||||
executed: boolean
|
||||
statusName?: string
|
||||
durationMs?: number
|
||||
errorMessage?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
type AuditNode = Node<AuditNodeData>
|
||||
type AuditEdge = Edge<{ executed?: boolean }>
|
||||
|
||||
type BranchDecision = {
|
||||
selectedEdgeId?: string
|
||||
selectedBranchId?: string
|
||||
selectedBranchName?: string
|
||||
selectedTargetNodeId?: string
|
||||
reason?: string
|
||||
evaluations?: BranchEvaluation[]
|
||||
}
|
||||
|
||||
type BranchEvaluation = {
|
||||
edgeId?: string
|
||||
branchId?: string
|
||||
branchName?: string
|
||||
targetNodeId?: string
|
||||
sourceNodeId?: string
|
||||
sourceField?: string
|
||||
operator?: string
|
||||
leftValue?: unknown
|
||||
rightValue?: unknown
|
||||
matched?: boolean
|
||||
}
|
||||
|
||||
const auditNodeTypes = {
|
||||
auditNode: AuditCanvasNode,
|
||||
}
|
||||
|
||||
const auditEdgeTypes = {
|
||||
auditEdge: AuditCanvasEdge,
|
||||
}
|
||||
|
||||
const fitViewOptions = {
|
||||
padding: 0.12,
|
||||
minZoom: 0.32,
|
||||
maxZoom: 1,
|
||||
}
|
||||
|
||||
const defaultEdgeOptions = {
|
||||
type: "auditEdge",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
}
|
||||
|
||||
const auditLayoutScale = {
|
||||
x: 1.35,
|
||||
y: 1.15,
|
||||
}
|
||||
import { useFlowgramEditorProps } from "../../ai-workflows/_components/flowgram-editor-provider"
|
||||
|
||||
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
|
||||
const nodeRuns = run.nodes ?? []
|
||||
const nodeRunByNodeId = useMemo(() => {
|
||||
const map = new Map<string, AIWorkflowNodeRun>()
|
||||
for (const node of nodeRuns) {
|
||||
map.set(node.nodeId, node)
|
||||
}
|
||||
return map
|
||||
}, [nodeRuns])
|
||||
const activeEdgeIds = useMemo(() => buildActiveEdgeIds(run.definition, nodeRuns), [run.definition, nodeRuns])
|
||||
const firstExecutedNodeId = nodeRuns[0]?.nodeId ?? run.definition?.entryNodeId ?? ""
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(firstExecutedNodeId)
|
||||
|
||||
const nodes = useMemo<AuditNode[]>(() => {
|
||||
return (run.definition?.nodes ?? []).map((node) => {
|
||||
const nodeRun = nodeRunByNodeId.get(node.id)
|
||||
return {
|
||||
id: node.id,
|
||||
type: "auditNode",
|
||||
position: scaleAuditPosition(node.position),
|
||||
data: {
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
name: node.name || node.id,
|
||||
executed: Boolean(nodeRun),
|
||||
statusName: nodeRun?.statusName,
|
||||
durationMs: nodeRun?.durationMs,
|
||||
errorMessage: nodeRun?.errorMessage,
|
||||
selected: selectedNodeId === node.id,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [nodeRunByNodeId, run.definition?.nodes, selectedNodeId])
|
||||
|
||||
const edges = useMemo<AuditEdge[]>(() => {
|
||||
return (run.definition?.edges ?? []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: "auditEdge",
|
||||
data: {
|
||||
executed: activeEdgeIds.has(edge.id),
|
||||
},
|
||||
}))
|
||||
}, [activeEdgeIds, run.definition?.edges])
|
||||
|
||||
const selectedNodeRun = selectedNodeId ? nodeRunByNodeId.get(selectedNodeId) : undefined
|
||||
const selectedDefinitionNode = run.definition?.nodes?.find((node) => node.id === selectedNodeId)
|
||||
|
||||
if (!run.definition?.nodes?.length) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
流程定义快照缺失,仍可查看下方节点运行明细。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] overflow-hidden rounded-md border bg-background lg:grid-cols-[minmax(0,1fr)_390px]">
|
||||
<div className="h-[600px] min-w-0 border-b bg-muted/10 lg:border-b-0 lg:border-r">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={auditNodeTypes}
|
||||
edgeTypes={auditEdgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
fitView
|
||||
fitViewOptions={fitViewOptions}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
elementsSelectable
|
||||
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<AuditSidePanel
|
||||
definitionNode={selectedDefinitionNode}
|
||||
nodeRun={selectedNodeRun}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildActiveEdgeIds(definition: AIWorkflowDefinition | undefined, nodeRuns: AIWorkflowNodeRun[]) {
|
||||
const active = new Set<string>()
|
||||
const edges = definition?.edges ?? []
|
||||
for (let i = 0; i < nodeRuns.length - 1; i += 1) {
|
||||
const source = nodeRuns[i]?.nodeId
|
||||
const target = nodeRuns[i + 1]?.nodeId
|
||||
const edge = edges.find((item) => item.source === source && item.target === target)
|
||||
if (edge) {
|
||||
active.add(edge.id)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
function AuditCanvasEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps<AuditEdge>) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
curvature: 0.18,
|
||||
const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
|
||||
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
|
||||
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
|
||||
const editorProps = useFlowgramEditorProps({
|
||||
definition: run.definition ?? { schemaVersion: 2, nodes: [], edges: [] },
|
||||
nodeSpecs: [],
|
||||
readonly: true,
|
||||
})
|
||||
const executed = Boolean(data?.executed)
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
className={cn(
|
||||
"transition-all",
|
||||
executed ? "!stroke-primary !stroke-[2.6px]" : "!stroke-muted-foreground/25 !stroke-[1.4px]"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditCanvasNode({ data }: NodeProps<AuditNode>) {
|
||||
const executed = Boolean(data.executed)
|
||||
const failed = data.statusName === "failed" || Boolean(data.errorMessage)
|
||||
const interrupted = data.statusName === "interrupted"
|
||||
const selected = Boolean(data.selected)
|
||||
const condition = data.nodeType === "condition"
|
||||
const toneClass = failed
|
||||
? "border-destructive bg-destructive/5 text-destructive"
|
||||
: interrupted
|
||||
? "border-amber-500 bg-amber-500/10 text-amber-700"
|
||||
: executed
|
||||
? "border-emerald-500 bg-emerald-500/10 text-emerald-700"
|
||||
: "border-border bg-muted/40 text-muted-foreground"
|
||||
|
||||
if (condition) {
|
||||
return (
|
||||
<div className={cn("relative flex size-24 items-center justify-center opacity-60", executed && "opacity-100")}>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-3 rotate-45 rounded-lg border shadow-sm transition-all",
|
||||
toneClass,
|
||||
selected && "ring-4 ring-primary/15"
|
||||
)}
|
||||
/>
|
||||
<div className="relative z-10 flex max-w-18 flex-col items-center text-center">
|
||||
<GitBranchIcon className="mb-0.5 size-3.5" />
|
||||
<div className="line-clamp-2 text-[11px] font-medium leading-tight">{data.name}</div>
|
||||
<div className="mt-1 text-[10px] opacity-75">{data.statusName || "未执行"}</div>
|
||||
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
|
||||
<div className="relative min-w-0">
|
||||
<FreeLayoutEditorProvider {...editorProps}>
|
||||
<EditorRenderer className="h-full w-full" />
|
||||
</FreeLayoutEditorProvider>
|
||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
已执行 {executedNodeIds.size}
|
||||
</Badge>
|
||||
{run.errorMessage ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangleIcon className="size-3" />
|
||||
异常
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-40 overflow-hidden rounded-md border bg-background shadow-sm opacity-55 transition-all",
|
||||
executed && "opacity-100",
|
||||
selected && "ring-4 ring-primary/15"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
|
||||
<div className={cn("border-b px-2.5 py-1.5", toneClass)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{failed ? <AlertTriangleIcon className="size-3.5 shrink-0" /> : <CheckCircle2Icon className="size-3.5 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">{data.name}</div>
|
||||
<div className="truncate text-[11px] opacity-75">{data.nodeType}</div>
|
||||
<aside className="flex min-h-0 flex-col border-l bg-background">
|
||||
<div className="border-b p-3">
|
||||
<div className="text-sm font-medium">节点轨迹</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">点击查看输入、输出和错误信息</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 p-3">
|
||||
{nodeRuns.map((node) => (
|
||||
<button
|
||||
key={node.id || node.nodeId}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-md border p-2 text-left text-xs hover:bg-muted",
|
||||
selectedNodeId === node.nodeId ? "border-primary bg-primary/5" : "bg-background"
|
||||
)}
|
||||
onClick={() => setSelectedNodeId(node.nodeId)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">{node.nodeId}</span>
|
||||
<Badge variant={node.errorMessage ? "destructive" : "secondary"}>{node.statusName}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||
<TimerIcon className="size-3" />
|
||||
{node.durationMs ?? 0}ms
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="max-h-72 overflow-auto border-t p-3">
|
||||
<NodeRunPreview nodeRun={selectedNodeRun} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{data.statusName || "未执行"}</span>
|
||||
{executed ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<TimerIcon className="size-3" />
|
||||
{data.durationMs ?? 0} ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditSidePanel({
|
||||
definitionNode,
|
||||
nodeRun,
|
||||
}: {
|
||||
definitionNode?: AIWorkflowDefinition["nodes"][number]
|
||||
nodeRun?: AIWorkflowNodeRun
|
||||
}) {
|
||||
const inputValue = safeParseJSON(nodeRun?.inputPreview ?? "")
|
||||
const outputValue = safeParseJSON(nodeRun?.outputPreview ?? "")
|
||||
const branchDecision = extractBranchDecision(outputValue)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[600px]">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">{definitionNode?.name || nodeRun?.nodeId || "节点详情"}</h3>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{definitionNode?.id || nodeRun?.nodeId || "-"} · {definitionNode?.type || nodeRun?.nodeType || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nodeRun ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<AuditMeta label="状态" value={nodeRun.statusName || String(nodeRun.status)} />
|
||||
<AuditMeta label="耗时" value={`${nodeRun.durationMs || 0} ms`} />
|
||||
<AuditMeta label="开始" value={nodeRun.startedAt || "-"} />
|
||||
<AuditMeta label="结束" value={nodeRun.endedAt || "-"} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
本次运行没有执行该节点。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeRun?.errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{nodeRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{branchDecision ? <BranchDecisionBlock decision={branchDecision} /> : null}
|
||||
|
||||
<PreviewBlock title="输入" raw={nodeRun?.inputPreview ?? ""} value={inputValue} />
|
||||
<PreviewBlock title="输出" raw={nodeRun?.outputPreview ?? ""} value={outputValue} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditMeta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md border bg-muted/20 px-2 py-1.5">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="truncate font-medium">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function scaleAuditPosition(position: AIWorkflowDefinition["nodes"][number]["position"] | undefined) {
|
||||
return {
|
||||
x: Math.round((position?.x ?? 0) * auditLayoutScale.x),
|
||||
y: Math.round((position?.y ?? 0) * auditLayoutScale.y),
|
||||
function NodeRunPreview({ nodeRun }: { nodeRun?: AIWorkflowNodeRun }) {
|
||||
if (!nodeRun) {
|
||||
return <div className="text-xs text-muted-foreground">暂无节点执行记录。</div>
|
||||
}
|
||||
}
|
||||
|
||||
function BranchDecisionBlock({ decision }: { decision: BranchDecision }) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium">分支决策</div>
|
||||
<Badge variant="outline">{decision.selectedBranchName || decision.selectedBranchId || "default"}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>目标节点:{decision.selectedTargetNodeId || "-"}</div>
|
||||
<div>原因:{decision.reason || "-"}</div>
|
||||
</div>
|
||||
{decision.evaluations?.length ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
{decision.evaluations.map((item, index) => (
|
||||
<div key={`${item.edgeId || item.branchId || index}`} className="rounded-md border bg-background px-2 py-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{item.branchName || item.branchId || item.edgeId || `条件 ${index + 1}`}</span>
|
||||
<Badge variant={item.matched ? "default" : "secondary"}>{item.matched ? "命中" : "未命中"}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 break-all text-muted-foreground">
|
||||
{item.sourceNodeId}.{item.sourceField} {item.operator} {formatUnknown(item.rightValue)}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-muted-foreground">
|
||||
实际值:{formatUnknown(item.leftValue)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3 text-xs">
|
||||
{nodeRun.errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-destructive">
|
||||
{nodeRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<PreviewBlock title="输入" value={nodeRun.inputPreview} />
|
||||
<PreviewBlock title="输出" value={nodeRun.outputPreview} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBlock({ title, raw, value }: { title: string; raw: string; value: unknown }) {
|
||||
function PreviewBlock({ title, value }: { title: string; value?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">{title}</div>
|
||||
{value !== null ? (
|
||||
<JsonTreeViewer value={value} collapsed={2} />
|
||||
) : raw.trim() ? (
|
||||
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all">
|
||||
{raw}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">-</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="mb-1 font-medium">{title}</div>
|
||||
{value ? <JsonTreeViewer value={parsePreview(value)} /> : <div className="text-muted-foreground">无</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractBranchDecision(value: unknown): BranchDecision | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const decision = record.branchDecision
|
||||
if (!decision || typeof decision !== "object") {
|
||||
return null
|
||||
}
|
||||
return decision as BranchDecision
|
||||
}
|
||||
|
||||
function safeParseJSON(raw: string): unknown | null {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
function parsePreview(value: string) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnknown(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return "-"
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import {
|
||||
type FreeLayoutProps,
|
||||
type WorkflowJSON,
|
||||
} from "@flowgram.ai/free-layout-editor"
|
||||
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
|
||||
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
|
||||
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
|
||||
|
||||
export function useFlowgramEditorProps({
|
||||
definition,
|
||||
nodeSpecs,
|
||||
readonly = false,
|
||||
onDefinitionChange,
|
||||
}: {
|
||||
definition: AIWorkflowDefinition
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
readonly?: boolean
|
||||
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
|
||||
}) {
|
||||
return useMemo<FreeLayoutProps>(
|
||||
() => ({
|
||||
background: true,
|
||||
readonly,
|
||||
initialData: definition as WorkflowJSON,
|
||||
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
|
||||
materials: {
|
||||
renderDefaultNode: FlowgramNodeRenderer,
|
||||
},
|
||||
nodeEngine: {
|
||||
enable: false,
|
||||
},
|
||||
history: {
|
||||
enable: !readonly,
|
||||
enableChangeNode: !readonly,
|
||||
},
|
||||
canDeleteNode: (_ctx, node) => {
|
||||
const type = String(node.flowNodeType ?? "")
|
||||
return type !== "start" && type !== "end"
|
||||
},
|
||||
canDeleteLine: () => !readonly,
|
||||
onContentChange: (ctx) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
onDefinitionChange?.(ctx.document.toJSON() as AIWorkflowDefinition)
|
||||
},
|
||||
onAllLayersRendered: (ctx) => {
|
||||
void ctx.tools.fitView(false)
|
||||
},
|
||||
getNodeDefaultRegistry(type) {
|
||||
return {
|
||||
type,
|
||||
meta: {
|
||||
defaultExpanded: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: () => [],
|
||||
}),
|
||||
[definition, nodeSpecs, onDefinitionChange, readonly]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
|
||||
|
||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
|
||||
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
|
||||
const seen = new Set<string>()
|
||||
const specs = nodeSpecs.length > 0
|
||||
? nodeSpecs
|
||||
: [
|
||||
{ type: "start", title: "开始" },
|
||||
{ type: "end", title: "结束" },
|
||||
]
|
||||
|
||||
return specs
|
||||
.filter((spec) => {
|
||||
if (!spec.type || seen.has(spec.type)) {
|
||||
return false
|
||||
}
|
||||
seen.add(spec.type)
|
||||
return true
|
||||
})
|
||||
.map((spec) => ({
|
||||
type: spec.type,
|
||||
meta: {
|
||||
defaultExpanded: true,
|
||||
deleteDisable: spec.type === "start" || spec.type === "end",
|
||||
copyDisable: spec.type === "start" || spec.type === "end",
|
||||
defaultPorts: defaultPortsForNodeType(spec.type),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function defaultPortsForNodeType(type: string) {
|
||||
if (type === "start") {
|
||||
return [{ type: "output" as const }]
|
||||
}
|
||||
if (type === "end") {
|
||||
return [{ type: "input" as const }]
|
||||
}
|
||||
return [{ type: "input" as const }, { type: "output" as const }]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import "@flowgram.ai/free-layout-editor/index.css"
|
||||
|
||||
import {
|
||||
useNodeRender,
|
||||
WorkflowNodeRenderer,
|
||||
type WorkflowNodeProps,
|
||||
} from "@flowgram.ai/free-layout-editor"
|
||||
import {
|
||||
BotIcon,
|
||||
CircleStopIcon,
|
||||
DatabaseIcon,
|
||||
GitBranchIcon,
|
||||
MessageSquareTextIcon,
|
||||
SendIcon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react"
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const iconByType: Record<string, ComponentType<{ className?: string }>> = {
|
||||
start: UserRoundIcon,
|
||||
conversation_understanding: BotIcon,
|
||||
reply_policy: MessageSquareTextIcon,
|
||||
condition: GitBranchIcon,
|
||||
knowledge_retrieve: DatabaseIcon,
|
||||
answerability_gate: GitBranchIcon,
|
||||
llm_reply: BotIcon,
|
||||
human_confirm: UserRoundIcon,
|
||||
create_ticket: MessageSquareTextIcon,
|
||||
handoff_to_human: UserRoundIcon,
|
||||
send_reply: SendIcon,
|
||||
end: CircleStopIcon,
|
||||
}
|
||||
|
||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
||||
const { selected, node } = useNodeRender()
|
||||
const nodeType = String(node.flowNodeType ?? "")
|
||||
const Icon = iconByType[nodeType] ?? BotIcon
|
||||
const nodeJSON = node.toJSON?.() as { data?: { title?: string }; title?: string } | undefined
|
||||
const title = nodeJSON?.data?.title || nodeJSON?.title || nodeType || "节点"
|
||||
|
||||
return (
|
||||
<WorkflowNodeRenderer
|
||||
node={props.node}
|
||||
className={cn(
|
||||
"w-[260px] rounded-md border bg-background shadow-sm transition-colors",
|
||||
selected ? "border-primary ring-2 ring-primary/15" : "border-border"
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium leading-5">{title}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowNodeRenderer>
|
||||
)
|
||||
}
|
||||
@@ -1,577 +1,363 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { Node } from "@xyflow/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Trash2Icon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { VariableSelector } from "./variable-selector"
|
||||
import type {
|
||||
WorkflowConditionBranch,
|
||||
WorkflowNodeSpec,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowVariableRef,
|
||||
WorkflowVariableSpec,
|
||||
WorkflowVariableSelector,
|
||||
} from "./workflow-utils"
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type WorkflowNodeData = Record<string, unknown> & {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
title?: string
|
||||
config?: WorkflowNodeConfig
|
||||
inputs?: Record<string, WorkflowVariableSelector>
|
||||
}
|
||||
import { VariableSelector } from "./variable-selector"
|
||||
import {
|
||||
createConditionBranchID,
|
||||
isRefValue,
|
||||
normalizeNodeConfig,
|
||||
type WorkflowConditionBranch,
|
||||
type WorkflowVariableRef,
|
||||
} from "./workflow-utils"
|
||||
|
||||
export type WorkflowBranchSummary = {
|
||||
branchId: string
|
||||
targetNodeId: string
|
||||
targetName: string
|
||||
conditionLabel: string
|
||||
conditionSet: boolean
|
||||
isDefault: boolean
|
||||
targetNodeId?: string
|
||||
targetName?: string
|
||||
}
|
||||
|
||||
export function NodeConfigPanel({
|
||||
node,
|
||||
nodeSpec,
|
||||
nodes,
|
||||
availableVariables,
|
||||
branchSummaries = [],
|
||||
onChange,
|
||||
onDelete,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData> | null
|
||||
nodeSpec?: WorkflowNodeSpec
|
||||
availableVariables: WorkflowVariableRef[]
|
||||
node: AIWorkflowDefinition["nodes"][number] | null
|
||||
nodeSpec?: AIWorkflowNodeSpec
|
||||
nodes: AIWorkflowDefinition["nodes"]
|
||||
availableVariables?: WorkflowVariableRef[]
|
||||
branchSummaries?: WorkflowBranchSummary[]
|
||||
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
||||
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
|
||||
onDelete?: (nodeId: string) => void
|
||||
}) {
|
||||
const [configText, setConfigText] = useState("{}")
|
||||
|
||||
useEffect(() => {
|
||||
setConfigText(JSON.stringify(node?.data?.config ?? {}, null, 2))
|
||||
}, [node?.id, node?.data?.config])
|
||||
|
||||
const configError = useMemo(() => {
|
||||
if (!node) {
|
||||
return ""
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(configText || "{}")
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? "" : "配置必须是 JSON 对象"
|
||||
} catch {
|
||||
return "JSON 格式错误"
|
||||
}
|
||||
}, [configText, node])
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
|
||||
选择一个节点后,可以配置输入映射并查看输出变量。
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
未选择节点
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeConfigForm
|
||||
key={node.id}
|
||||
node={node}
|
||||
nodeSpec={nodeSpec}
|
||||
availableVariables={availableVariables}
|
||||
branchSummaries={branchSummaries}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeConfigForm({
|
||||
node,
|
||||
nodeSpec,
|
||||
availableVariables,
|
||||
branchSummaries,
|
||||
onChange,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData>
|
||||
nodeSpec?: WorkflowNodeSpec
|
||||
availableVariables: WorkflowVariableRef[]
|
||||
branchSummaries: WorkflowBranchSummary[]
|
||||
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
||||
}) {
|
||||
const [name, setName] = useState(node.data.name ?? "")
|
||||
const [configText, setConfigText] = useState(JSON.stringify(node.data.config ?? {}, null, 2))
|
||||
const [inputs, setInputs] = useState<Record<string, WorkflowVariableSelector>>(
|
||||
node.data.inputs ?? {}
|
||||
)
|
||||
const [error, setError] = useState("")
|
||||
const inputsValues = node.data?.inputsValues ?? {}
|
||||
const inputSchema = nodeSpec?.inputSchema ?? []
|
||||
const outputSchema = nodeSpec?.outputSchema ?? []
|
||||
const isConditionNode = node.data.nodeType === "condition"
|
||||
const fallbackNodeName = nodeSpec?.title || node.data.title || node.data.nodeType || node.id
|
||||
const panelTitle = name.trim() || node.data.name?.trim() || fallbackNodeName
|
||||
const canDelete = node.type !== "start" && node.type !== "end"
|
||||
const config = normalizeNodeConfig(node.data?.config)
|
||||
const branches = config.branches ?? []
|
||||
|
||||
const commitChange = (next: Partial<WorkflowNodeData>) => {
|
||||
const updateData = (data: Partial<AIWorkflowDefinition["nodes"][number]["data"]>) => {
|
||||
onChange(node.id, {
|
||||
...node.data,
|
||||
name: name.trim() || fallbackNodeName,
|
||||
config: node.data.config ?? {},
|
||||
inputs,
|
||||
...next,
|
||||
...(node.data ?? {}),
|
||||
...data,
|
||||
})
|
||||
}
|
||||
const updateConfig = (nextConfig: Record<string, unknown>) => updateData({ config: nextConfig })
|
||||
const updateBranch = (branch: WorkflowConditionBranch) => {
|
||||
const nextBranches = branches.some((item) => item.id === branch.id)
|
||||
? branches.map((item) => (item.id === branch.id ? branch : item))
|
||||
: [...branches, branch]
|
||||
updateConfig({ ...config, branches: nextBranches })
|
||||
}
|
||||
const deleteBranch = (branchId: string) => {
|
||||
updateConfig({ ...config, branches: branches.filter((branch) => branch.id !== branchId) })
|
||||
}
|
||||
const addBranch = () => {
|
||||
const targetNodeId = nodes.find((item) => item.id !== node.id && item.type !== "start")?.id ?? ""
|
||||
updateBranch({
|
||||
id: createConditionBranchID(branches),
|
||||
name: "新分支",
|
||||
targetNodeId,
|
||||
condition: {
|
||||
operator: "eq",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(configText || "{}") as Record<string, unknown>
|
||||
setError("")
|
||||
commitChange({ config: parsed })
|
||||
} catch {
|
||||
setError("Config must be valid JSON.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<div className="sticky top-0 z-10 shrink-0 border-b border-border/60 bg-background">
|
||||
<div className="px-4 pb-2 pt-4">
|
||||
<Input
|
||||
id="workflow-node-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onBlur={() => commitChange({ name: name.trim() || node.data.nodeType || node.id })}
|
||||
className="h-8 border-0 bg-transparent px-0 text-sm font-semibold uppercase shadow-none focus-visible:ring-0"
|
||||
aria-label="节点名称"
|
||||
/>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{node.data.nodeType && node.data.nodeType !== panelTitle
|
||||
? `${node.id} · ${node.data.nodeType}`
|
||||
: node.id}
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{node.data?.title || nodeSpec?.title || node.type}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{node.id}</div>
|
||||
</div>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete?.(node.id)}
|
||||
aria-label="删除节点"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
{isConditionNode ? (
|
||||
<ConditionNodePanel
|
||||
branches={node.data.config?.branches ?? []}
|
||||
branchSummaries={branchSummaries}
|
||||
availableVariables={availableVariables}
|
||||
outputSchema={outputSchema}
|
||||
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`node-title-${node.id}`}>标题</Label>
|
||||
<Input
|
||||
id={`node-title-${node.id}`}
|
||||
value={node.data?.title ?? ""}
|
||||
placeholder={nodeSpec?.title || node.type}
|
||||
onChange={(event) => updateData({ title: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{inputSchema.length > 0 ? (
|
||||
<div className="space-y-3 border-b border-border/60 p-4">
|
||||
<div className="text-sm font-semibold uppercase">输入映射</div>
|
||||
{availableVariables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
当前节点前面还没有可用变量,请先连接上游节点。
|
||||
</div>
|
||||
) : null}
|
||||
{inputSchema.map((input) => (
|
||||
</div>
|
||||
|
||||
{inputSchema.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">输入</div>
|
||||
{inputSchema.map((input) => {
|
||||
const value = inputsValues[input.name]
|
||||
return (
|
||||
<div key={input.name} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">
|
||||
{input.name}
|
||||
{input.required ? <span className="text-destructive"> *</span> : null}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{input.type}</span>
|
||||
</div>
|
||||
<Label className="flex items-center gap-1">
|
||||
<span>{input.label || input.name}</span>
|
||||
{input.required ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
<VariableSelector
|
||||
value={inputs[input.name]}
|
||||
variables={availableVariables}
|
||||
onChange={(value) => {
|
||||
const nextInputs = {
|
||||
...inputs,
|
||||
[input.name]: value,
|
||||
}
|
||||
setInputs(nextInputs)
|
||||
commitChange({
|
||||
inputs: nextInputs,
|
||||
value={isRefValue(value) ? value : undefined}
|
||||
variables={availableVariables ?? []}
|
||||
placeholder="选择变量"
|
||||
onChange={(next) => {
|
||||
updateData({
|
||||
inputsValues: {
|
||||
...inputsValues,
|
||||
[input.name]: next,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{inputs[input.name] ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
已选择:{inputs[input.name].nodeId}.{inputs[input.name].field}
|
||||
</div>
|
||||
) : null}
|
||||
{input.description ? (
|
||||
<div className="text-xs text-muted-foreground">{input.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<details className="border-b border-border/60 p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">高级配置 JSON</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Textarea
|
||||
id="workflow-node-config"
|
||||
className="h-40 font-mono text-xs"
|
||||
value={configText}
|
||||
onChange={(event) => setConfigText(event.target.value)}
|
||||
/>
|
||||
{error ? <div className="text-xs text-destructive">{error}</div> : null}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleApply}>
|
||||
保存高级配置
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
{outputSchema.length > 0 ? (
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="text-sm font-semibold uppercase">输出变量</div>
|
||||
<div className="space-y-1 rounded-lg bg-muted/60 p-2">
|
||||
{outputSchema.map((output) => (
|
||||
<div key={output.name} className="space-y-0.5 rounded-sm px-1 py-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-medium">{output.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{output.type}</span>
|
||||
</div>
|
||||
{output.description ? (
|
||||
<div className="text-xs text-muted-foreground">{output.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConditionNodePanel({
|
||||
branches,
|
||||
branchSummaries,
|
||||
availableVariables,
|
||||
outputSchema,
|
||||
onChange,
|
||||
}: {
|
||||
branches: WorkflowConditionBranch[]
|
||||
branchSummaries: WorkflowBranchSummary[]
|
||||
availableVariables: WorkflowVariableRef[]
|
||||
outputSchema: WorkflowVariableSpec[]
|
||||
onChange: (branches: WorkflowConditionBranch[]) => void
|
||||
}) {
|
||||
const summariesByBranchID = new Map(branchSummaries.map((item) => [item.branchId, item]))
|
||||
const commitBranch = (branchId: string, patch: Partial<WorkflowConditionBranch>) => {
|
||||
onChange(branches.map((branch) => (
|
||||
branch.id === branchId ? normalizeBranch({ ...branch, ...patch }) : branch
|
||||
)))
|
||||
}
|
||||
const addBranch = () => {
|
||||
const index = branches.length + 1
|
||||
const nextBranch = {
|
||||
id: `branch_${index}`,
|
||||
name: `分支 ${index}`,
|
||||
targetNodeId: "",
|
||||
condition: { operator: "eq" },
|
||||
}
|
||||
const defaultIndex = branches.findIndex((branch) => branch.default)
|
||||
if (defaultIndex >= 0) {
|
||||
onChange([
|
||||
...branches.slice(0, defaultIndex),
|
||||
nextBranch,
|
||||
...branches.slice(defaultIndex),
|
||||
])
|
||||
return
|
||||
}
|
||||
onChange([
|
||||
...branches,
|
||||
nextBranch,
|
||||
{
|
||||
id: "default",
|
||||
name: "其他情况",
|
||||
targetNodeId: "",
|
||||
default: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
const deleteBranch = (branchId: string) => {
|
||||
onChange(branches.filter((branch) => branch.id !== branchId || branch.default))
|
||||
}
|
||||
const moveBranch = (branchId: string, direction: -1 | 1) => {
|
||||
const index = branches.findIndex((branch) => branch.id === branchId)
|
||||
if (index < 0 || branches[index]?.default) {
|
||||
return
|
||||
}
|
||||
const nextIndex = index + direction
|
||||
if (nextIndex < 0 || nextIndex >= branches.length || branches[nextIndex]?.default) {
|
||||
return
|
||||
}
|
||||
const next = [...branches]
|
||||
const current = next[index]
|
||||
next[index] = next[nextIndex]
|
||||
next[nextIndex] = current
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 border-b border-border/60 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold uppercase">分支</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addBranch}>
|
||||
添加分支
|
||||
</Button>
|
||||
</div>
|
||||
{branches.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{branches.map((branch, index) => {
|
||||
const summary = summariesByBranchID.get(branch.id)
|
||||
const condition = branch.condition ?? {}
|
||||
const selectedVariable = findConditionVariable(availableVariables, condition.left)
|
||||
const operatorOptions = getConditionOperatorOptions(selectedVariable)
|
||||
const conditionRight = condition.right === undefined || condition.right === null
|
||||
? ""
|
||||
: String(condition.right)
|
||||
return (
|
||||
<div key={branch.id} className="space-y-3 rounded-xl bg-muted/60 p-3">
|
||||
<div className="flex h-6 items-center justify-between gap-2 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-semibold text-muted-foreground">
|
||||
{branch.default ? "ELSE" : index === 0 ? "IF" : "ELIF"}
|
||||
</span>
|
||||
{!branch.default ? (
|
||||
<span className="truncate text-[10px] font-semibold text-muted-foreground/80">
|
||||
CASE {index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-background px-1.5 py-0.5 text-muted-foreground">
|
||||
{branch.default ? "默认" : "条件"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">分支名称</Label>
|
||||
<Input
|
||||
value={branch.name ?? ""}
|
||||
onChange={(event) => commitBranch(branch.id, { name: event.target.value })}
|
||||
placeholder="例如:需要转人工"
|
||||
className="h-8 bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">目标节点</Label>
|
||||
<div className="rounded-md border border-dashed bg-background/70 px-2 py-2 text-xs text-muted-foreground">
|
||||
{summary?.targetNodeId
|
||||
? `已连接到:${summary.targetName}`
|
||||
: "请从画布中该分支右侧连接点拖线到目标节点"}
|
||||
</div>
|
||||
</div>
|
||||
{branch.default ? (
|
||||
<div className="rounded-md bg-background/70 p-2 text-xs text-muted-foreground">
|
||||
未命中上方条件时进入:{summary?.targetName ?? (branch.targetNodeId || "未选择目标节点")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg bg-background p-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">判断变量</Label>
|
||||
<VariableSelector
|
||||
value={condition.left}
|
||||
variables={availableVariables}
|
||||
onChange={(value) => commitBranch(branch.id, {
|
||||
condition: { ...condition, left: value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">判断方式</Label>
|
||||
<OptionCombobox
|
||||
value={condition.operator ?? "eq"}
|
||||
options={operatorOptions}
|
||||
placeholder="选择判断方式"
|
||||
searchPlaceholder="搜索判断方式"
|
||||
emptyText="没有可用判断方式"
|
||||
onChange={(value) => commitBranch(branch.id, {
|
||||
condition: { ...condition, operator: value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{!conditionOperatorWithoutRight(condition.operator ?? "eq") ? (
|
||||
<ConditionRightControl
|
||||
value={conditionRight}
|
||||
variable={selectedVariable}
|
||||
onChange={(right) => commitBranch(branch.id, {
|
||||
condition: { ...condition, right },
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!branch.default && index > 0 ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => moveBranch(branch.id, -1)}>
|
||||
上移
|
||||
</Button>
|
||||
) : null}
|
||||
{!branch.default && index < branches.findIndex((item) => item.default) - 1 ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => moveBranch(branch.id, 1)}>
|
||||
下移
|
||||
</Button>
|
||||
) : null}
|
||||
{!branch.default ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => deleteBranch(branch.id)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-2 rounded-md bg-background/70 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{summary?.conditionLabel ?? "尚未完成分支配置"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
当前还没有分支。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{outputSchema.length > 0 ? (
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="text-sm font-semibold uppercase">输出变量</div>
|
||||
<div className="space-y-1 rounded-lg bg-muted/60 p-2">
|
||||
{outputSchema.map((output) => (
|
||||
<div key={output.name} className="space-y-0.5 rounded-sm px-1 py-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-medium">{output.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{output.type}</span>
|
||||
</div>
|
||||
{output.description ? (
|
||||
<div className="text-xs text-muted-foreground">{output.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === "condition" || branches.length > 0 ? (
|
||||
<ConditionBranchesEditor
|
||||
branches={branches}
|
||||
nodes={nodes}
|
||||
currentNodeId={node.id}
|
||||
variables={availableVariables ?? []}
|
||||
onAdd={addBranch}
|
||||
onChange={updateBranch}
|
||||
onDelete={deleteBranch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`node-config-${node.id}`}>配置 JSON</Label>
|
||||
<Textarea
|
||||
id={`node-config-${node.id}`}
|
||||
value={configText}
|
||||
className="min-h-36 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
setConfigText(next)
|
||||
try {
|
||||
const parsed = JSON.parse(next || "{}")
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
updateData({ config: parsed as Record<string, unknown> })
|
||||
}
|
||||
} catch {
|
||||
// The textarea keeps the draft while the user fixes invalid JSON.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{configError ? <div className="text-xs text-destructive">{configError}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const conditionOperators = [
|
||||
{ value: "eq", label: "等于" },
|
||||
{ value: "neq", label: "不等于" },
|
||||
{ value: "contains", label: "包含" },
|
||||
{ value: "exists", label: "存在" },
|
||||
{ value: "not_exists", label: "不存在" },
|
||||
{ value: "truthy", label: "为真" },
|
||||
{ value: "is_true", label: "为真" },
|
||||
{ value: "falsy", label: "为假" },
|
||||
{ value: "is_false", label: "为假" },
|
||||
{ value: "gt", label: "大于" },
|
||||
{ value: "gte", label: "大于等于" },
|
||||
{ value: "lt", label: "小于" },
|
||||
{ value: "lte", label: "小于等于" },
|
||||
]
|
||||
|
||||
function ConditionRightControl({
|
||||
value,
|
||||
variable,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
variable?: WorkflowVariableRef
|
||||
onChange: (value: unknown) => void
|
||||
}) {
|
||||
const valueOptions = getConditionValueOptions(variable)
|
||||
if (valueOptions.length > 0) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">比较值</Label>
|
||||
<OptionCombobox
|
||||
value={value}
|
||||
options={valueOptions}
|
||||
placeholder="选择比较值"
|
||||
searchPlaceholder="搜索比较值"
|
||||
emptyText="当前变量没有可选值"
|
||||
onChange={(nextValue) => onChange(decodeConditionRight(nextValue, variable))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">比较值</Label>
|
||||
<Input
|
||||
type={variable?.type === "number" || variable?.type === "integer" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(event) => onChange(normalizeConditionRight(event.target.value, variable))}
|
||||
placeholder={variable ? `请输入${variable.label || variable.field}的比较值` : "请输入比较值"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function conditionOperatorWithoutRight(operator: string) {
|
||||
return ["exists", "not_exists", "truthy", "is_true", "falsy", "is_false"].includes(operator)
|
||||
}
|
||||
|
||||
function normalizeConditionRight(value: string, variable?: WorkflowVariableRef) {
|
||||
const trimmed = value.trim()
|
||||
if (variable?.type === "boolean") {
|
||||
return trimmed === "true"
|
||||
}
|
||||
if (variable?.type === "number" || variable?.type === "integer") {
|
||||
return trimmed === "" ? "" : Number(trimmed)
|
||||
}
|
||||
if (variable?.type === "string") {
|
||||
return trimmed
|
||||
}
|
||||
if (trimmed === "true") return true
|
||||
if (trimmed === "false") return false
|
||||
if (trimmed !== "" && !Number.isNaN(Number(trimmed))) return Number(trimmed)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function findConditionVariable(
|
||||
variables: WorkflowVariableRef[],
|
||||
selector?: WorkflowVariableSelector
|
||||
): WorkflowVariableRef | undefined {
|
||||
if (!selector?.nodeId || !selector.field) {
|
||||
return undefined
|
||||
}
|
||||
return variables.find((item) => item.nodeId === selector.nodeId && item.field === selector.field)
|
||||
}
|
||||
|
||||
function getConditionOperatorOptions(variable?: WorkflowVariableRef) {
|
||||
if (!variable?.operators?.length) {
|
||||
return conditionOperators
|
||||
}
|
||||
const allowed = new Set(variable.operators)
|
||||
return conditionOperators.filter((item) => allowed.has(item.value))
|
||||
}
|
||||
|
||||
function getConditionValueOptions(variable?: WorkflowVariableRef) {
|
||||
if (variable?.valueOptions?.length) {
|
||||
return variable.valueOptions.map((item) => ({
|
||||
value: encodeConditionRight(item.value),
|
||||
label: item.label,
|
||||
function ConditionBranchesEditor({
|
||||
branches,
|
||||
nodes,
|
||||
currentNodeId,
|
||||
variables,
|
||||
onAdd,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: {
|
||||
branches: WorkflowConditionBranch[]
|
||||
nodes: AIWorkflowDefinition["nodes"]
|
||||
currentNodeId: string
|
||||
variables: WorkflowVariableRef[]
|
||||
onAdd: () => void
|
||||
onChange: (branch: WorkflowConditionBranch) => void
|
||||
onDelete: (branchId: string) => void
|
||||
}) {
|
||||
const targetOptions = nodes
|
||||
.filter((node) => node.id !== currentNodeId && node.type !== "start")
|
||||
.map((node) => ({
|
||||
value: node.id,
|
||||
label: node.data?.title || node.type || node.id,
|
||||
}))
|
||||
}
|
||||
if (variable?.type === "boolean") {
|
||||
return [
|
||||
{ value: "true", label: "是" },
|
||||
{ value: "false", label: "否" },
|
||||
]
|
||||
}
|
||||
return []
|
||||
const operatorOptions = [
|
||||
{ value: "eq", label: "等于" },
|
||||
{ value: "neq", label: "不等于" },
|
||||
{ value: "contains", label: "包含" },
|
||||
{ value: "not_contains", label: "不包含" },
|
||||
{ value: "gt", label: "大于" },
|
||||
{ value: "gte", label: "大于等于" },
|
||||
{ value: "lt", label: "小于" },
|
||||
{ value: "lte", label: "小于等于" },
|
||||
{ value: "exists", label: "存在" },
|
||||
{ value: "empty", label: "为空" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">条件分支</div>
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={onAdd}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{branches.length === 0 ? (
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
暂无分支。条件节点需要至少一个默认分支或条件分支。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{branches.map((branch) => {
|
||||
const condition = branch.condition ?? {}
|
||||
return (
|
||||
<div key={branch.id} className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input
|
||||
value={branch.name ?? ""}
|
||||
placeholder={branch.id}
|
||||
className="h-8"
|
||||
onChange={(event) => onChange({ ...branch, name: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(branch.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>目标节点</Label>
|
||||
<OptionCombobox
|
||||
value={branch.targetNodeId}
|
||||
options={targetOptions}
|
||||
placeholder="选择目标节点"
|
||||
onChange={(targetNodeId) => onChange({ ...branch, targetNodeId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branch.default === true}
|
||||
className="size-4"
|
||||
onChange={(event) => onChange({
|
||||
...branch,
|
||||
default: event.target.checked,
|
||||
condition: event.target.checked ? undefined : branch.condition,
|
||||
})}
|
||||
/>
|
||||
默认分支
|
||||
</label>
|
||||
|
||||
{branch.default ? null : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>左值</Label>
|
||||
<VariableSelector
|
||||
value={isRefValue(condition.left) ? condition.left : undefined}
|
||||
variables={variables}
|
||||
placeholder="选择变量"
|
||||
onChange={(left) => onChange({
|
||||
...branch,
|
||||
condition: { ...condition, left },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>操作符</Label>
|
||||
<OptionCombobox
|
||||
value={condition.operator ?? ""}
|
||||
options={operatorOptions}
|
||||
placeholder="选择操作符"
|
||||
onChange={(operator) => onChange({
|
||||
...branch,
|
||||
condition: { ...condition, operator },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("space-y-1.5", ["exists", "empty"].includes(condition.operator ?? "") && "opacity-50")}>
|
||||
<Label>右值</Label>
|
||||
<Input
|
||||
value={stringifyConditionRight(condition.right)}
|
||||
disabled={["exists", "empty"].includes(condition.operator ?? "")}
|
||||
onChange={(event) => onChange({
|
||||
...branch,
|
||||
condition: { ...condition, right: event.target.value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function encodeConditionRight(value: unknown) {
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
function stringifyConditionRight(value: unknown) {
|
||||
if (value === undefined || value === null) {
|
||||
return ""
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function decodeConditionRight(value: string, variable?: WorkflowVariableRef) {
|
||||
if (variable?.type === "boolean") {
|
||||
return value === "true"
|
||||
}
|
||||
if (variable?.type === "number" || variable?.type === "integer") {
|
||||
return Number(value)
|
||||
}
|
||||
const option = variable?.valueOptions?.find((item) => encodeConditionRight(item.value) === value)
|
||||
return option ? option.value : value
|
||||
}
|
||||
|
||||
function normalizeBranch(branch: WorkflowConditionBranch): WorkflowConditionBranch {
|
||||
if (branch.default) {
|
||||
const rest = { ...branch }
|
||||
delete rest.condition
|
||||
return rest
|
||||
}
|
||||
return {
|
||||
...branch,
|
||||
condition: branch.condition ?? { operator: "eq" },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import type { AIWorkflowDefinition } from "@/lib/api/admin"
|
||||
|
||||
type WorkflowDefinitionHistoryState = {
|
||||
present: AIWorkflowDefinition
|
||||
past: AIWorkflowDefinition[]
|
||||
future: AIWorkflowDefinition[]
|
||||
revision: number
|
||||
}
|
||||
|
||||
export function useWorkflowDefinitionHistory(initialDefinition: AIWorkflowDefinition) {
|
||||
const [state, setState] = useState<WorkflowDefinitionHistoryState>({
|
||||
present: initialDefinition,
|
||||
past: [],
|
||||
future: [],
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
const replace = useCallback((definition: AIWorkflowDefinition) => {
|
||||
setState((current) => ({
|
||||
present: definition,
|
||||
past: [],
|
||||
future: [],
|
||||
revision: current.revision + 1,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const update = useCallback((definition: AIWorkflowDefinition) => {
|
||||
setState((current) => {
|
||||
if (sameWorkflowDefinition(current.present, definition)) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
present: definition,
|
||||
past: [...current.past.slice(-49), current.present],
|
||||
future: [],
|
||||
revision: current.revision,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setState((current) => {
|
||||
const previous = current.past[current.past.length - 1]
|
||||
if (!previous) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
present: previous,
|
||||
past: current.past.slice(0, -1),
|
||||
future: [current.present, ...current.future.slice(0, 49)],
|
||||
revision: current.revision + 1,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setState((current) => {
|
||||
const next = current.future[0]
|
||||
if (!next) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
present: next,
|
||||
past: [...current.past.slice(-49), current.present],
|
||||
future: current.future.slice(1),
|
||||
revision: current.revision + 1,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
definition: state.present,
|
||||
revision: state.revision,
|
||||
canUndo: state.past.length > 0,
|
||||
canRedo: state.future.length > 0,
|
||||
replace,
|
||||
update,
|
||||
undo,
|
||||
redo,
|
||||
}
|
||||
}
|
||||
|
||||
function sameWorkflowDefinition(left: AIWorkflowDefinition, right: AIWorkflowDefinition) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
@@ -2,33 +2,41 @@
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
|
||||
import type { WorkflowVariableRef, WorkflowVariableSelector } from "./workflow-utils"
|
||||
import {
|
||||
createRefValue,
|
||||
refField,
|
||||
refNodeId,
|
||||
type WorkflowVariableRef,
|
||||
type WorkflowVariableSelector,
|
||||
} from "./workflow-utils"
|
||||
|
||||
export function VariableSelector({
|
||||
value,
|
||||
variables,
|
||||
onChange,
|
||||
placeholder = "选择变量",
|
||||
}: {
|
||||
value?: WorkflowVariableSelector
|
||||
variables: WorkflowVariableRef[]
|
||||
onChange: (value: WorkflowVariableSelector) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const options = variables.map((item) => ({
|
||||
value: `${item.nodeId}.${item.field}`,
|
||||
label: `${item.nodeName}.${item.label || item.field} · ${item.type}`,
|
||||
const selected = value ? `${refNodeId(value)}.${refField(value)}` : ""
|
||||
const options = variables.map((variable) => ({
|
||||
value: `${variable.nodeId}.${variable.field}`,
|
||||
label: `${variable.nodeName}.${variable.label || variable.field}`,
|
||||
}))
|
||||
const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
|
||||
|
||||
return (
|
||||
<OptionCombobox
|
||||
value={selectedValue}
|
||||
value={selected}
|
||||
options={options}
|
||||
placeholder="选择变量"
|
||||
searchPlaceholder="搜索变量"
|
||||
emptyText="没有可用上游变量"
|
||||
onChange={(nextValue) => {
|
||||
const [nodeId, ...fieldParts] = nextValue.split(".")
|
||||
onChange({ nodeId, field: fieldParts.join(".") })
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => {
|
||||
const variable = variables.find((item) => `${item.nodeId}.${item.field}` === next)
|
||||
if (variable) {
|
||||
onChange(createRefValue(variable.nodeId, variable.field))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { NodeConfigPanel } from "./node-config-panel"
|
||||
import {
|
||||
getAvailableVariables,
|
||||
getNodeTitle,
|
||||
type WorkflowNodeData,
|
||||
} from "./workflow-utils"
|
||||
|
||||
export function WorkflowConfigSidebar({
|
||||
definition,
|
||||
nodeSpecs,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
onChangeNodeData,
|
||||
onDeleteNode,
|
||||
}: {
|
||||
definition: AIWorkflowDefinition
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
selectedNodeId: string
|
||||
onSelectNode: (nodeId: string) => void
|
||||
onChangeNodeData: (nodeId: string, data: WorkflowNodeData) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
}) {
|
||||
const selectedNode = definition.nodes.find((node) => node.id === selectedNodeId) ?? null
|
||||
const selectedNodeSpec = selectedNode
|
||||
? nodeSpecs.find((spec) => spec.type === selectedNode.type)
|
||||
: undefined
|
||||
const availableVariables = selectedNode
|
||||
? getAvailableVariables(definition, selectedNode.id, nodeSpecs)
|
||||
: []
|
||||
|
||||
return (
|
||||
<aside className="flex w-80 shrink-0 flex-col border-l bg-background">
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-sm font-medium">配置</div>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border-b p-2">
|
||||
<div className="space-y-1">
|
||||
{definition.nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted",
|
||||
selectedNodeId === node.id && "bg-muted"
|
||||
)}
|
||||
onClick={() => onSelectNode(node.id)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{getNodeTitle(node, nodeSpecs)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{node.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeSpec={selectedNodeSpec}
|
||||
nodes={definition.nodes}
|
||||
availableVariables={availableVariables}
|
||||
onChange={onChangeNodeData}
|
||||
onDelete={onDeleteNode}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
RotateCcwIcon,
|
||||
SaveIcon,
|
||||
SendIcon,
|
||||
Undo2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { WorkflowDraftValidation } from "./workflow-utils"
|
||||
|
||||
export function WorkflowEditorToolbar({
|
||||
validation,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
toolbarExtra,
|
||||
onUndo,
|
||||
undoDisabled = false,
|
||||
onRedo,
|
||||
redoDisabled = false,
|
||||
onRestoreDefault,
|
||||
restoreDefaultDisabled = false,
|
||||
onValidate,
|
||||
validateDisabled = false,
|
||||
onSaveDraft,
|
||||
saveDraftDisabled = false,
|
||||
onPublish,
|
||||
publishDisabled = false,
|
||||
}: {
|
||||
validation: WorkflowDraftValidation
|
||||
nodeCount: number
|
||||
edgeCount: number
|
||||
toolbarExtra?: ReactNode
|
||||
onUndo?: () => void
|
||||
undoDisabled?: boolean
|
||||
onRedo?: () => void
|
||||
redoDisabled?: boolean
|
||||
onRestoreDefault?: () => void
|
||||
restoreDefaultDisabled?: boolean
|
||||
onValidate?: () => void
|
||||
validateDisabled?: boolean
|
||||
onSaveDraft?: () => void
|
||||
saveDraftDisabled?: boolean
|
||||
onPublish?: () => void
|
||||
publishDisabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b bg-background px-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs",
|
||||
validation.valid ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
|
||||
)}
|
||||
>
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{validation.valid ? "本地检查通过" : `${validation.errors.length} 个本地问题`}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{nodeCount} 个节点 / {edgeCount} 条连线
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{toolbarExtra}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={undoDisabled} onClick={onUndo}>
|
||||
<Undo2Icon className="size-3.5" />
|
||||
撤销
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={redoDisabled} onClick={onRedo}>
|
||||
重做
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={restoreDefaultDisabled} onClick={onRestoreDefault}>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={validateDisabled} onClick={onValidate}>
|
||||
检查
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={saveDraftDisabled} onClick={onSaveDraft}>
|
||||
<SaveIcon className="size-3.5" />
|
||||
保存
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="h-7 px-2 text-xs" disabled={publishDisabled} onClick={onPublish}>
|
||||
<SendIcon className="size-3.5" />
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
|
||||
export function WorkflowNodePalette({
|
||||
nodeSpecs,
|
||||
onAddNode,
|
||||
}: {
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
onAddNode: (spec: AIWorkflowNodeSpec) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r bg-muted/20">
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-sm font-medium">节点</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<div className="space-y-1">
|
||||
{nodeSpecs.map((spec) => (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 rounded-md border bg-background px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => onAddNode(spec)}
|
||||
>
|
||||
<PlusIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{spec.title || spec.type}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{spec.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
@@ -26,12 +26,49 @@ async function loadModule() {
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
describe("validateWorkflowDraft", () => {
|
||||
it("rejects missing start", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
function workflowNode(id, type, position = { x: 0, y: 0 }, data = {}) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
meta: { position },
|
||||
data: {
|
||||
title: type,
|
||||
config: {},
|
||||
inputsValues: {},
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const result = validateWorkflowDraft({
|
||||
nodes: [{ id: "end_1", type: "end", position: { x: 0, y: 0 }, data: {} }],
|
||||
function workflowEdge(sourceNodeID, targetNodeID, extra = {}) {
|
||||
return {
|
||||
sourceNodeID,
|
||||
targetNodeID,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe("FlowGram value helpers", () => {
|
||||
it("creates and reads reference values", async () => {
|
||||
const { createRefValue, isRefValue, refField, refNodeId } = await loadModule()
|
||||
|
||||
const value = createRefValue("start_1", "userMessage")
|
||||
|
||||
assert.deepEqual(plain(value), { type: "ref", content: ["start_1", "userMessage"] })
|
||||
assert.equal(isRefValue(value), true)
|
||||
assert.equal(refNodeId(value), "start_1")
|
||||
assert.equal(refField(value), "userMessage")
|
||||
assert.equal(isRefValue({ type: "constant", content: "hello" }), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateWorkflowDefinition", () => {
|
||||
it("rejects a workflow without exactly one start node", async () => {
|
||||
const { validateWorkflowDefinition } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDefinition({
|
||||
schemaVersion: 2,
|
||||
nodes: [workflowNode("end_1", "end")],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
@@ -39,35 +76,59 @@ describe("validateWorkflowDraft", () => {
|
||||
assert.match(result.errors.join("\n"), /exactly one start/)
|
||||
})
|
||||
|
||||
it("rejects dangling edge", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
it("rejects dangling FlowGram edges", async () => {
|
||||
const { validateWorkflowDefinition } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft({
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "end_1", type: "end", position: { x: 200, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "missing_1" }],
|
||||
const result = validateWorkflowDefinition({
|
||||
schemaVersion: 2,
|
||||
nodes: [workflowNode("start_1", "start"), workflowNode("end_1", "end")],
|
||||
edges: [workflowEdge("start_1", "missing_1")],
|
||||
})
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /target node does not exist/)
|
||||
assert.match(result.errors.join("\n"), /target node does not exist: missing_1/)
|
||||
})
|
||||
|
||||
it("rejects missing required input mapping", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
it("rejects missing required inputs from node specs", async () => {
|
||||
const { validateWorkflowDefinition } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft(
|
||||
const result = validateWorkflowDefinition(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
|
||||
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
|
||||
workflowNode("start_1", "start"),
|
||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, { title: "发送回复" }),
|
||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start_1", target: "reply_1" },
|
||||
{ id: "e2", source: "reply_1", target: "end_1" },
|
||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "send_reply",
|
||||
title: "发送回复",
|
||||
inputSchema: [{ name: "replyText", label: "回复内容", type: "string", required: true }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /发送回复 missing required input: 回复内容/)
|
||||
})
|
||||
|
||||
it("accepts a valid schema v2 workflow", async () => {
|
||||
const { createRefValue, validateWorkflowDefinition } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDefinition(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
workflowNode("start_1", "start"),
|
||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, {
|
||||
inputsValues: { replyText: createRefValue("start_1", "userMessage") },
|
||||
}),
|
||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
||||
],
|
||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
||||
},
|
||||
[
|
||||
{
|
||||
@@ -77,129 +138,12 @@ describe("validateWorkflowDraft", () => {
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
|
||||
})
|
||||
|
||||
it("rejects condition branch target without matching branch handle edge", async () => {
|
||||
const { getConditionBranchHandleId, validateWorkflowDraft } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft({
|
||||
nodes: [
|
||||
{ id: "start_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "start" } },
|
||||
{
|
||||
id: "condition_1",
|
||||
type: "workflowNode",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {
|
||||
nodeType: "condition",
|
||||
name: "Route",
|
||||
config: {
|
||||
branches: [
|
||||
{
|
||||
id: "direct",
|
||||
name: "Direct",
|
||||
targetNodeId: "send_1",
|
||||
condition: {
|
||||
left: { nodeId: "start_1", field: "userMessage" },
|
||||
operator: "eq",
|
||||
right: "hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Else",
|
||||
targetNodeId: "send_1",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "send_1", type: "workflowNode", position: { x: 400, y: 0 }, data: { nodeType: "send_reply" } },
|
||||
{ id: "end_1", type: "workflowNode", position: { x: 600, y: 0 }, data: { nodeType: "end" } },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start_1", target: "condition_1" },
|
||||
{
|
||||
id: "e2",
|
||||
source: "condition_1",
|
||||
target: "send_1",
|
||||
sourceHandle: getConditionBranchHandleId("default"),
|
||||
},
|
||||
{ id: "e3", source: "send_1", target: "end_1" },
|
||||
],
|
||||
})
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /对应分支连接点/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyAutoInputMappings", () => {
|
||||
it("maps start user message to knowledge retrieve query", async () => {
|
||||
const { applyAutoInputMappings } = await loadModule()
|
||||
|
||||
const draft = applyAutoInputMappings(
|
||||
{
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
|
||||
},
|
||||
"start_1",
|
||||
"retrieve_1",
|
||||
[
|
||||
{
|
||||
type: "start",
|
||||
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieve",
|
||||
inputSchema: [{ name: "query", type: "string", required: true }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
|
||||
query: { nodeId: "start_1", field: "userMessage" },
|
||||
})
|
||||
})
|
||||
|
||||
it("maps llm reply text to send reply content", async () => {
|
||||
const { applyAutoInputMappings } = await loadModule()
|
||||
|
||||
const draft = applyAutoInputMappings(
|
||||
{
|
||||
nodes: [
|
||||
{ id: "llm_1", type: "llm_reply", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "send_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [{ id: "e1", source: "llm_1", target: "send_1" }],
|
||||
},
|
||||
"llm_1",
|
||||
"send_1",
|
||||
[
|
||||
{
|
||||
type: "llm_reply",
|
||||
outputSchema: [{ name: "replyText", type: "string", description: "Reply" }],
|
||||
},
|
||||
{
|
||||
type: "send_reply",
|
||||
inputSchema: [{ name: "replyText", type: "string", required: true }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
|
||||
replyText: { nodeId: "llm_1", field: "replyText" },
|
||||
})
|
||||
assert.deepEqual(plain(result), { valid: true, errors: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("createWorkflowNodeFromSpec", () => {
|
||||
it("creates node at dropped canvas position with unique id", async () => {
|
||||
it("creates a FlowGram schema v2 node with default inputs", async () => {
|
||||
const { createWorkflowNodeFromSpec } = await loadModule()
|
||||
|
||||
const node = createWorkflowNodeFromSpec(
|
||||
@@ -207,129 +151,56 @@ describe("createWorkflowNodeFromSpec", () => {
|
||||
type: "llm_reply",
|
||||
title: "AI 回复",
|
||||
defaultInputs: {
|
||||
userMessage: { nodeId: "start_1", field: "userMessage" },
|
||||
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
|
||||
},
|
||||
},
|
||||
[
|
||||
{ id: "llm_reply_1", type: "workflowNode", position: { x: 0, y: 0 }, data: {} },
|
||||
],
|
||||
[{ id: "llm_reply_1" }],
|
||||
{ x: 120, y: 240 }
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(node), {
|
||||
id: "llm_reply_2",
|
||||
type: "workflowNode",
|
||||
position: { x: 120, y: 240 },
|
||||
type: "llm_reply",
|
||||
meta: { position: { x: 120, y: 240 } },
|
||||
data: {
|
||||
nodeType: "llm_reply",
|
||||
name: "AI 回复",
|
||||
label: "AI 回复",
|
||||
title: "AI 回复",
|
||||
config: {},
|
||||
inputs: {
|
||||
userMessage: { nodeId: "start_1", field: "userMessage" },
|
||||
inputsValues: {
|
||||
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateWorkflowHelperLines", () => {
|
||||
it("snaps dragged node to a nearby horizontal alignment", async () => {
|
||||
const { calculateWorkflowHelperLines } = await loadModule()
|
||||
|
||||
const result = calculateWorkflowHelperLines(
|
||||
[
|
||||
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
|
||||
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 },
|
||||
],
|
||||
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 }
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(result), {
|
||||
position: { x: 392, y: 120 },
|
||||
horizontal: { y: 120, left: 100, width: 512 },
|
||||
})
|
||||
})
|
||||
|
||||
it("does not show helper lines outside the alignment threshold", async () => {
|
||||
const { calculateWorkflowHelperLines } = await loadModule()
|
||||
|
||||
const result = calculateWorkflowHelperLines(
|
||||
[
|
||||
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
|
||||
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 },
|
||||
],
|
||||
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 }
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(result), {
|
||||
position: { x: 392, y: 132 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("workflow history", () => {
|
||||
it("undoes and redoes snapshots while clearing redo after a new edit", async () => {
|
||||
const {
|
||||
createWorkflowHistory,
|
||||
pushWorkflowHistory,
|
||||
undoWorkflowHistory,
|
||||
redoWorkflowHistory,
|
||||
} = await loadModule()
|
||||
|
||||
const first = {
|
||||
nodes: [{ id: "start_1", position: { x: 0, y: 0 } }],
|
||||
edges: [],
|
||||
}
|
||||
const second = {
|
||||
nodes: [{ id: "start_1", position: { x: 100, y: 0 } }],
|
||||
edges: [],
|
||||
}
|
||||
const third = {
|
||||
nodes: [{ id: "start_1", position: { x: 200, y: 0 } }],
|
||||
edges: [],
|
||||
}
|
||||
const branch = {
|
||||
nodes: [{ id: "start_1", position: { x: 300, y: 0 } }],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
let history = createWorkflowHistory()
|
||||
history = pushWorkflowHistory(history, first)
|
||||
history = pushWorkflowHistory(history, second)
|
||||
|
||||
const undone = undoWorkflowHistory(history, third)
|
||||
assert.deepEqual(plain(undone.snapshot), second)
|
||||
assert.equal(undone.history.past.length, 1)
|
||||
assert.equal(undone.history.future.length, 1)
|
||||
|
||||
const redone = redoWorkflowHistory(undone.history, undone.snapshot)
|
||||
assert.deepEqual(plain(redone.snapshot), third)
|
||||
assert.equal(redone.history.past.length, 2)
|
||||
assert.equal(redone.history.future.length, 0)
|
||||
|
||||
const branched = pushWorkflowHistory(undone.history, branch)
|
||||
assert.equal(branched.future.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableVariables", () => {
|
||||
it("exposes start outputs to retrieve node", async () => {
|
||||
it("returns upstream output variables in dependency order", async () => {
|
||||
const { getAvailableVariables } = await loadModule()
|
||||
|
||||
const variables = getAvailableVariables(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: { name: "Start" } },
|
||||
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
|
||||
workflowNode("start_1", "start", { x: 0, y: 0 }, { title: "开始" }),
|
||||
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, { title: "知识检索" }),
|
||||
workflowNode("reply_1", "llm_reply", { x: 480, y: 0 }, { title: "AI 回复" }),
|
||||
workflowNode("end_1", "end", { x: 720, y: 0 }),
|
||||
],
|
||||
edges: [
|
||||
workflowEdge("start_1", "retrieve_1"),
|
||||
workflowEdge("retrieve_1", "reply_1"),
|
||||
workflowEdge("reply_1", "end_1"),
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
|
||||
},
|
||||
"retrieve_1",
|
||||
"reply_1",
|
||||
[
|
||||
{
|
||||
type: "start",
|
||||
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
|
||||
outputSchema: [{ name: "userMessage", label: "用户消息", type: "string", description: "input" }],
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieve",
|
||||
outputSchema: [{ name: "documents", label: "文档", type: "array<object>", description: "docs" }],
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -337,318 +208,99 @@ describe("getAvailableVariables", () => {
|
||||
assert.deepEqual(plain(variables), [
|
||||
{
|
||||
nodeId: "start_1",
|
||||
nodeName: "Start",
|
||||
nodeName: "开始",
|
||||
field: "userMessage",
|
||||
label: "用户消息",
|
||||
type: "string",
|
||||
description: "Message",
|
||||
description: "input",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("hides variables from downstream nodes", async () => {
|
||||
const { getAvailableVariables } = await loadModule()
|
||||
|
||||
const variables = getAvailableVariables(
|
||||
{
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
|
||||
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start_1", target: "reply_1" },
|
||||
{ id: "e2", source: "reply_1", target: "end_1" },
|
||||
],
|
||||
},
|
||||
"reply_1",
|
||||
[
|
||||
{
|
||||
type: "end",
|
||||
outputSchema: [{ name: "status", type: "string", description: "Status" }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(variables), [])
|
||||
})
|
||||
|
||||
it("preserves condition editor metadata from output specs", async () => {
|
||||
const { getAvailableVariables } = await loadModule()
|
||||
|
||||
const variables = getAvailableVariables(
|
||||
{
|
||||
nodes: [
|
||||
{ id: "policy_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "reply_policy", name: "回复策略" } },
|
||||
{ id: "condition_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "condition" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "policy_1", target: "condition_1" }],
|
||||
},
|
||||
"condition_1",
|
||||
[
|
||||
{
|
||||
type: "reply_policy",
|
||||
outputSchema: [
|
||||
{
|
||||
name: "action",
|
||||
label: "处理策略",
|
||||
type: "string",
|
||||
description: "Selected policy action.",
|
||||
operators: ["eq", "neq"],
|
||||
valueOptions: [{ value: "direct_reply", label: "直接回复客户" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(variables), [
|
||||
{
|
||||
nodeId: "policy_1",
|
||||
nodeName: "回复策略",
|
||||
field: "action",
|
||||
label: "处理策略",
|
||||
type: "string",
|
||||
description: "Selected policy action.",
|
||||
operators: ["eq", "neq"],
|
||||
valueOptions: [{ value: "direct_reply", label: "直接回复客户" }],
|
||||
nodeId: "retrieve_1",
|
||||
nodeName: "知识检索",
|
||||
field: "documents",
|
||||
label: "文档",
|
||||
type: "array<object>",
|
||||
description: "docs",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toApiDefinition", () => {
|
||||
it("updates condition branch target from branch handle connection", async () => {
|
||||
const { applyConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
|
||||
|
||||
const draft = applyConditionBranchConnection(
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
id: "condition_1",
|
||||
type: "workflowNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
nodeType: "condition",
|
||||
config: {
|
||||
branches: [
|
||||
{ id: "direct", name: "Direct", targetNodeId: "", condition: { operator: "eq" } },
|
||||
{ id: "default", name: "Else", targetNodeId: "", default: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "send_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "send_reply" } },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
{
|
||||
source: "condition_1",
|
||||
target: "send_1",
|
||||
sourceHandle: getConditionBranchHandleId("direct"),
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "send_1")
|
||||
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "")
|
||||
})
|
||||
|
||||
it("clears condition branch target when the branch edge is removed", async () => {
|
||||
const { clearConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
|
||||
|
||||
const draft = clearConditionBranchConnection(
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
id: "condition_1",
|
||||
type: "workflowNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
nodeType: "condition",
|
||||
config: {
|
||||
branches: [
|
||||
{ id: "direct", name: "Direct", targetNodeId: "send_1", condition: { operator: "eq" } },
|
||||
{ id: "default", name: "Else", targetNodeId: "fallback_1", default: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
{
|
||||
id: "edge_condition_send",
|
||||
source: "condition_1",
|
||||
target: "send_1",
|
||||
sourceHandle: getConditionBranchHandleId("direct"),
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "")
|
||||
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "fallback_1")
|
||||
})
|
||||
|
||||
it("keeps condition branches on the condition node config and exports plain edges", async () => {
|
||||
const { toApiDefinition } = await loadModule()
|
||||
|
||||
const definition = toApiDefinition({
|
||||
describe("workflow definition mutations", () => {
|
||||
it("updates node data without changing unrelated nodes", async () => {
|
||||
const { updateWorkflowNodeData } = await loadModule()
|
||||
const definition = {
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "workflowNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { nodeType: "start", name: "Start", config: {} },
|
||||
},
|
||||
{
|
||||
id: "condition_1",
|
||||
type: "workflowNode",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {
|
||||
nodeType: "condition",
|
||||
name: "Route",
|
||||
config: {
|
||||
branches: [
|
||||
{
|
||||
id: "vip",
|
||||
name: "VIP",
|
||||
targetNodeId: "vip_reply",
|
||||
condition: {
|
||||
left: { nodeId: "start_1", field: "userMessage" },
|
||||
operator: "eq",
|
||||
right: "vip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
targetNodeId: "normal_reply",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vip_reply",
|
||||
type: "workflowNode",
|
||||
position: { x: 400, y: 0 },
|
||||
data: { nodeType: "llm_reply", name: "VIP", config: {} },
|
||||
},
|
||||
{
|
||||
id: "normal_reply",
|
||||
type: "workflowNode",
|
||||
position: { x: 400, y: 160 },
|
||||
data: { nodeType: "llm_reply", name: "Normal", config: {} },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start_1", target: "condition_1" },
|
||||
{
|
||||
id: "e2",
|
||||
source: "condition_1",
|
||||
target: "vip_reply",
|
||||
data: {
|
||||
condition: {
|
||||
left: { nodeId: "start_1", field: "userMessage" },
|
||||
operator: "eq",
|
||||
right: "legacy",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "e3", source: "condition_1", target: "normal_reply" },
|
||||
workflowNode("start_1", "start"),
|
||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
|
||||
],
|
||||
edges: [workflowEdge("start_1", "reply_1")],
|
||||
}
|
||||
|
||||
const next = updateWorkflowNodeData(definition, "reply_1", {
|
||||
title: "发送回复",
|
||||
config: { staticReply: "hello" },
|
||||
inputsValues: {},
|
||||
})
|
||||
|
||||
assert.deepEqual(plain(definition.edges), [
|
||||
{ id: "e1", source: "start_1", target: "condition_1" },
|
||||
{ id: "e2", source: "condition_1", target: "vip_reply" },
|
||||
{ id: "e3", source: "condition_1", target: "normal_reply" },
|
||||
])
|
||||
assert.deepEqual(plain(definition.nodes[1].config.branches), [
|
||||
{
|
||||
id: "vip",
|
||||
name: "VIP",
|
||||
targetNodeId: "vip_reply",
|
||||
condition: {
|
||||
left: { nodeId: "start_1", field: "userMessage" },
|
||||
operator: "eq",
|
||||
right: "vip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
targetNodeId: "normal_reply",
|
||||
default: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves xyflow node positions", async () => {
|
||||
const { toApiDefinition } = await loadModule()
|
||||
|
||||
const definition = toApiDefinition({
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
position: { x: 12, y: 34 },
|
||||
data: { name: "Start", config: { enabled: true } },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
position: { x: 240, y: 80 },
|
||||
data: { name: "End", config: {} },
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
})
|
||||
|
||||
assert.deepEqual(plain(definition), {
|
||||
schemaVersion: 1,
|
||||
entryNodeId: "start_1",
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
name: "Start",
|
||||
position: { x: 12, y: 34 },
|
||||
config: { enabled: true },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
name: "End",
|
||||
position: { x: 240, y: 80 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
assert.equal(next.nodes[0].data.title, "start")
|
||||
assert.deepEqual(plain(next.nodes[1].data), {
|
||||
title: "发送回复",
|
||||
config: { staticReply: "hello" },
|
||||
inputsValues: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("uses node data type for xyflow default nodes", async () => {
|
||||
const { toApiDefinition } = await loadModule()
|
||||
|
||||
const definition = toApiDefinition({
|
||||
it("deletes normal nodes and related edges while keeping start and end protected", async () => {
|
||||
const { deleteWorkflowNode } = await loadModule()
|
||||
const definition = {
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { nodeType: "start", name: "Start", config: {} },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "default",
|
||||
position: { x: 200, y: 0 },
|
||||
data: { nodeType: "end", name: "End", config: {} },
|
||||
},
|
||||
workflowNode("start_1", "start"),
|
||||
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
|
||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
|
||||
}
|
||||
|
||||
const next = deleteWorkflowNode(definition, "reply_1")
|
||||
assert.deepEqual(next.nodes.map((node) => node.id), ["start_1", "end_1"])
|
||||
assert.deepEqual(next.edges, [])
|
||||
|
||||
const protectedDefinition = deleteWorkflowNode(definition, "start_1")
|
||||
assert.deepEqual(protectedDefinition, definition)
|
||||
})
|
||||
|
||||
it("upserts and deletes condition branches in node config", async () => {
|
||||
const { deleteConditionBranch, upsertConditionBranch } = await loadModule()
|
||||
const definition = {
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
|
||||
config: {
|
||||
branches: [{ id: "default", name: "默认", targetNodeId: "end_1", default: true }],
|
||||
},
|
||||
}),
|
||||
workflowNode("end_1", "end", { x: 480, y: 0 }),
|
||||
],
|
||||
edges: [workflowEdge("condition_1", "end_1")],
|
||||
}
|
||||
|
||||
const updated = upsertConditionBranch(definition, "condition_1", {
|
||||
id: "vip",
|
||||
name: "VIP",
|
||||
targetNodeId: "end_1",
|
||||
condition: {
|
||||
left: { type: "ref", content: ["start_1", "priority"] },
|
||||
operator: "eq",
|
||||
right: "vip",
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(definition.entryNodeId, "start_1")
|
||||
assert.equal(definition.nodes[0].type, "start")
|
||||
assert.deepEqual(plain(updated.nodes[0].data.config.branches.map((branch) => branch.id)), ["default", "vip"])
|
||||
|
||||
const deleted = deleteConditionBranch(updated, "condition_1", "default")
|
||||
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["vip"])
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-16
@@ -294,10 +294,19 @@ export type AIWorkflowVariableType =
|
||||
| "array<object>"
|
||||
| "any"
|
||||
|
||||
export type AIWorkflowVariableSelector = {
|
||||
nodeId: string
|
||||
field: string
|
||||
}
|
||||
export type AIWorkflowValue =
|
||||
| {
|
||||
type: "ref"
|
||||
content: [string, string]
|
||||
}
|
||||
| {
|
||||
type: "constant"
|
||||
content?: unknown
|
||||
}
|
||||
| {
|
||||
type: "template"
|
||||
content?: string
|
||||
}
|
||||
|
||||
export type AIWorkflowVariableSpec = {
|
||||
name: string
|
||||
@@ -315,21 +324,28 @@ export type AIWorkflowVariableSpec = {
|
||||
|
||||
export type AIWorkflowDefinition = {
|
||||
schemaVersion: number
|
||||
entryNodeId: string
|
||||
nodes: {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
position: AIWorkflowPosition
|
||||
config: Record<string, unknown>
|
||||
inputs?: Record<string, AIWorkflowVariableSelector>
|
||||
meta: {
|
||||
position: AIWorkflowPosition
|
||||
}
|
||||
data: {
|
||||
title?: string
|
||||
config?: Record<string, unknown>
|
||||
inputs?: unknown
|
||||
outputs?: unknown
|
||||
inputsValues?: Record<string, AIWorkflowValue>
|
||||
[key: string]: unknown
|
||||
}
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}[]
|
||||
}
|
||||
edges: {
|
||||
sourceNodeID: string
|
||||
targetNodeID: string
|
||||
sourcePortID?: string
|
||||
targetPortID?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type AIWorkflow = {
|
||||
id: number
|
||||
@@ -370,7 +386,7 @@ export type AIWorkflowNodeSpec = {
|
||||
configSchema?: unknown
|
||||
inputSchema?: AIWorkflowVariableSpec[]
|
||||
outputSchema?: AIWorkflowVariableSpec[]
|
||||
defaultInputs?: Record<string, AIWorkflowVariableSelector>
|
||||
defaultInputs?: Record<string, AIWorkflowValue>
|
||||
}
|
||||
|
||||
export type AIWorkflowValidationResult = {
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function nextConfig(phase: string): NextConfig {
|
||||
assetPrefix: `${productionBasePath}/`,
|
||||
trailingSlash: false,
|
||||
devIndicators: false,
|
||||
reactStrictMode: false,
|
||||
}
|
||||
|
||||
if (phase !== PHASE_DEVELOPMENT_SERVER) {
|
||||
|
||||
+4
-1
@@ -16,6 +16,9 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@flowgram.ai/free-layout-editor": "1.0.11",
|
||||
"@flowgram.ai/free-snap-plugin": "1.0.11",
|
||||
"@flowgram.ai/minimap-plugin": "1.0.11",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-image": "^3.20.2",
|
||||
@@ -25,7 +28,6 @@
|
||||
"@tiptap/react": "^3.20.2",
|
||||
"@tiptap/starter-kit": "^3.20.2",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.41",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -45,6 +47,7 @@
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^4.0.7",
|
||||
"sonner": "^2.0.7",
|
||||
"styled-components": "^6.4.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"turndown": "^7.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
Generated
+768
-146
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user