feat: enhance condition handling with enum value options and metadata in workflow components
This commit is contained in:
@@ -44,8 +44,25 @@ func DefaultRegistry() *Registry {
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("normalizedMessage", VariableTypeString, "Normalized customer message."),
|
||||
output("messageIntent", VariableTypeString, "Detected customer message intent."),
|
||||
output("answerScope", VariableTypeString, "Recommended answer scope."),
|
||||
enumOutput("messageIntent", "消息意图", "Detected customer message intent.", []VariableValueOption{
|
||||
valueOption("unknown", "未知意图", "系统暂时无法判断客户意图。"),
|
||||
valueOption("greeting", "打招呼", "客户在问候或开始对话。"),
|
||||
valueOption("thanks", "表达感谢", "客户在表示感谢。"),
|
||||
valueOption("end_conversation", "结束会话", "客户表示问题已处理或准备结束。"),
|
||||
valueOption("confirmation", "确认操作", "客户对上一步操作进行确认。"),
|
||||
valueOption("handoff_request", "要求人工", "客户明确要求转人工处理。"),
|
||||
valueOption("complaint", "投诉升级", "客户表达投诉、举报、起诉等升级风险。"),
|
||||
valueOption("ticket_request", "要求建单", "客户希望创建或跟进工单。"),
|
||||
valueOption("ambiguous_question", "问题不明确", "客户问题缺少必要上下文,需要追问。"),
|
||||
valueOption("business_question", "业务问题", "客户问题适合进入知识库检索。"),
|
||||
}),
|
||||
enumOutput("answerScope", "回复策略", "Recommended answer scope.", []VariableValueOption{
|
||||
valueOption("direct_reply", "直接回复客户", "无需检索知识库或转人工,可以直接生成回复。"),
|
||||
valueOption("needs_clarification", "追问补充信息", "当前信息不足,需要客户补充。"),
|
||||
valueOption("needs_handoff", "转人工处理", "需要人工客服介入。"),
|
||||
valueOption("needs_ticket", "创建工单", "需要进入工单处理流程。"),
|
||||
valueOption("needs_knowledge", "检索知识库", "需要先检索知识库再回答。"),
|
||||
}),
|
||||
output("confidence", VariableTypeNumber, "Classifier confidence."),
|
||||
output("riskSignals", VariableTypeStringArray, "Detected risk signals."),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
@@ -67,12 +84,31 @@ func DefaultRegistry() *Registry {
|
||||
optionalInput("answerability", VariableTypeString, "Knowledge answerability decision."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("action", VariableTypeString, "Selected policy action."),
|
||||
enumOutput("action", "处理策略", "Selected policy action.", []VariableValueOption{
|
||||
valueOption("direct_reply", "直接回复客户", "直接发送策略节点生成的回复。"),
|
||||
valueOption("clarify", "追问补充信息", "先让客户补充必要信息。"),
|
||||
valueOption("end_conversation", "结束会话", "发送结束语并结束本轮处理。"),
|
||||
valueOption("handoff_to_human", "转人工", "进入人工接待流程。"),
|
||||
valueOption("prepare_ticket", "创建工单", "整理工单草稿并等待确认。"),
|
||||
valueOption("retrieve_knowledge", "检索知识库", "进入知识检索和 AI 回复流程。"),
|
||||
valueOption("knowledge_fallback", "知识库兜底", "知识库结果不足,发送兜底回复。"),
|
||||
}),
|
||||
output("replyText", VariableTypeString, "Customer-visible reply text when the policy can answer directly."),
|
||||
output("reason", VariableTypeString, "Policy decision reason."),
|
||||
output("requiresFlow", VariableTypeBoolean, "Whether the decision should continue into workflow actions."),
|
||||
output("targetFlow", VariableTypeString, "Suggested target flow."),
|
||||
output("finalReplySource", VariableTypeString, "Source category for the final reply."),
|
||||
enumOutput("targetFlow", "目标流程", "Suggested target flow.", []VariableValueOption{
|
||||
valueOption("handoff_to_human", "转人工流程", "继续执行转人工节点。"),
|
||||
valueOption("prepare_ticket", "工单流程", "继续执行工单草稿和确认节点。"),
|
||||
valueOption("knowledge", "知识库流程", "继续执行知识检索节点。"),
|
||||
}),
|
||||
enumOutput("finalReplySource", "回复来源", "Source category for the final reply.", []VariableValueOption{
|
||||
valueOption("direct_reply", "策略直接回复", "由回复策略节点直接生成回复。"),
|
||||
valueOption("clarification", "追问回复", "用于追问客户补充信息。"),
|
||||
valueOption("handoff_notice", "转人工提示", "用于提示客户已进入人工处理。"),
|
||||
valueOption("ticket_result", "工单结果", "用于提示建单结果。"),
|
||||
valueOption("knowledge_answer", "知识库回答", "用于发送基于知识库生成的回复。"),
|
||||
valueOption("knowledge_fallback", "知识库兜底", "用于知识库信息不足时的兜底回复。"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
@@ -101,7 +137,10 @@ func DefaultRegistry() *Registry {
|
||||
requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("answerability", VariableTypeString, "Answerability decision."),
|
||||
enumOutput("answerability", "可回答性", "Answerability decision.", []VariableValueOption{
|
||||
valueOption("answerable", "可以回答", "检索结果足够支撑回答。"),
|
||||
valueOption("unanswerable", "无法回答", "检索结果不足,应该走兜底或追问。"),
|
||||
}),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
},
|
||||
},
|
||||
@@ -197,7 +236,13 @@ func DefaultRegistry() *Registry {
|
||||
OutputSchema: []VariableSpec{
|
||||
output("handoffId", VariableTypeInteger, "Handoff operation ID."),
|
||||
output("reason", VariableTypeString, "Handoff reason."),
|
||||
output("decision", VariableTypeString, "Handoff dispatch decision."),
|
||||
enumOutput("decision", "转人工结果", "Handoff dispatch decision.", []VariableValueOption{
|
||||
valueOption("assigned", "已分配客服", "已成功分配给人工客服。"),
|
||||
valueOption("team_pool", "团队队列等待", "暂未分配到客服,进入团队等待队列。"),
|
||||
valueOption("global_pool", "全局队列等待", "非服务时间或无可用团队,进入全局等待队列。"),
|
||||
valueOption("off_hours", "非服务时间", "当前不在人工客服服务时间内。"),
|
||||
valueOption("cancelled", "已取消转人工", "由于未确认或条件不满足,未执行转人工。"),
|
||||
}),
|
||||
output("teamId", VariableTypeInteger, "Assigned or pending team ID."),
|
||||
output("assigneeId", VariableTypeInteger, "Assigned agent user ID."),
|
||||
output("message", VariableTypeString, "Customer-visible handoff notice."),
|
||||
@@ -239,3 +284,18 @@ func optionalInput(name string, variableType VariableType, description string) V
|
||||
func output(name string, variableType VariableType, description string) VariableSpec {
|
||||
return VariableSpec{Name: name, Type: variableType, Description: description}
|
||||
}
|
||||
|
||||
func enumOutput(name string, label string, description string, options []VariableValueOption) VariableSpec {
|
||||
return VariableSpec{
|
||||
Name: name,
|
||||
Label: label,
|
||||
Type: VariableTypeString,
|
||||
Description: description,
|
||||
Operators: []string{"eq", "neq"},
|
||||
ValueOptions: options,
|
||||
}
|
||||
}
|
||||
|
||||
func valueOption(value any, label string, description string) VariableValueOption {
|
||||
return VariableValueOption{Value: value, Label: label, Description: description}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,19 @@ const (
|
||||
)
|
||||
|
||||
type VariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type VariableType `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Type VariableType `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Operators []string `json:"operators,omitempty"`
|
||||
ValueOptions []VariableValueOption `json:"valueOptions,omitempty"`
|
||||
}
|
||||
|
||||
type VariableValueOption struct {
|
||||
Value any `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type NodeSpec struct {
|
||||
|
||||
@@ -357,8 +357,17 @@ func (v *definitionValidator) validateCondition(field string, sourceNodeID strin
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField); !ok {
|
||||
outputSpec, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
|
||||
if !ok {
|
||||
v.addError(field+".left", "condition source field does not exist: "+sourceSelectorNodeID+"."+sourceField)
|
||||
return
|
||||
}
|
||||
if len(outputSpec.Operators) > 0 && !stringInSlice(outputSpec.Operators, operator) {
|
||||
v.addError(field+".operator", "condition operator is not allowed for variable: "+operator)
|
||||
return
|
||||
}
|
||||
if !conditionOperatorWithoutRight(operator) && len(outputSpec.ValueOptions) > 0 && !valueOptionExists(outputSpec.ValueOptions, condition.Right) {
|
||||
v.addError(field+".right", "condition comparison value is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +380,61 @@ func isSupportedConditionOperator(operator string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func conditionOperatorWithoutRight(operator string) bool {
|
||||
switch strings.TrimSpace(operator) {
|
||||
case "exists", "not_exists", "truthy", "is_true", "falsy", "is_false":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stringInSlice(items []string, value string) bool {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func valueOptionExists(items []registry.VariableValueOption, value any) bool {
|
||||
for _, item := range items {
|
||||
if conditionValuesEqual(item.Value, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func conditionValuesEqual(left any, right any) bool {
|
||||
switch l := left.(type) {
|
||||
case string:
|
||||
r, ok := right.(string)
|
||||
return ok && l == r
|
||||
case bool:
|
||||
r, ok := right.(bool)
|
||||
return ok && l == r
|
||||
case int:
|
||||
return conditionValuesEqual(float64(l), right)
|
||||
case int64:
|
||||
return conditionValuesEqual(float64(l), right)
|
||||
case float64:
|
||||
switch r := right.(type) {
|
||||
case int:
|
||||
return l == float64(r)
|
||||
case int64:
|
||||
return l == float64(r)
|
||||
case float64:
|
||||
return l == r
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) hasPath(sourceID string, targetID string, visiting map[string]struct{}) bool {
|
||||
if sourceID == targetID {
|
||||
return false
|
||||
|
||||
@@ -304,6 +304,19 @@ 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 minimalDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
@@ -322,6 +335,52 @@ func minimalDefinition() dsl.Definition {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@@ -34,6 +34,36 @@ func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAIWorkflowNodeSpecsIncludesConditionValueOptions(t *testing.T) {
|
||||
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
|
||||
|
||||
var action *workflowregistry.VariableSpec
|
||||
for _, spec := range specs {
|
||||
if spec.Type != workflowregistry.NodeTypeReplyPolicy {
|
||||
continue
|
||||
}
|
||||
for index := range spec.OutputSchema {
|
||||
if spec.OutputSchema[index].Name == "action" {
|
||||
action = &spec.OutputSchema[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action == nil {
|
||||
t.Fatalf("expected reply_policy action output")
|
||||
}
|
||||
if action.Label != "处理策略" {
|
||||
t.Fatalf("expected user-facing action label, got %q", action.Label)
|
||||
}
|
||||
if !hasResponseVariableOption(action.ValueOptions, "direct_reply", "直接回复客户") {
|
||||
t.Fatalf("expected direct_reply option with business label, got %#v", action.ValueOptions)
|
||||
}
|
||||
if !hasResponseOperator(action.Operators, "eq") || !hasResponseOperator(action.Operators, "neq") {
|
||||
t.Fatalf("expected action to constrain condition operators, got %#v", action.Operators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
|
||||
startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
|
||||
endedAt := startedAt.Add(1500 * time.Millisecond)
|
||||
@@ -109,3 +139,21 @@ func hasResponseVariable(items []workflowregistry.VariableSpec, name string) boo
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasResponseVariableOption(items []workflowregistry.VariableValueOption, value any, label string) bool {
|
||||
for _, item := range items {
|
||||
if item.Value == value && item.Label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasResponseOperator(items []string, value string) bool {
|
||||
for _, item := range items {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -292,6 +292,8 @@ function ConditionNodePanel({
|
||||
{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)
|
||||
@@ -344,7 +346,7 @@ function ConditionNodePanel({
|
||||
<Label className="text-xs">判断方式</Label>
|
||||
<OptionCombobox
|
||||
value={condition.operator ?? "eq"}
|
||||
options={conditionOperators}
|
||||
options={operatorOptions}
|
||||
placeholder="选择判断方式"
|
||||
searchPlaceholder="搜索判断方式"
|
||||
emptyText="没有可用判断方式"
|
||||
@@ -354,19 +356,13 @@ function ConditionNodePanel({
|
||||
/>
|
||||
</div>
|
||||
{!conditionOperatorWithoutRight(condition.operator ?? "eq") ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">比较值</Label>
|
||||
<Input
|
||||
value={conditionRight}
|
||||
onChange={(event) => commitBranch(branch.id, {
|
||||
condition: {
|
||||
...condition,
|
||||
right: normalizeConditionRight(event.target.value),
|
||||
},
|
||||
})}
|
||||
placeholder="请输入比较值"
|
||||
/>
|
||||
</div>
|
||||
<ConditionRightControl
|
||||
value={conditionRight}
|
||||
variable={selectedVariable}
|
||||
onChange={(right) => commitBranch(branch.id, {
|
||||
condition: { ...condition, right },
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -422,25 +418,126 @@ const conditionOperators = [
|
||||
{ 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 conditionOperatorWithoutRight(operator: string) {
|
||||
return ["exists", "not_exists", "truthy", "falsy"].includes(operator)
|
||||
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 normalizeConditionRight(value: string) {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
if (variable?.type === "boolean") {
|
||||
return [
|
||||
{ value: "true", label: "是" },
|
||||
{ value: "false", label: "否" },
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function encodeConditionRight(value: unknown) {
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(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 { condition: _condition, ...rest } = branch
|
||||
|
||||
@@ -15,7 +15,7 @@ export function VariableSelector({
|
||||
}) {
|
||||
const options = variables.map((item) => ({
|
||||
value: `${item.nodeId}.${item.field}`,
|
||||
label: `${item.nodeName}.${item.field} · ${item.type}`,
|
||||
label: `${item.nodeName}.${item.label || item.field} · ${item.type}`,
|
||||
}))
|
||||
const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
type WorkflowHistory,
|
||||
type WorkflowHelperLine,
|
||||
type WorkflowNodeConfig,
|
||||
type WorkflowVariableSpec,
|
||||
} from "./workflow-utils"
|
||||
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
|
||||
import type { WorkflowBranchTargetOption } from "./node-config-panel"
|
||||
@@ -250,8 +251,8 @@ export function WorkflowEditor({
|
||||
[draft, nodeSpecs, propertyPanelNode]
|
||||
)
|
||||
const propertyPanelBranchSummaries = useMemo(
|
||||
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id) : []),
|
||||
[nodes, propertyPanelNode]
|
||||
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id, nodeSpecs) : []),
|
||||
[nodeSpecs, nodes, propertyPanelNode]
|
||||
)
|
||||
const propertyPanelBranchTargetOptions = useMemo(
|
||||
() => (propertyPanelNode ? getBranchTargetOptions(nodes, edges, propertyPanelNode.id) : []),
|
||||
@@ -1012,7 +1013,9 @@ const conditionOperators = [
|
||||
{ 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: "小于" },
|
||||
@@ -1021,7 +1024,8 @@ const conditionOperators = [
|
||||
|
||||
function getBranchSummaries(
|
||||
nodes: WorkflowFlowNode[],
|
||||
nodeId: string
|
||||
nodeId: string,
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
): WorkflowBranchSummary[] {
|
||||
const node = nodes.find((item) => item.id === nodeId)
|
||||
const branches = node?.data.config?.branches ?? []
|
||||
@@ -1032,7 +1036,7 @@ function getBranchSummaries(
|
||||
branchId: branch.id,
|
||||
targetNodeId: branch.targetNodeId,
|
||||
targetName: target?.data.name ?? target?.data.title ?? branch.targetNodeId,
|
||||
conditionLabel: branch.condition ? formatConditionLabel(branch.condition) : "无条件匹配",
|
||||
conditionLabel: branch.condition ? formatConditionLabel(branch.condition, nodes, nodeSpecs) : "无条件匹配",
|
||||
isDefault: Boolean(branch.default),
|
||||
}
|
||||
})
|
||||
@@ -1054,31 +1058,62 @@ function getBranchTargetOptions(
|
||||
})
|
||||
}
|
||||
|
||||
function formatConditionLabel(condition: WorkflowCondition) {
|
||||
const left = condition.left?.nodeId && condition.left.field
|
||||
? `${condition.left.nodeId}.${condition.left.field}`
|
||||
: "未选择变量"
|
||||
function formatConditionLabel(
|
||||
condition: WorkflowCondition,
|
||||
nodes: WorkflowFlowNode[],
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
) {
|
||||
const variable = findConditionOutputSpec(condition.left, nodes, nodeSpecs)
|
||||
const left = variable?.label
|
||||
?? (condition.left?.nodeId && condition.left.field ? `${condition.left.nodeId}.${condition.left.field}` : "未选择变量")
|
||||
const operator = conditionOperators.find((item) => item.value === condition.operator)?.label
|
||||
?? condition.operator
|
||||
?? "未选择判断方式"
|
||||
|
||||
if (["exists", "not_exists", "truthy", "falsy"].includes(condition.operator ?? "")) {
|
||||
if (["exists", "not_exists", "truthy", "is_true", "falsy", "is_false"].includes(condition.operator ?? "")) {
|
||||
return `${left} ${operator}`
|
||||
}
|
||||
|
||||
return `${left} ${operator} ${formatConditionRight(condition.right)}`
|
||||
return `${left} ${operator} ${formatConditionRight(condition.right, variable)}`
|
||||
}
|
||||
|
||||
function formatConditionRight(value: unknown) {
|
||||
function formatConditionRight(value: unknown, variable?: WorkflowVariableSpec) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "未填写比较值"
|
||||
}
|
||||
const option = variable?.valueOptions?.find((item) => conditionValueEquals(item.value, value))
|
||||
if (option) {
|
||||
return option.label
|
||||
}
|
||||
if (variable?.type === "boolean") {
|
||||
return value === true ? "是" : "否"
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function findConditionOutputSpec(
|
||||
selector: WorkflowCondition["left"],
|
||||
nodes: WorkflowFlowNode[],
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
): WorkflowVariableSpec | undefined {
|
||||
if (!selector?.nodeId || !selector.field) {
|
||||
return undefined
|
||||
}
|
||||
const sourceNode = nodes.find((item) => item.id === selector.nodeId)
|
||||
if (!sourceNode) {
|
||||
return undefined
|
||||
}
|
||||
const spec = getNodeSpec(nodeSpecs, sourceNode.data.nodeType ?? "")
|
||||
return spec?.outputSchema?.find((item) => item.name === selector.field)
|
||||
}
|
||||
|
||||
function conditionValueEquals(left: unknown, right: unknown) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
function getEventClientPoint(event: MouseEvent | TouchEvent) {
|
||||
if ("changedTouches" in event) {
|
||||
const touch = event.changedTouches[0] ?? event.touches[0]
|
||||
|
||||
@@ -317,6 +317,49 @@ describe("getAvailableVariables", () => {
|
||||
|
||||
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: "直接回复客户" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toApiDefinition", () => {
|
||||
|
||||
@@ -108,11 +108,20 @@ export type WorkflowVariableSelector = {
|
||||
field: string
|
||||
}
|
||||
|
||||
export type WorkflowVariableValueOption = {
|
||||
value: unknown
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type WorkflowVariableSpec = {
|
||||
name: string
|
||||
label?: string
|
||||
type: WorkflowVariableType
|
||||
required?: boolean
|
||||
description?: string
|
||||
operators?: string[]
|
||||
valueOptions?: WorkflowVariableValueOption[]
|
||||
}
|
||||
|
||||
export type WorkflowNodeSpec = {
|
||||
@@ -128,8 +137,11 @@ export type WorkflowVariableRef = {
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
field: string
|
||||
label?: string
|
||||
type: string
|
||||
description: string
|
||||
operators?: string[]
|
||||
valueOptions?: WorkflowVariableValueOption[]
|
||||
}
|
||||
|
||||
export type WorkflowDraftValidation = {
|
||||
@@ -647,8 +659,11 @@ export function getAvailableVariables(
|
||||
nodeId: sourceNode.id,
|
||||
nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id,
|
||||
field: output.name,
|
||||
label: output.label,
|
||||
type: output.type,
|
||||
description: output.description ?? "",
|
||||
operators: output.operators,
|
||||
valueOptions: output.valueOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,9 +301,16 @@ export type AIWorkflowVariableSelector = {
|
||||
|
||||
export type AIWorkflowVariableSpec = {
|
||||
name: string
|
||||
label?: string
|
||||
type: AIWorkflowVariableType
|
||||
required?: boolean
|
||||
description: string
|
||||
operators?: string[]
|
||||
valueOptions?: {
|
||||
value: unknown
|
||||
label: string
|
||||
description?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type AIWorkflowDefinition = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type PublicConfig = {
|
||||
language: string
|
||||
wxworkEnabled: boolean
|
||||
oidcEnabled: boolean
|
||||
}
|
||||
|
||||
let publicConfigPromise: Promise<PublicConfig> | null = null
|
||||
|
||||
export function fetchPublicConfig() {
|
||||
publicConfigPromise ??= request<PublicConfig>("/api/config", {
|
||||
skipAuth: true,
|
||||
}).catch((error) => {
|
||||
publicConfigPromise = null
|
||||
throw error
|
||||
})
|
||||
return publicConfigPromise
|
||||
}
|
||||
Reference in New Issue
Block a user