From f85fb6d406b3dc75895057876764083ba8354d62 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 27 Jun 2026 12:50:29 +0800 Subject: [PATCH] feat: enhance condition handling with enum value options and metadata in workflow components --- internal/ai/workflow/registry/registry.go | 74 +++++++++- internal/ai/workflow/registry/spec.go | 17 ++- internal/ai/workflow/validator/validator.go | 66 ++++++++- .../ai/workflow/validator/validator_test.go | 59 ++++++++ internal/builders/ai_workflow_builder_test.go | 48 +++++++ .../_components/node-config-panel.tsx | 131 +++++++++++++++--- .../_components/variable-selector.tsx | 2 +- .../_components/workflow-editor.tsx | 57 ++++++-- .../_components/workflow-utils.test.mjs | 43 ++++++ .../_components/workflow-utils.ts | 15 ++ web/lib/api/admin.ts | 7 + web/lib/api/config.ts | 19 +++ 12 files changed, 497 insertions(+), 41 deletions(-) create mode 100644 web/lib/api/config.ts diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index 49b6f1c..3715aba 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -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} +} diff --git a/internal/ai/workflow/registry/spec.go b/internal/ai/workflow/registry/spec.go index 240e4a1..f5a35e1 100644 --- a/internal/ai/workflow/registry/spec.go +++ b/internal/ai/workflow/registry/spec.go @@ -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 { diff --git a/internal/ai/workflow/validator/validator.go b/internal/ai/workflow/validator/validator.go index 4e0bcdb..7259bfc 100644 --- a/internal/ai/workflow/validator/validator.go +++ b/internal/ai/workflow/validator/validator.go @@ -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 diff --git a/internal/ai/workflow/validator/validator_test.go b/internal/ai/workflow/validator/validator_test.go index 54037bd..3017e65 100644 --- a/internal/ai/workflow/validator/validator_test.go +++ b/internal/ai/workflow/validator/validator_test.go @@ -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{ diff --git a/internal/builders/ai_workflow_builder_test.go b/internal/builders/ai_workflow_builder_test.go index dc99fb4..1d254df 100644 --- a/internal/builders/ai_workflow_builder_test.go +++ b/internal/builders/ai_workflow_builder_test.go @@ -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 +} diff --git a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx index 2cedbe3..2b22301 100644 --- a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx +++ b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx @@ -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({ {!conditionOperatorWithoutRight(condition.operator ?? "eq") ? ( -
- - commitBranch(branch.id, { - condition: { - ...condition, - right: normalizeConditionRight(event.target.value), - }, - })} - placeholder="请输入比较值" - /> -
+ commitBranch(branch.id, { + condition: { ...condition, right }, + })} + /> ) : null} )} @@ -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 ( +
+ + onChange(decodeConditionRight(nextValue, variable))} + /> +
+ ) + } + + return ( +
+ + onChange(normalizeConditionRight(event.target.value, variable))} + placeholder={variable ? `请输入${variable.label || variable.field}的比较值` : "请输入比较值"} + /> +
+ ) } -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 diff --git a/web/app/dashboard/ai-workflows/_components/variable-selector.tsx b/web/app/dashboard/ai-workflows/_components/variable-selector.tsx index 16202f0..a4b8b17 100644 --- a/web/app/dashboard/ai-workflows/_components/variable-selector.tsx +++ b/web/app/dashboard/ai-workflows/_components/variable-selector.tsx @@ -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}` : "" diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx index 15df002..3eef9d9 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx @@ -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] diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs index 685f9bf..3a8d930 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs @@ -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", () => { diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts index a0113b6..92763b2 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts @@ -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, }) } } diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 0ad08e2..855c6f1 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -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 = { diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts new file mode 100644 index 0000000..e96af10 --- /dev/null +++ b/web/lib/api/config.ts @@ -0,0 +1,19 @@ +import { request } from "@/lib/api/client" + +export type PublicConfig = { + language: string + wxworkEnabled: boolean + oidcEnabled: boolean +} + +let publicConfigPromise: Promise | null = null + +export function fetchPublicConfig() { + publicConfigPromise ??= request("/api/config", { + skipAuth: true, + }).catch((error) => { + publicConfigPromise = null + throw error + }) + return publicConfigPromise +}