feat: enhance condition handling with enum value options and metadata in workflow components

This commit is contained in:
mlogclub
2026-06-27 12:50:29 +08:00
parent d4ff0980c4
commit f85fb6d406
12 changed files with 497 additions and 41 deletions
+67 -7
View File
@@ -44,8 +44,25 @@ func DefaultRegistry() *Registry {
}, },
OutputSchema: []VariableSpec{ OutputSchema: []VariableSpec{
output("normalizedMessage", VariableTypeString, "Normalized customer message."), output("normalizedMessage", VariableTypeString, "Normalized customer message."),
output("messageIntent", VariableTypeString, "Detected customer message intent."), enumOutput("messageIntent", "消息意图", "Detected customer message intent.", []VariableValueOption{
output("answerScope", VariableTypeString, "Recommended answer scope."), 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("confidence", VariableTypeNumber, "Classifier confidence."),
output("riskSignals", VariableTypeStringArray, "Detected risk signals."), output("riskSignals", VariableTypeStringArray, "Detected risk signals."),
output("reason", VariableTypeString, "Decision reason."), output("reason", VariableTypeString, "Decision reason."),
@@ -67,12 +84,31 @@ func DefaultRegistry() *Registry {
optionalInput("answerability", VariableTypeString, "Knowledge answerability decision."), optionalInput("answerability", VariableTypeString, "Knowledge answerability decision."),
}, },
OutputSchema: []VariableSpec{ 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("replyText", VariableTypeString, "Customer-visible reply text when the policy can answer directly."),
output("reason", VariableTypeString, "Policy decision reason."), output("reason", VariableTypeString, "Policy decision reason."),
output("requiresFlow", VariableTypeBoolean, "Whether the decision should continue into workflow actions."), output("requiresFlow", VariableTypeBoolean, "Whether the decision should continue into workflow actions."),
output("targetFlow", VariableTypeString, "Suggested target flow."), enumOutput("targetFlow", "目标流程", "Suggested target flow.", []VariableValueOption{
output("finalReplySource", VariableTypeString, "Source category for the final reply."), 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{ NodeSpec{
@@ -101,7 +137,10 @@ func DefaultRegistry() *Registry {
requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."), requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
}, },
OutputSchema: []VariableSpec{ OutputSchema: []VariableSpec{
output("answerability", VariableTypeString, "Answerability decision."), enumOutput("answerability", "可回答性", "Answerability decision.", []VariableValueOption{
valueOption("answerable", "可以回答", "检索结果足够支撑回答。"),
valueOption("unanswerable", "无法回答", "检索结果不足,应该走兜底或追问。"),
}),
output("reason", VariableTypeString, "Decision reason."), output("reason", VariableTypeString, "Decision reason."),
}, },
}, },
@@ -197,7 +236,13 @@ func DefaultRegistry() *Registry {
OutputSchema: []VariableSpec{ OutputSchema: []VariableSpec{
output("handoffId", VariableTypeInteger, "Handoff operation ID."), output("handoffId", VariableTypeInteger, "Handoff operation ID."),
output("reason", VariableTypeString, "Handoff reason."), 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("teamId", VariableTypeInteger, "Assigned or pending team ID."),
output("assigneeId", VariableTypeInteger, "Assigned agent user ID."), output("assigneeId", VariableTypeInteger, "Assigned agent user ID."),
output("message", VariableTypeString, "Customer-visible handoff notice."), 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 { func output(name string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Type: variableType, Description: description} 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}
}
+9
View File
@@ -26,9 +26,18 @@ const (
type VariableSpec struct { type VariableSpec struct {
Name string `json:"name"` Name string `json:"name"`
Label string `json:"label,omitempty"`
Type VariableType `json:"type"` Type VariableType `json:"type"`
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Description string `json:"description"` 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 { type NodeSpec struct {
+65 -1
View File
@@ -357,8 +357,17 @@ func (v *definitionValidator) validateCondition(field string, sourceNodeID strin
if !ok { if !ok {
return 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) 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 { func (v *definitionValidator) hasPath(sourceID string, targetID string, visiting map[string]struct{}) bool {
if sourceID == targetID { if sourceID == targetID {
return false 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 { func minimalDefinition() dsl.Definition {
return dsl.Definition{ return dsl.Definition{
SchemaVersion: 1, 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 { func mappedReplyDefinition() dsl.Definition {
def := minimalDefinition() def := minimalDefinition()
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{ 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) { func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC) startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
endedAt := startedAt.Add(1500 * time.Millisecond) endedAt := startedAt.Add(1500 * time.Millisecond)
@@ -109,3 +139,21 @@ func hasResponseVariable(items []workflowregistry.VariableSpec, name string) boo
} }
return false 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) => { {branches.map((branch, index) => {
const summary = summariesByBranchID.get(branch.id) const summary = summariesByBranchID.get(branch.id)
const condition = branch.condition ?? {} const condition = branch.condition ?? {}
const selectedVariable = findConditionVariable(availableVariables, condition.left)
const operatorOptions = getConditionOperatorOptions(selectedVariable)
const conditionRight = condition.right === undefined || condition.right === null const conditionRight = condition.right === undefined || condition.right === null
? "" ? ""
: String(condition.right) : String(condition.right)
@@ -344,7 +346,7 @@ function ConditionNodePanel({
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<OptionCombobox <OptionCombobox
value={condition.operator ?? "eq"} value={condition.operator ?? "eq"}
options={conditionOperators} options={operatorOptions}
placeholder="选择判断方式" placeholder="选择判断方式"
searchPlaceholder="搜索判断方式" searchPlaceholder="搜索判断方式"
emptyText="没有可用判断方式" emptyText="没有可用判断方式"
@@ -354,19 +356,13 @@ function ConditionNodePanel({
/> />
</div> </div>
{!conditionOperatorWithoutRight(condition.operator ?? "eq") ? ( {!conditionOperatorWithoutRight(condition.operator ?? "eq") ? (
<div className="space-y-1.5"> <ConditionRightControl
<Label className="text-xs"></Label>
<Input
value={conditionRight} value={conditionRight}
onChange={(event) => commitBranch(branch.id, { variable={selectedVariable}
condition: { onChange={(right) => commitBranch(branch.id, {
...condition, condition: { ...condition, right },
right: normalizeConditionRight(event.target.value),
},
})} })}
placeholder="请输入比较值"
/> />
</div>
) : null} ) : null}
</div> </div>
)} )}
@@ -422,25 +418,126 @@ const conditionOperators = [
{ value: "exists", label: "存在" }, { value: "exists", label: "存在" },
{ value: "not_exists", label: "不存在" }, { value: "not_exists", label: "不存在" },
{ value: "truthy", label: "为真" }, { value: "truthy", label: "为真" },
{ value: "is_true", label: "为真" },
{ value: "falsy", label: "为假" }, { value: "falsy", label: "为假" },
{ value: "is_false", label: "为假" },
{ value: "gt", label: "大于" }, { value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" }, { value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" }, { value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" }, { value: "lte", label: "小于等于" },
] ]
function conditionOperatorWithoutRight(operator: string) { function ConditionRightControl({
return ["exists", "not_exists", "truthy", "falsy"].includes(operator) 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>
)
} }
function normalizeConditionRight(value: string) { return (
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
type={variable?.type === "number" || variable?.type === "integer" ? "number" : "text"}
value={value}
onChange={(event) => onChange(normalizeConditionRight(event.target.value, variable))}
placeholder={variable ? `请输入${variable.label || variable.field}的比较值` : "请输入比较值"}
/>
</div>
)
}
function conditionOperatorWithoutRight(operator: string) {
return ["exists", "not_exists", "truthy", "is_true", "falsy", "is_false"].includes(operator)
}
function normalizeConditionRight(value: string, variable?: WorkflowVariableRef) {
const trimmed = value.trim() 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 === "true") return true
if (trimmed === "false") return false if (trimmed === "false") return false
if (trimmed !== "" && !Number.isNaN(Number(trimmed))) return Number(trimmed) if (trimmed !== "" && !Number.isNaN(Number(trimmed))) return Number(trimmed)
return 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 { function normalizeBranch(branch: WorkflowConditionBranch): WorkflowConditionBranch {
if (branch.default) { if (branch.default) {
const { condition: _condition, ...rest } = branch const { condition: _condition, ...rest } = branch
@@ -15,7 +15,7 @@ export function VariableSelector({
}) { }) {
const options = variables.map((item) => ({ const options = variables.map((item) => ({
value: `${item.nodeId}.${item.field}`, 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}` : "" const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
@@ -70,6 +70,7 @@ import {
type WorkflowHistory, type WorkflowHistory,
type WorkflowHelperLine, type WorkflowHelperLine,
type WorkflowNodeConfig, type WorkflowNodeConfig,
type WorkflowVariableSpec,
} from "./workflow-utils" } from "./workflow-utils"
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel" import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
import type { WorkflowBranchTargetOption } from "./node-config-panel" import type { WorkflowBranchTargetOption } from "./node-config-panel"
@@ -250,8 +251,8 @@ export function WorkflowEditor({
[draft, nodeSpecs, propertyPanelNode] [draft, nodeSpecs, propertyPanelNode]
) )
const propertyPanelBranchSummaries = useMemo( const propertyPanelBranchSummaries = useMemo(
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id) : []), () => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id, nodeSpecs) : []),
[nodes, propertyPanelNode] [nodeSpecs, nodes, propertyPanelNode]
) )
const propertyPanelBranchTargetOptions = useMemo( const propertyPanelBranchTargetOptions = useMemo(
() => (propertyPanelNode ? getBranchTargetOptions(nodes, edges, propertyPanelNode.id) : []), () => (propertyPanelNode ? getBranchTargetOptions(nodes, edges, propertyPanelNode.id) : []),
@@ -1012,7 +1013,9 @@ const conditionOperators = [
{ value: "exists", label: "存在" }, { value: "exists", label: "存在" },
{ value: "not_exists", label: "不存在" }, { value: "not_exists", label: "不存在" },
{ value: "truthy", label: "为真" }, { value: "truthy", label: "为真" },
{ value: "is_true", label: "为真" },
{ value: "falsy", label: "为假" }, { value: "falsy", label: "为假" },
{ value: "is_false", label: "为假" },
{ value: "gt", label: "大于" }, { value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" }, { value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" }, { value: "lt", label: "小于" },
@@ -1021,7 +1024,8 @@ const conditionOperators = [
function getBranchSummaries( function getBranchSummaries(
nodes: WorkflowFlowNode[], nodes: WorkflowFlowNode[],
nodeId: string nodeId: string,
nodeSpecs: AIWorkflowNodeSpec[]
): WorkflowBranchSummary[] { ): WorkflowBranchSummary[] {
const node = nodes.find((item) => item.id === nodeId) const node = nodes.find((item) => item.id === nodeId)
const branches = node?.data.config?.branches ?? [] const branches = node?.data.config?.branches ?? []
@@ -1032,7 +1036,7 @@ function getBranchSummaries(
branchId: branch.id, branchId: branch.id,
targetNodeId: branch.targetNodeId, targetNodeId: branch.targetNodeId,
targetName: target?.data.name ?? target?.data.title ?? 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), isDefault: Boolean(branch.default),
} }
}) })
@@ -1054,31 +1058,62 @@ function getBranchTargetOptions(
}) })
} }
function formatConditionLabel(condition: WorkflowCondition) { function formatConditionLabel(
const left = condition.left?.nodeId && condition.left.field condition: WorkflowCondition,
? `${condition.left.nodeId}.${condition.left.field}` 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 const operator = conditionOperators.find((item) => item.value === condition.operator)?.label
?? condition.operator ?? 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}`
} }
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 === "") { if (value === undefined || value === null || value === "") {
return "未填写比较值" 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") { if (typeof value === "object") {
return JSON.stringify(value) return JSON.stringify(value)
} }
return String(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) { function getEventClientPoint(event: MouseEvent | TouchEvent) {
if ("changedTouches" in event) { if ("changedTouches" in event) {
const touch = event.changedTouches[0] ?? event.touches[0] const touch = event.changedTouches[0] ?? event.touches[0]
@@ -317,6 +317,49 @@ describe("getAvailableVariables", () => {
assert.deepEqual(plain(variables), []) 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", () => { describe("toApiDefinition", () => {
@@ -108,11 +108,20 @@ export type WorkflowVariableSelector = {
field: string field: string
} }
export type WorkflowVariableValueOption = {
value: unknown
label: string
description?: string
}
export type WorkflowVariableSpec = { export type WorkflowVariableSpec = {
name: string name: string
label?: string
type: WorkflowVariableType type: WorkflowVariableType
required?: boolean required?: boolean
description?: string description?: string
operators?: string[]
valueOptions?: WorkflowVariableValueOption[]
} }
export type WorkflowNodeSpec = { export type WorkflowNodeSpec = {
@@ -128,8 +137,11 @@ export type WorkflowVariableRef = {
nodeId: string nodeId: string
nodeName: string nodeName: string
field: string field: string
label?: string
type: string type: string
description: string description: string
operators?: string[]
valueOptions?: WorkflowVariableValueOption[]
} }
export type WorkflowDraftValidation = { export type WorkflowDraftValidation = {
@@ -647,8 +659,11 @@ export function getAvailableVariables(
nodeId: sourceNode.id, nodeId: sourceNode.id,
nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id, nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id,
field: output.name, field: output.name,
label: output.label,
type: output.type, type: output.type,
description: output.description ?? "", description: output.description ?? "",
operators: output.operators,
valueOptions: output.valueOptions,
}) })
} }
} }
+7
View File
@@ -301,9 +301,16 @@ export type AIWorkflowVariableSelector = {
export type AIWorkflowVariableSpec = { export type AIWorkflowVariableSpec = {
name: string name: string
label?: string
type: AIWorkflowVariableType type: AIWorkflowVariableType
required?: boolean required?: boolean
description: string description: string
operators?: string[]
valueOptions?: {
value: unknown
label: string
description?: string
}[]
} }
export type AIWorkflowDefinition = { export type AIWorkflowDefinition = {
+19
View File
@@ -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
}