feat: Enhance AI Workflow with Variable Contracts and Input Mapping
- Added ConfigSchema, InputSchema, OutputSchema, and DefaultInputs to AIWorkflowNodeSpecResponse. - Implemented BuildAIWorkflowNodeSpecs to include variable contracts for start and send_reply nodes. - Introduced applyAutoInputMappings to automatically map inputs based on node connections. - Enhanced validation to check for required input mappings in workflows. - Updated workflow editor to support variable selection for node inputs. - Translated node names and labels to Chinese for better localization. - Added tests for variable mapping and validation logic.
This commit is contained in:
@@ -10,11 +10,12 @@ type Definition struct {
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Position Position `json:"position"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
@@ -30,5 +31,13 @@ type Edge struct {
|
||||
}
|
||||
|
||||
type Condition struct {
|
||||
Expression string `json:"expression"`
|
||||
Expression string `json:"expression,omitempty"`
|
||||
Left *VariableSelector `json:"left,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
type VariableSelector struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package registry
|
||||
|
||||
import "agent-desk/internal/ai/workflow/dsl"
|
||||
|
||||
const (
|
||||
NodeTypeStart = "start"
|
||||
NodeTypeKnowledgeRetrieve = "knowledge_retrieve"
|
||||
@@ -17,17 +19,173 @@ const (
|
||||
|
||||
func DefaultRegistry() *Registry {
|
||||
return NewRegistry(
|
||||
NodeSpec{Type: NodeTypeStart, Title: "Start", Description: "Conversation workflow entry.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypeKnowledgeRetrieve, Title: "Knowledge Retrieve", Description: "Retrieve knowledge for the current user message.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypeAnswerabilityGate, Title: "Answerability Gate", Description: "Decide whether retrieved knowledge is enough to answer.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypeLLMReply, Title: "LLM Reply", Description: "Generate a reply or structured analysis with the configured model.", RiskLevel: NodeRiskLevelMedium},
|
||||
NodeSpec{Type: NodeTypeCondition, Title: "Condition", Description: "Route by controlled workflow variables.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypeAnalyzeConversation, Title: "Analyze Conversation", Description: "Analyze intent, risk, and recommended next action.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypePrepareTicketDraft, Title: "Prepare Ticket Draft", Description: "Build a ticket draft from conversation context.", RiskLevel: NodeRiskLevelMedium},
|
||||
NodeSpec{Type: NodeTypeHumanConfirm, Title: "Human Confirm", Description: "Interrupt and wait for explicit user confirmation.", RiskLevel: NodeRiskLevelMedium, Interruptible: true},
|
||||
NodeSpec{Type: NodeTypeCreateTicket, Title: "Create Ticket", Description: "Create a ticket from a confirmed draft.", RiskLevel: NodeRiskLevelHigh, RequiresConfirmationPredecessor: true},
|
||||
NodeSpec{Type: NodeTypeHandoffToHuman, Title: "Handoff To Human", Description: "Transfer the conversation to human support.", RiskLevel: NodeRiskLevelHigh, RequiresConfirmationPredecessor: true},
|
||||
NodeSpec{Type: NodeTypeSendReply, Title: "Send Reply", Description: "Return or commit customer-visible reply text.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{Type: NodeTypeEnd, Title: "End", Description: "End workflow execution.", RiskLevel: NodeRiskLevelLow},
|
||||
NodeSpec{
|
||||
Type: NodeTypeStart,
|
||||
Title: "Start",
|
||||
Description: "Conversation workflow entry.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
OutputSchema: []VariableSpec{
|
||||
output("conversationId", VariableTypeInteger, "Conversation ID."),
|
||||
output("messageId", VariableTypeInteger, "Current user message ID."),
|
||||
output("aiAgentId", VariableTypeInteger, "AI Agent ID."),
|
||||
output("userMessage", VariableTypeString, "Current user message content."),
|
||||
output("knowledgeBaseIds", VariableTypeIntegerArray, "Knowledge bases bound to the AI Agent."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeKnowledgeRetrieve,
|
||||
Title: "Knowledge Retrieve",
|
||||
Description: "Retrieve knowledge for the current user message.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("query", VariableTypeString, "Search query."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("items", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
output("summary", VariableTypeString, "Short retrieval summary."),
|
||||
},
|
||||
DefaultInputs: map[string]dsl.VariableSelector{
|
||||
"query": {NodeID: "start_1", Field: "userMessage"},
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeAnswerabilityGate,
|
||||
Title: "Answerability Gate",
|
||||
Description: "Decide whether retrieved knowledge is enough to answer.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("userMessage", VariableTypeString, "Current user message content."),
|
||||
requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("answerability", VariableTypeString, "Answerability decision."),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeLLMReply,
|
||||
Title: "LLM Reply",
|
||||
Description: "Generate a reply or structured analysis with the configured model.",
|
||||
RiskLevel: NodeRiskLevelMedium,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("userMessage", VariableTypeString, "Current user message content."),
|
||||
optionalInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("replyText", VariableTypeString, "Generated reply text."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeCondition,
|
||||
Title: "Condition",
|
||||
Description: "Route by controlled workflow variables.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
OutputSchema: []VariableSpec{
|
||||
output("matched", VariableTypeBoolean, "Whether the condition matched."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeAnalyzeConversation,
|
||||
Title: "Analyze Conversation",
|
||||
Description: "Analyze intent, risk, and recommended next action.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("userMessage", VariableTypeString, "Current user message content."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("intent", VariableTypeString, "Detected user intent."),
|
||||
output("riskLevel", VariableTypeString, "Detected risk level."),
|
||||
output("needTicket", VariableTypeBoolean, "Whether a ticket is recommended."),
|
||||
output("needHumanHandoff", VariableTypeBoolean, "Whether human handoff is recommended."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypePrepareTicketDraft,
|
||||
Title: "Prepare Ticket Draft",
|
||||
Description: "Build a ticket draft from conversation context.",
|
||||
RiskLevel: NodeRiskLevelMedium,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("issue", VariableTypeString, "Issue summary."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("ticketDraft", VariableTypeObject, "Draft ticket payload."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeHumanConfirm,
|
||||
Title: "Human Confirm",
|
||||
Description: "Interrupt and wait for explicit user confirmation.",
|
||||
RiskLevel: NodeRiskLevelMedium,
|
||||
Interruptible: true,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("prompt", VariableTypeString, "Confirmation prompt."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("confirmed", VariableTypeBoolean, "Whether the user confirmed."),
|
||||
output("responseText", VariableTypeString, "Confirmation response text."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeCreateTicket,
|
||||
Title: "Create Ticket",
|
||||
Description: "Create a ticket from a confirmed draft.",
|
||||
RiskLevel: NodeRiskLevelHigh,
|
||||
RequiresConfirmationPredecessor: true,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("ticketDraft", VariableTypeObject, "Confirmed draft ticket payload."),
|
||||
requiredInput("confirmed", VariableTypeBoolean, "Confirmation result."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("ticketId", VariableTypeInteger, "Created ticket ID."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeHandoffToHuman,
|
||||
Title: "Handoff To Human",
|
||||
Description: "Transfer the conversation to human support.",
|
||||
RiskLevel: NodeRiskLevelHigh,
|
||||
RequiresConfirmationPredecessor: true,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("reason", VariableTypeString, "Handoff reason."),
|
||||
requiredInput("confirmed", VariableTypeBoolean, "Confirmation result."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("handoffId", VariableTypeInteger, "Handoff operation ID."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeSendReply,
|
||||
Title: "Send Reply",
|
||||
Description: "Return or commit customer-visible reply text.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("replyText", VariableTypeString, "Customer-visible reply text."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("sent", VariableTypeBoolean, "Whether the reply was sent."),
|
||||
output("replyMessageId", VariableTypeInteger, "Reply message ID."),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
Type: NodeTypeEnd,
|
||||
Title: "End",
|
||||
Description: "End workflow execution.",
|
||||
RiskLevel: NodeRiskLevelLow,
|
||||
OutputSchema: []VariableSpec{
|
||||
output("status", VariableTypeString, "Workflow terminal status."),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func requiredInput(name string, variableType VariableType, description string) VariableSpec {
|
||||
return VariableSpec{Name: name, Type: variableType, Required: true, Description: description}
|
||||
}
|
||||
|
||||
func optionalInput(name string, variableType VariableType, description string) VariableSpec {
|
||||
return VariableSpec{Name: name, Type: variableType, Description: description}
|
||||
}
|
||||
|
||||
func output(name string, variableType VariableType, description string) VariableSpec {
|
||||
return VariableSpec{Name: name, Type: variableType, Description: description}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package registry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRegistryExposesStartOutputs(t *testing.T) {
|
||||
spec, ok := DefaultRegistry().Get(NodeTypeStart)
|
||||
if !ok {
|
||||
t.Fatalf("start node spec not found")
|
||||
}
|
||||
if !hasVariable(spec.OutputSchema, "userMessage", VariableTypeString) {
|
||||
t.Fatalf("expected start output userMessage:string, got %#v", spec.OutputSchema)
|
||||
}
|
||||
if !hasVariable(spec.OutputSchema, "knowledgeBaseIds", VariableTypeIntegerArray) {
|
||||
t.Fatalf("expected start output knowledgeBaseIds:array<int>, got %#v", spec.OutputSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistryExposesKnowledgeRetrieveInputsAndOutputs(t *testing.T) {
|
||||
spec, ok := DefaultRegistry().Get(NodeTypeKnowledgeRetrieve)
|
||||
if !ok {
|
||||
t.Fatalf("knowledge_retrieve node spec not found")
|
||||
}
|
||||
if !hasRequiredVariable(spec.InputSchema, "query", VariableTypeString) {
|
||||
t.Fatalf("expected knowledge_retrieve required input query:string, got %#v", spec.InputSchema)
|
||||
}
|
||||
if !hasVariable(spec.OutputSchema, "items", VariableTypeObjectArray) {
|
||||
t.Fatalf("expected knowledge_retrieve output items:array<object>, got %#v", spec.OutputSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistryExposesSendReplyRequiredInput(t *testing.T) {
|
||||
spec, ok := DefaultRegistry().Get(NodeTypeSendReply)
|
||||
if !ok {
|
||||
t.Fatalf("send_reply node spec not found")
|
||||
}
|
||||
if !hasRequiredVariable(spec.InputSchema, "replyText", VariableTypeString) {
|
||||
t.Fatalf("expected send_reply required input replyText:string, got %#v", spec.InputSchema)
|
||||
}
|
||||
if !hasVariable(spec.OutputSchema, "sent", VariableTypeBoolean) {
|
||||
t.Fatalf("expected send_reply output sent:boolean, got %#v", spec.OutputSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func hasRequiredVariable(items []VariableSpec, name string, variableType VariableType) bool {
|
||||
for _, item := range items {
|
||||
if item.Name == name && item.Type == variableType && item.Required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasVariable(items []VariableSpec, name string, variableType VariableType) bool {
|
||||
for _, item := range items {
|
||||
if item.Name == name && item.Type == variableType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package registry
|
||||
|
||||
import "agent-desk/internal/ai/workflow/dsl"
|
||||
|
||||
type NodeRiskLevel string
|
||||
|
||||
const (
|
||||
@@ -8,13 +10,37 @@ const (
|
||||
NodeRiskLevelHigh NodeRiskLevel = "high"
|
||||
)
|
||||
|
||||
type VariableType string
|
||||
|
||||
const (
|
||||
VariableTypeString VariableType = "string"
|
||||
VariableTypeInteger VariableType = "integer"
|
||||
VariableTypeBoolean VariableType = "boolean"
|
||||
VariableTypeObject VariableType = "object"
|
||||
VariableTypeStringArray VariableType = "array<string>"
|
||||
VariableTypeIntegerArray VariableType = "array<int>"
|
||||
VariableTypeObjectArray VariableType = "array<object>"
|
||||
VariableTypeAny VariableType = "any"
|
||||
)
|
||||
|
||||
type VariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type VariableType `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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 Registry struct {
|
||||
|
||||
@@ -55,6 +55,7 @@ func (v *definitionValidator) validate() {
|
||||
v.validateEntry()
|
||||
v.validateReachability()
|
||||
v.validateConfirmationGuards()
|
||||
v.validateVariableMappings()
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateNodes() {
|
||||
@@ -182,6 +183,116 @@ func (v *definitionValidator) validateConfirmationGuards() {
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateVariableMappings() {
|
||||
for id, node := range v.nodesByID {
|
||||
spec, ok := v.registry.Get(node.Type)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, input := range spec.InputSchema {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
v.validateInputSelector(id, input, selector)
|
||||
}
|
||||
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
|
||||
}
|
||||
if _, ok := findInputSpec(spec.InputSchema, inputName); ok {
|
||||
continue
|
||||
}
|
||||
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(selector.NodeID)]
|
||||
if !sourceOK {
|
||||
v.addError("nodes."+id+".inputs."+inputName, "input source node does not exist: "+selector.NodeID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) validateInputSelector(nodeID string, input registry.VariableSpec, selector dsl.VariableSelector) {
|
||||
sourceNodeID := strings.TrimSpace(selector.NodeID)
|
||||
sourceField := strings.TrimSpace(selector.Field)
|
||||
sourceNode, ok := v.nodesByID[sourceNodeID]
|
||||
if !ok {
|
||||
v.addError("nodes."+nodeID+".inputs."+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)
|
||||
return
|
||||
}
|
||||
sourceSpec, ok := v.registry.Get(sourceNode.Type)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
output, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
|
||||
if !ok {
|
||||
v.addError("nodes."+nodeID+".inputs."+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))
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) hasPath(sourceID string, targetID string, visiting map[string]struct{}) bool {
|
||||
if sourceID == targetID {
|
||||
return false
|
||||
}
|
||||
if _, seen := visiting[sourceID]; seen {
|
||||
return false
|
||||
}
|
||||
visiting[sourceID] = struct{}{}
|
||||
for _, next := range v.outgoing[sourceID] {
|
||||
if next == targetID {
|
||||
return true
|
||||
}
|
||||
if v.hasPath(next, targetID, visiting) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findInputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return registry.VariableSpec{}, false
|
||||
}
|
||||
|
||||
func findOutputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return registry.VariableSpec{}, false
|
||||
}
|
||||
|
||||
func variableTypesCompatible(input registry.VariableType, output registry.VariableType) bool {
|
||||
return input == registry.VariableTypeAny || output == registry.VariableTypeAny || input == output
|
||||
}
|
||||
|
||||
func (v *definitionValidator) hasConfirmationPredecessor(nodeID string, visiting map[string]struct{}) bool {
|
||||
if _, seen := visiting[nodeID]; seen {
|
||||
return false
|
||||
|
||||
@@ -83,9 +83,16 @@ func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "draft_1", Type: "prepare_ticket_draft"},
|
||||
{ID: "confirm_1", Type: "human_confirm"},
|
||||
{ID: "create_1", Type: "create_ticket"},
|
||||
{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{
|
||||
@@ -103,13 +110,99 @@ func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Inputs = nil
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected missing required input mapping to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "required input mapping is missing") {
|
||||
t.Fatalf("expected required-input error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "missing_1", Field: "replyText"}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected unknown input source node to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "input source node does not exist") {
|
||||
t.Fatalf("expected source-node error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "missing"}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected unknown input source field to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "input source field does not exist") {
|
||||
t.Fatalf("expected source-field error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
|
||||
def := mappedReplyDefinition()
|
||||
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "conversationId"}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected incompatible input type to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "input type mismatch") {
|
||||
t.Fatalf("expected type-mismatch error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionAcceptsMappedKnowledgeFlow(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
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"},
|
||||
},
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected mapped knowledge flow to be valid, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
|
||||
{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"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
@@ -119,6 +212,14 @@ func minimalDefinition() dsl.Definition {
|
||||
}
|
||||
}
|
||||
|
||||
func mappedReplyDefinition() dsl.Definition {
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "start_1", Field: "userMessage"},
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func hasValidationMessage(result validator.Result, want string) bool {
|
||||
for _, item := range result.Errors {
|
||||
if strings.Contains(item.Message, want) {
|
||||
|
||||
@@ -78,6 +78,10 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
|
||||
RiskLevel: item.RiskLevel,
|
||||
Interruptible: item.Interruptible,
|
||||
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
|
||||
ConfigSchema: item.ConfigSchema,
|
||||
InputSchema: item.InputSchema,
|
||||
OutputSchema: item.OutputSchema,
|
||||
DefaultInputs: item.DefaultInputs,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
)
|
||||
|
||||
func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) {
|
||||
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
|
||||
|
||||
var startFound bool
|
||||
var sendReplyFound bool
|
||||
for _, spec := range specs {
|
||||
switch spec.Type {
|
||||
case workflowregistry.NodeTypeStart:
|
||||
startFound = true
|
||||
if !hasResponseVariable(spec.OutputSchema, "userMessage") {
|
||||
t.Fatalf("expected start output userMessage, got %#v", spec.OutputSchema)
|
||||
}
|
||||
case workflowregistry.NodeTypeSendReply:
|
||||
sendReplyFound = true
|
||||
if !hasResponseVariable(spec.InputSchema, "replyText") {
|
||||
t.Fatalf("expected send_reply input replyText, got %#v", spec.InputSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !startFound || !sendReplyFound {
|
||||
t.Fatalf("expected start and send_reply specs in response")
|
||||
}
|
||||
}
|
||||
|
||||
func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool {
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -42,10 +42,14 @@ type AIWorkflowValidationResponse struct {
|
||||
}
|
||||
|
||||
type AIWorkflowNodeSpecResponse struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
if stored.EntryNodeID == "" {
|
||||
t.Fatalf("expected default draft definition")
|
||||
}
|
||||
if len(stored.Nodes) < 5 {
|
||||
t.Fatalf("expected product-ready default workflow, got nodes: %#v", stored.Nodes)
|
||||
}
|
||||
if !workflowHasNodeType(stored, "knowledge_retrieve") || !workflowHasNodeType(stored, "llm_reply") || !workflowHasNodeType(stored, "send_reply") {
|
||||
t.Fatalf("expected default workflow to include retrieve, llm reply, and send reply nodes: %#v", stored.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
|
||||
@@ -169,3 +175,12 @@ func aiAgentWorkflowTestOperator() *dto.AuthPrincipal {
|
||||
Nickname: "agent-workflow-tester",
|
||||
}
|
||||
}
|
||||
|
||||
func workflowHasNodeType(def dsl.Definition, nodeType string) bool {
|
||||
for _, node := range def.Nodes {
|
||||
if node.Type == nodeType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -337,10 +337,25 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start", Position: dsl.Position{X: 0, Y: 80}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End", Position: dsl.Position{X: 360, Y: 80}},
|
||||
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 120}},
|
||||
{ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 260, Y: 120}, Inputs: map[string]dsl.VariableSelector{
|
||||
"query": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 520, Y: 120}, 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: 780, Y: 120}, Inputs: map[string]dsl.VariableSelector{
|
||||
"replyText": {NodeID: "reply_1", Field: "replyText"},
|
||||
}},
|
||||
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1040, Y: 120}},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "edge_start_retrieve", Source: "start_1", Target: "retrieve_1"},
|
||||
{ID: "edge_retrieve_reply", Source: "retrieve_1", Target: "reply_1"},
|
||||
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
|
||||
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
|
||||
},
|
||||
Edges: []dsl.Edge{{ID: "edge_start_end", Source: "start_1", Target: "end_1"}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@ func validAIWorkflowDefinition() dsl.Definition {
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
|
||||
{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"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
|
||||
@@ -89,19 +89,55 @@ const emptyDefinition: AIWorkflowDefinition = {
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
name: "Start",
|
||||
position: { x: 0, y: 80 },
|
||||
name: "开始",
|
||||
position: { x: 0, y: 120 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "retrieve_1",
|
||||
type: "knowledge_retrieve",
|
||||
name: "知识检索",
|
||||
position: { x: 260, y: 120 },
|
||||
config: {},
|
||||
inputs: {
|
||||
query: { nodeId: "start_1", field: "userMessage" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reply_1",
|
||||
type: "llm_reply",
|
||||
name: "AI 回复",
|
||||
position: { x: 520, y: 120 },
|
||||
config: {},
|
||||
inputs: {
|
||||
userMessage: { nodeId: "start_1", field: "userMessage" },
|
||||
knowledgeItems: { nodeId: "retrieve_1", field: "items" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "send_1",
|
||||
type: "send_reply",
|
||||
name: "发送回复",
|
||||
position: { x: 780, y: 120 },
|
||||
config: {},
|
||||
inputs: {
|
||||
replyText: { nodeId: "reply_1", field: "replyText" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
name: "End",
|
||||
position: { x: 360, y: 80 },
|
||||
name: "结束",
|
||||
position: { x: 1040, y: 120 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
|
||||
edges: [
|
||||
{ id: "edge_start_retrieve", source: "start_1", target: "retrieve_1" },
|
||||
{ id: "edge_retrieve_reply", source: "retrieve_1", target: "reply_1" },
|
||||
{ id: "edge_reply_send", source: "reply_1", target: "send_1" },
|
||||
{ id: "edge_send_end", source: "send_1", target: "end_1" },
|
||||
],
|
||||
}
|
||||
|
||||
function toText(value: string | number | undefined | null) {
|
||||
|
||||
@@ -7,51 +7,85 @@ 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 { VariableSelector } from "./variable-selector"
|
||||
import type {
|
||||
WorkflowNodeSpec,
|
||||
WorkflowVariableRef,
|
||||
WorkflowVariableSelector,
|
||||
} from "./workflow-utils"
|
||||
|
||||
type WorkflowNodeData = Record<string, unknown> & {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
inputs?: Record<string, WorkflowVariableSelector>
|
||||
}
|
||||
|
||||
export function NodeConfigPanel({
|
||||
node,
|
||||
nodeSpec,
|
||||
availableVariables,
|
||||
onChange,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData> | null
|
||||
nodeSpec?: WorkflowNodeSpec
|
||||
availableVariables: WorkflowVariableRef[]
|
||||
onChange: (nodeId: string, data: WorkflowNodeData) => void
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
|
||||
Select a node to edit its properties.
|
||||
选择一个节点后,可以配置输入映射并查看输出变量。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <NodeConfigForm key={node.id} node={node} onChange={onChange} />
|
||||
return (
|
||||
<NodeConfigForm
|
||||
key={node.id}
|
||||
node={node}
|
||||
nodeSpec={nodeSpec}
|
||||
availableVariables={availableVariables}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeConfigForm({
|
||||
node,
|
||||
nodeSpec,
|
||||
availableVariables,
|
||||
onChange,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData>
|
||||
nodeSpec?: WorkflowNodeSpec
|
||||
availableVariables: WorkflowVariableRef[]
|
||||
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 inputSchema = nodeSpec?.inputSchema ?? []
|
||||
const outputSchema = nodeSpec?.outputSchema ?? []
|
||||
|
||||
const commitChange = (next: Partial<WorkflowNodeData>) => {
|
||||
onChange(node.id, {
|
||||
...node.data,
|
||||
name: name.trim() || node.data.nodeType || node.id,
|
||||
config: node.data.config ?? {},
|
||||
inputs,
|
||||
...next,
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(configText || "{}") as Record<string, unknown>
|
||||
setError("")
|
||||
onChange(node.id, {
|
||||
...node.data,
|
||||
name: name.trim() || node.data.nodeType || node.id,
|
||||
config: parsed,
|
||||
})
|
||||
commitChange({ config: parsed })
|
||||
} catch {
|
||||
setError("Config must be valid JSON.")
|
||||
}
|
||||
@@ -64,24 +98,90 @@ function NodeConfigForm({
|
||||
<div className="mt-1 text-xs text-muted-foreground">{node.id}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-node-name">Name</Label>
|
||||
<Label htmlFor="workflow-node-name">节点名称</Label>
|
||||
<Input
|
||||
id="workflow-node-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onBlur={() => commitChange({ name: name.trim() || node.data.nodeType || node.id })}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2">
|
||||
<Label htmlFor="workflow-node-config">Config JSON</Label>
|
||||
<Textarea
|
||||
id="workflow-node-config"
|
||||
className="h-64 font-mono text-xs"
|
||||
value={configText}
|
||||
onChange={(event) => setConfigText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="text-xs text-destructive">{error}</div> : null}
|
||||
<Button onClick={handleApply}>Apply</Button>
|
||||
{inputSchema.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">输入映射</div>
|
||||
{availableVariables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
当前节点前面还没有可用变量,请先连接上游节点。
|
||||
</div>
|
||||
) : null}
|
||||
{inputSchema.map((input) => (
|
||||
<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>
|
||||
<VariableSelector
|
||||
value={inputs[input.name]}
|
||||
variables={availableVariables}
|
||||
onChange={(value) => {
|
||||
const nextInputs = {
|
||||
...inputs,
|
||||
[input.name]: value,
|
||||
}
|
||||
setInputs(nextInputs)
|
||||
commitChange({
|
||||
inputs: nextInputs,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{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="rounded-md border bg-background p-3">
|
||||
<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">
|
||||
<div className="text-sm font-medium">输出变量</div>
|
||||
<div className="space-y-1 rounded-md border bg-background 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
|
||||
import type { WorkflowVariableRef, WorkflowVariableSelector } from "./workflow-utils"
|
||||
|
||||
export function VariableSelector({
|
||||
value,
|
||||
variables,
|
||||
onChange,
|
||||
}: {
|
||||
value?: WorkflowVariableSelector
|
||||
variables: WorkflowVariableRef[]
|
||||
onChange: (value: WorkflowVariableSelector) => void
|
||||
}) {
|
||||
const options = variables.map((item) => ({
|
||||
value: `${item.nodeId}.${item.field}`,
|
||||
label: `${item.nodeName}.${item.field} · ${item.type}`,
|
||||
}))
|
||||
const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
|
||||
|
||||
return (
|
||||
<OptionCombobox
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
placeholder="选择变量"
|
||||
searchPlaceholder="搜索变量"
|
||||
emptyText="没有可用上游变量"
|
||||
onChange={(nextValue) => {
|
||||
const [nodeId, ...fieldParts] = nextValue.split(".")
|
||||
onChange({ nodeId, field: fieldParts.join(".") })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
addEdge,
|
||||
Background,
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
Position,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -31,11 +34,16 @@ import {
|
||||
} from "@/components/ui/resizable"
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
import {
|
||||
applyAutoInputMappings,
|
||||
fromApiDefinition,
|
||||
getAvailableVariables,
|
||||
getNodeSpec,
|
||||
getRequiredInputs,
|
||||
toApiDefinition,
|
||||
validateWorkflowDraft,
|
||||
type WorkflowEditorEdge,
|
||||
type WorkflowEditorNode,
|
||||
type WorkflowNodeSpec,
|
||||
} from "./workflow-utils"
|
||||
import { NodeConfigPanel } from "./node-config-panel"
|
||||
|
||||
@@ -43,22 +51,33 @@ type WorkflowNodeData = Record<string, unknown> & {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
inputs?: Record<string, { nodeId: string; field: string }>
|
||||
label?: string
|
||||
title?: string
|
||||
description?: string
|
||||
inputCount?: number
|
||||
outputCount?: number
|
||||
missingInputs?: string[]
|
||||
}
|
||||
|
||||
type WorkflowFlowNode = Node<WorkflowNodeData>
|
||||
type WorkflowFlowEdge = Edge
|
||||
|
||||
const nodeTypes = {
|
||||
workflowNode: WorkflowCanvasNode,
|
||||
}
|
||||
|
||||
function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] {
|
||||
return fromApiDefinition(definition).nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "default",
|
||||
type: "workflowNode",
|
||||
position: node.position,
|
||||
data: {
|
||||
nodeType: node.data?.nodeType ?? node.type,
|
||||
name: node.data?.name ?? node.id,
|
||||
label: node.data?.name ?? node.type ?? node.id,
|
||||
config: node.data?.config ?? {},
|
||||
inputs: node.data?.inputs ?? {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -82,6 +101,7 @@ function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
|
||||
nodeType: node.data.nodeType,
|
||||
name: node.data.name,
|
||||
config: node.data.config,
|
||||
inputs: node.data.inputs,
|
||||
},
|
||||
})) as WorkflowEditorNode[],
|
||||
edges: edges.map((edge) => ({
|
||||
@@ -113,14 +133,31 @@ export function WorkflowEditor({
|
||||
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId]
|
||||
)
|
||||
const validation = useMemo(() => validateWorkflowDraft(toDraft(nodes, edges)), [nodes, edges])
|
||||
const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges])
|
||||
const validation = useMemo(
|
||||
() => validateWorkflowDraft(draft, nodeSpecs),
|
||||
[draft, nodeSpecs]
|
||||
)
|
||||
const renderedNodes = useMemo(
|
||||
() => enrichNodesForRender(nodes, nodeSpecs),
|
||||
[nodes, nodeSpecs]
|
||||
)
|
||||
const selectedNodeSpec = useMemo(
|
||||
() => getNodeSpec(nodeSpecs, selectedNode?.data.nodeType ?? ""),
|
||||
[nodeSpecs, selectedNode]
|
||||
)
|
||||
const availableVariables = useMemo(
|
||||
() => (selectedNode ? getAvailableVariables(draft, selectedNode.id, nodeSpecs) : []),
|
||||
[draft, nodeSpecs, selectedNode]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)
|
||||
}, [edges, nodes, onDefinitionChange])
|
||||
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
|
||||
}, [draft, onDefinitionChange])
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
let newEdge: WorkflowFlowEdge | null = null
|
||||
setEdges((current) => {
|
||||
let nextIndex = current.length + 1
|
||||
let id = `edge_${connection.source}_${connection.target}_${nextIndex}`
|
||||
@@ -128,6 +165,10 @@ export function WorkflowEditor({
|
||||
nextIndex += 1
|
||||
id = `edge_${connection.source}_${connection.target}_${nextIndex}`
|
||||
}
|
||||
newEdge = {
|
||||
...connection,
|
||||
id,
|
||||
} as WorkflowFlowEdge
|
||||
return addEdge(
|
||||
{
|
||||
...connection,
|
||||
@@ -136,8 +177,32 @@ export function WorkflowEditor({
|
||||
current
|
||||
)
|
||||
})
|
||||
if (connection.source && connection.target) {
|
||||
setNodes((currentNodes) => {
|
||||
const currentDraft = toDraft(currentNodes, newEdge ? [...edges, newEdge] : edges)
|
||||
const nextDraft = applyAutoInputMappings(
|
||||
currentDraft,
|
||||
connection.source!,
|
||||
connection.target!,
|
||||
nodeSpecs
|
||||
)
|
||||
return currentNodes.map((node) => {
|
||||
const nextNode = nextDraft.nodes.find((item) => item.id === node.id)
|
||||
if (!nextNode) {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
inputs: nextNode.data?.inputs ?? node.data.inputs,
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[setEdges]
|
||||
[edges, nodeSpecs, setEdges, setNodes]
|
||||
)
|
||||
|
||||
const addNode = (spec: AIWorkflowNodeSpec) => {
|
||||
@@ -152,13 +217,14 @@ export function WorkflowEditor({
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
type: "default",
|
||||
type: "workflowNode",
|
||||
position: { x: 120 + current.length * 28, y: 100 + current.length * 24 },
|
||||
data: {
|
||||
nodeType: spec.type,
|
||||
name: spec.title,
|
||||
label: spec.title,
|
||||
config: {},
|
||||
inputs: spec.defaultInputs ?? {},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -185,7 +251,7 @@ export function WorkflowEditor({
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full min-h-0 border-t">
|
||||
<ResizablePanel defaultSize="18%" minSize="12%" maxSize="34%" className="min-h-0">
|
||||
<aside className="h-full min-h-0 overflow-y-auto bg-muted/20 p-3">
|
||||
<div className="mb-3 text-sm font-medium">Nodes</div>
|
||||
<div className="mb-3 text-sm font-medium">节点库</div>
|
||||
<div className="space-y-2">
|
||||
{nodeSpecs.map((spec) => (
|
||||
<button
|
||||
@@ -200,6 +266,10 @@ export function WorkflowEditor({
|
||||
<span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{spec.description}
|
||||
</span>
|
||||
<span className="mt-1 flex gap-2 text-[11px] text-muted-foreground">
|
||||
<span>输入 {spec.inputSchema?.length ?? 0}</span>
|
||||
<span>输出 {spec.outputSchema?.length ?? 0}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -210,8 +280,9 @@ export function WorkflowEditor({
|
||||
<ResizablePanel defaultSize="56%" minSize="30%" className="min-h-0">
|
||||
<section className="relative h-full min-h-0">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
@@ -228,7 +299,12 @@ export function WorkflowEditor({
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize="26%" minSize="18%" maxSize="40%" className="min-h-0">
|
||||
<aside className="h-full min-h-0 overflow-y-auto bg-muted/10">
|
||||
<NodeConfigPanel node={selectedNode} onChange={updateNodeData} />
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeSpec={selectedNodeSpec}
|
||||
availableVariables={availableVariables}
|
||||
onChange={updateNodeData}
|
||||
/>
|
||||
{!validation.valid ? (
|
||||
<div className="border-t p-4">
|
||||
<div className="mb-2 text-sm font-medium">Local validation</div>
|
||||
@@ -254,6 +330,73 @@ export function WorkflowEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function enrichNodesForRender(
|
||||
nodes: WorkflowFlowNode[],
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
): WorkflowFlowNode[] {
|
||||
return nodes.map((node) => {
|
||||
const spec = getNodeSpec(nodeSpecs, node.data.nodeType ?? "")
|
||||
const missingInputs = getRequiredInputs(spec).filter((input) => {
|
||||
const selector = node.data.inputs?.[input.name]
|
||||
return !selector?.nodeId || !selector.field
|
||||
})
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
title: spec?.title ?? node.data.name ?? node.id,
|
||||
description: spec?.description ?? "",
|
||||
inputCount: spec?.inputSchema?.length ?? 0,
|
||||
outputCount: spec?.outputSchema?.length ?? 0,
|
||||
missingInputs: missingInputs.map((input) => input.name),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
|
||||
const missingInputs = data.missingInputs ?? []
|
||||
const hasIssue = missingInputs.length > 0
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"min-w-56 rounded-md border bg-background shadow-sm",
|
||||
selected ? "ring-2 ring-ring" : "",
|
||||
hasIssue ? "border-destructive/70" : "border-border",
|
||||
].join(" ")}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="flex items-start gap-2 border-b px-3 py-2">
|
||||
{hasIssue ? (
|
||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-emerald-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{data.name ?? data.title}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{data.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 px-3 py-2 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>输入 {data.inputCount ?? 0}</span>
|
||||
<span>输出 {data.outputCount ?? 0}</span>
|
||||
</div>
|
||||
{hasIssue ? (
|
||||
<div className="rounded-sm bg-destructive/10 px-2 py-1 text-destructive">
|
||||
缺少输入:{missingInputs.join("、")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-sm bg-emerald-500/10 px-2 py-1 text-emerald-700">
|
||||
配置完整
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowValidationBadge({
|
||||
errors,
|
||||
valid,
|
||||
|
||||
@@ -53,6 +53,155 @@ describe("validateWorkflowDraft", () => {
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /target node does not exist/)
|
||||
})
|
||||
|
||||
it("rejects missing required input mapping", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft(
|
||||
{
|
||||
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" },
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "send_reply",
|
||||
inputSchema: [{ name: "replyText", type: "string", required: true }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
|
||||
})
|
||||
})
|
||||
|
||||
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" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableVariables", () => {
|
||||
it("exposes start outputs to retrieve node", async () => {
|
||||
const { getAvailableVariables } = await loadModule()
|
||||
|
||||
const variables = getAvailableVariables(
|
||||
{
|
||||
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: {} },
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
|
||||
},
|
||||
"retrieve_1",
|
||||
[
|
||||
{
|
||||
type: "start",
|
||||
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(plain(variables), [
|
||||
{
|
||||
nodeId: "start_1",
|
||||
nodeName: "Start",
|
||||
field: "userMessage",
|
||||
type: "string",
|
||||
description: "Message",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toApiDefinition", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type WorkflowEditorNode = {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
inputs?: Record<string, WorkflowVariableSelector>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ export type WorkflowDefinition = {
|
||||
name: string
|
||||
position: WorkflowNodePosition
|
||||
config: Record<string, unknown>
|
||||
inputs?: Record<string, WorkflowVariableSelector>
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
@@ -50,12 +52,54 @@ export type WorkflowDefinition = {
|
||||
}[]
|
||||
}
|
||||
|
||||
export type WorkflowVariableType =
|
||||
| "string"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "object"
|
||||
| "array<string>"
|
||||
| "array<int>"
|
||||
| "array<object>"
|
||||
| "any"
|
||||
|
||||
export type WorkflowVariableSelector = {
|
||||
nodeId: string
|
||||
field: string
|
||||
}
|
||||
|
||||
export type WorkflowVariableSpec = {
|
||||
name: string
|
||||
type: WorkflowVariableType
|
||||
required?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type WorkflowNodeSpec = {
|
||||
type: string
|
||||
title?: string
|
||||
description?: string
|
||||
inputSchema?: WorkflowVariableSpec[]
|
||||
outputSchema?: WorkflowVariableSpec[]
|
||||
defaultInputs?: Record<string, WorkflowVariableSelector>
|
||||
}
|
||||
|
||||
export type WorkflowVariableRef = {
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
field: string
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowDraftValidation = {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValidation {
|
||||
export function validateWorkflowDraft(
|
||||
draft: WorkflowDraft,
|
||||
nodeSpecs: WorkflowNodeSpec[] = []
|
||||
): WorkflowDraftValidation {
|
||||
const errors: string[] = []
|
||||
const nodeIds = new Set<string>()
|
||||
let startCount = 0
|
||||
@@ -104,6 +148,21 @@ export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValida
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of draft.nodes) {
|
||||
const nodeType = node.data?.nodeType ?? node.type ?? ""
|
||||
const spec = getNodeSpec(nodeSpecs, nodeType)
|
||||
if (!spec) {
|
||||
continue
|
||||
}
|
||||
for (const input of getRequiredInputs(spec)) {
|
||||
const selector = node.data?.inputs?.[input.name]
|
||||
if (!selector?.nodeId || !selector.field) {
|
||||
const nodeName = node.data?.name ?? spec.title ?? node.id
|
||||
errors.push(`${nodeName} 缺少必填输入「${input.name}」,请选择上游节点的输出变量。`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
@@ -124,6 +183,7 @@ export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
|
||||
y: node.position.y,
|
||||
},
|
||||
config: node.data?.config ?? {},
|
||||
...(node.data?.inputs ? { inputs: node.data.inputs } : {}),
|
||||
})),
|
||||
edges: draft.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
@@ -150,6 +210,7 @@ export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft
|
||||
nodeType: node.type,
|
||||
name: node.name,
|
||||
config: node.config ?? {},
|
||||
inputs: node.inputs ?? {},
|
||||
},
|
||||
})),
|
||||
edges: (definition.edges ?? []).map((edge) => ({
|
||||
@@ -160,3 +221,166 @@ export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAutoInputMappings(
|
||||
draft: WorkflowDraft,
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
nodeSpecs: WorkflowNodeSpec[]
|
||||
): WorkflowDraft {
|
||||
const sourceNode = draft.nodes.find((node) => node.id === sourceNodeId)
|
||||
const targetNode = draft.nodes.find((node) => node.id === targetNodeId)
|
||||
if (!sourceNode || !targetNode) {
|
||||
return draft
|
||||
}
|
||||
const sourceSpec = getNodeSpec(nodeSpecs, sourceNode.data?.nodeType ?? sourceNode.type ?? "")
|
||||
const targetSpec = getNodeSpec(nodeSpecs, targetNode.data?.nodeType ?? targetNode.type ?? "")
|
||||
if (!sourceSpec || !targetSpec) {
|
||||
return draft
|
||||
}
|
||||
const nextInputs = { ...(targetNode.data?.inputs ?? {}) }
|
||||
let changed = false
|
||||
|
||||
for (const input of targetSpec.inputSchema ?? []) {
|
||||
if (nextInputs[input.name]) {
|
||||
continue
|
||||
}
|
||||
const output = findPreferredOutput(input.name, input.type, sourceSpec.outputSchema ?? [])
|
||||
if (!output) {
|
||||
continue
|
||||
}
|
||||
nextInputs[input.name] = { nodeId: sourceNodeId, field: output.name }
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return draft
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
nodes: draft.nodes.map((node) =>
|
||||
node.id === targetNodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
inputs: nextInputs,
|
||||
},
|
||||
}
|
||||
: node
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function findPreferredOutput(
|
||||
inputName: string,
|
||||
inputType: WorkflowVariableType,
|
||||
outputs: WorkflowVariableSpec[]
|
||||
): WorkflowVariableSpec | undefined {
|
||||
const preferred = preferredOutputName(inputName)
|
||||
if (preferred) {
|
||||
const exact = outputs.find((output) => output.name === preferred && variableTypesCompatible(inputType, output.type))
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
const sameName = outputs.find((output) => output.name === inputName && variableTypesCompatible(inputType, output.type))
|
||||
if (sameName) {
|
||||
return sameName
|
||||
}
|
||||
return outputs.find((output) => variableTypesCompatible(inputType, output.type))
|
||||
}
|
||||
|
||||
function preferredOutputName(inputName: string): string {
|
||||
switch (inputName) {
|
||||
case "query":
|
||||
case "userMessage":
|
||||
case "issue":
|
||||
case "prompt":
|
||||
return "userMessage"
|
||||
case "knowledgeItems":
|
||||
return "items"
|
||||
case "replyText":
|
||||
return "replyText"
|
||||
case "confirmed":
|
||||
return "confirmed"
|
||||
case "ticketDraft":
|
||||
return "ticketDraft"
|
||||
case "reason":
|
||||
return "reason"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function variableTypesCompatible(input: WorkflowVariableType, output: WorkflowVariableType): boolean {
|
||||
return input === "any" || output === "any" || input === output
|
||||
}
|
||||
|
||||
export function getNodeSpec(
|
||||
nodeSpecs: WorkflowNodeSpec[],
|
||||
nodeType: string
|
||||
): WorkflowNodeSpec | undefined {
|
||||
return nodeSpecs.find((spec) => spec.type === nodeType)
|
||||
}
|
||||
|
||||
export function getRequiredInputs(spec: WorkflowNodeSpec | undefined): WorkflowVariableSpec[] {
|
||||
return (spec?.inputSchema ?? []).filter((item) => item.required)
|
||||
}
|
||||
|
||||
export function getAvailableVariables(
|
||||
draft: WorkflowDraft,
|
||||
nodeId: string,
|
||||
nodeSpecs: WorkflowNodeSpec[]
|
||||
): WorkflowVariableRef[] {
|
||||
const ancestors = collectAncestorNodeIds(draft, nodeId)
|
||||
const nodesById = new Map(draft.nodes.map((node) => [node.id, node]))
|
||||
const variables: WorkflowVariableRef[] = []
|
||||
|
||||
for (const sourceNodeId of ancestors) {
|
||||
const sourceNode = nodesById.get(sourceNodeId)
|
||||
if (!sourceNode) {
|
||||
continue
|
||||
}
|
||||
const nodeType = sourceNode.data?.nodeType ?? sourceNode.type ?? ""
|
||||
const spec = getNodeSpec(nodeSpecs, nodeType)
|
||||
for (const output of spec?.outputSchema ?? []) {
|
||||
variables.push({
|
||||
nodeId: sourceNode.id,
|
||||
nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id,
|
||||
field: output.name,
|
||||
type: output.type,
|
||||
description: output.description ?? "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
function collectAncestorNodeIds(draft: WorkflowDraft, nodeId: string): string[] {
|
||||
const incoming = new Map<string, string[]>()
|
||||
for (const edge of draft.edges) {
|
||||
const sources = incoming.get(edge.target) ?? []
|
||||
sources.push(edge.source)
|
||||
incoming.set(edge.target, sources)
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const ordered: string[] = []
|
||||
|
||||
function visit(current: string) {
|
||||
for (const source of incoming.get(current) ?? []) {
|
||||
if (visited.has(source)) {
|
||||
continue
|
||||
}
|
||||
visited.add(source)
|
||||
visit(source)
|
||||
ordered.push(source)
|
||||
}
|
||||
}
|
||||
|
||||
visit(nodeId)
|
||||
return ordered
|
||||
}
|
||||
|
||||
@@ -286,6 +286,28 @@ export type AIWorkflowPosition = {
|
||||
y: number
|
||||
}
|
||||
|
||||
export type AIWorkflowVariableType =
|
||||
| "string"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "object"
|
||||
| "array<string>"
|
||||
| "array<int>"
|
||||
| "array<object>"
|
||||
| "any"
|
||||
|
||||
export type AIWorkflowVariableSelector = {
|
||||
nodeId: string
|
||||
field: string
|
||||
}
|
||||
|
||||
export type AIWorkflowVariableSpec = {
|
||||
name: string
|
||||
type: AIWorkflowVariableType
|
||||
required?: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export type AIWorkflowDefinition = {
|
||||
schemaVersion: number
|
||||
entryNodeId: string
|
||||
@@ -295,6 +317,7 @@ export type AIWorkflowDefinition = {
|
||||
name: string
|
||||
position: AIWorkflowPosition
|
||||
config: Record<string, unknown>
|
||||
inputs?: Record<string, AIWorkflowVariableSelector>
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
@@ -342,6 +365,10 @@ export type AIWorkflowNodeSpec = {
|
||||
riskLevel: "low" | "medium" | "high"
|
||||
interruptible: boolean
|
||||
requiresConfirmationPredecessor: boolean
|
||||
configSchema?: unknown
|
||||
inputSchema?: AIWorkflowVariableSpec[]
|
||||
outputSchema?: AIWorkflowVariableSpec[]
|
||||
defaultInputs?: Record<string, AIWorkflowVariableSelector>
|
||||
}
|
||||
|
||||
export type AIWorkflowValidationResult = {
|
||||
|
||||
Reference in New Issue
Block a user