feat: add validation for default condition branch position and enhance branch connection handling

This commit is contained in:
mlogclub
2026-06-27 17:00:41 +08:00
parent f85fb6d406
commit 2143bfa06b
6 changed files with 432 additions and 110 deletions
@@ -310,6 +310,9 @@ func (v *definitionValidator) validateConditions() {
if branch.Condition != nil {
v.addError(branchField+".condition", "default condition branch must not define a condition")
}
if branchIndex != len(config.Branches)-1 {
v.addError(branchField, "default condition branch must be last")
}
continue
}
v.validateCondition(branchField+".condition", strings.TrimSpace(node.ID), branch.Condition)
@@ -317,6 +317,29 @@ func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
}
}
func TestValidateDefinitionRejectsConditionDefaultBranchBeforeLast(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0], config.Branches[1] = config.Branches[1], config.Branches[0]
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
}
def.Nodes[1].Config = raw
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected default branch before last to be invalid")
}
if !hasValidationMessage(result, "default condition branch must be last") {
t.Fatalf("expected default branch order error, got %#v", result.Errors)
}
}
func minimalDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
@@ -34,24 +34,17 @@ export type WorkflowBranchSummary = {
isDefault: boolean
}
export type WorkflowBranchTargetOption = {
value: string
label: string
}
export function NodeConfigPanel({
node,
nodeSpec,
availableVariables,
branchSummaries = [],
branchTargetOptions = [],
onChange,
}: {
node: Node<WorkflowNodeData> | null
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
branchSummaries?: WorkflowBranchSummary[]
branchTargetOptions?: WorkflowBranchTargetOption[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
if (!node) {
@@ -69,7 +62,6 @@ export function NodeConfigPanel({
nodeSpec={nodeSpec}
availableVariables={availableVariables}
branchSummaries={branchSummaries}
branchTargetOptions={branchTargetOptions}
onChange={onChange}
/>
)
@@ -80,14 +72,12 @@ function NodeConfigForm({
nodeSpec,
availableVariables,
branchSummaries,
branchTargetOptions,
onChange,
}: {
node: Node<WorkflowNodeData>
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
branchSummaries: WorkflowBranchSummary[]
branchTargetOptions: WorkflowBranchTargetOption[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
const [name, setName] = useState(node.data.name ?? "")
@@ -145,7 +135,6 @@ function NodeConfigForm({
<ConditionNodePanel
branches={node.data.config?.branches ?? []}
branchSummaries={branchSummaries}
branchTargetOptions={branchTargetOptions}
availableVariables={availableVariables}
outputSchema={outputSchema}
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
@@ -237,14 +226,12 @@ function NodeConfigForm({
function ConditionNodePanel({
branches,
branchSummaries,
branchTargetOptions,
availableVariables,
outputSchema,
onChange,
}: {
branches: WorkflowConditionBranch[]
branchSummaries: WorkflowBranchSummary[]
branchTargetOptions: WorkflowBranchTargetOption[]
availableVariables: WorkflowVariableRef[]
outputSchema: WorkflowVariableSpec[]
onChange: (branches: WorkflowConditionBranch[]) => void
@@ -257,25 +244,49 @@ function ConditionNodePanel({
}
const addBranch = () => {
const index = branches.length + 1
const nextBranch = {
id: `branch_${index}`,
name: `分支 ${index}`,
targetNodeId: "",
condition: { operator: "eq" },
}
const defaultIndex = branches.findIndex((branch) => branch.default)
if (defaultIndex >= 0) {
onChange([
...branches.slice(0, defaultIndex),
nextBranch,
...branches.slice(defaultIndex),
])
return
}
onChange([
...branches,
nextBranch,
{
id: `branch_${index}`,
name: `分支 ${index}`,
targetNodeId: branchTargetOptions[0]?.value ?? "",
condition: { operator: "eq" },
id: "default",
name: "其他情况",
targetNodeId: "",
default: true,
},
])
}
const deleteBranch = (branchId: string) => {
onChange(branches.filter((branch) => branch.id !== branchId))
onChange(branches.filter((branch) => branch.id !== branchId || branch.default))
}
const markDefault = (branchId: string) => {
onChange(branches.map((branch) => normalizeBranch({
...branch,
default: branch.id === branchId,
condition: branch.id === branchId ? undefined : branch.condition ?? { operator: "eq" },
})))
const moveBranch = (branchId: string, direction: -1 | 1) => {
const index = branches.findIndex((branch) => branch.id === branchId)
if (index < 0 || branches[index]?.default) {
return
}
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= branches.length || branches[nextIndex]?.default) {
return
}
const next = [...branches]
const current = next[index]
next[index] = next[nextIndex]
next[nextIndex] = current
onChange(next)
}
return (
@@ -317,14 +328,11 @@ function ConditionNodePanel({
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<OptionCombobox
value={branch.targetNodeId}
options={branchTargetOptions}
placeholder="选择目标节点"
searchPlaceholder="搜索目标节点"
emptyText="请先从条件节点连出下游节点"
onChange={(value) => commitBranch(branch.id, { targetNodeId: value })}
/>
<div className="rounded-md border border-dashed bg-muted/20 px-2 py-2 text-xs text-muted-foreground">
{summary?.targetNodeId
? `已连接到:${summary.targetName}`
: "请从画布中该分支右侧连接点拖线到目标节点"}
</div>
</div>
{branch.default ? (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
@@ -367,14 +375,21 @@ function ConditionNodePanel({
</div>
)}
<div className="flex flex-wrap gap-2">
{!branch.default ? (
<Button type="button" size="sm" variant="outline" onClick={() => markDefault(branch.id)}>
{!branch.default && index > 0 ? (
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, -1)}>
</Button>
) : null}
{!branch.default && index < branches.findIndex((item) => item.default) - 1 ? (
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, 1)}>
</Button>
) : null}
{!branch.default ? (
<Button type="button" size="sm" variant="outline" onClick={() => deleteBranch(branch.id)}>
</Button>
) : null}
<Button type="button" size="sm" variant="outline" onClick={() => deleteBranch(branch.id)}>
</Button>
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary?.conditionLabel ?? "尚未完成分支配置"}
@@ -52,12 +52,15 @@ import {
import { cn } from "@/lib/utils"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
applyConditionBranchConnection,
applyAutoInputMappings,
calculateWorkflowHelperLines,
clearConditionBranchConnection,
createWorkflowHistory,
createWorkflowNodeFromSpec,
fromApiDefinition,
getAvailableVariables,
getConditionBranchHandleId,
getNodeSpec,
getRequiredInputs,
pushWorkflowHistory,
@@ -66,6 +69,7 @@ import {
undoWorkflowHistory,
validateWorkflowDraft,
type WorkflowCondition,
type WorkflowDraft,
type WorkflowEditorNode,
type WorkflowHistory,
type WorkflowHelperLine,
@@ -73,7 +77,6 @@ import {
type WorkflowVariableSpec,
} from "./workflow-utils"
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
import type { WorkflowBranchTargetOption } from "./node-config-panel"
type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
@@ -88,6 +91,7 @@ type WorkflowNodeData = Record<string, unknown> & {
inputCount?: number
outputCount?: number
missingInputs?: string[]
branchSummaries?: WorkflowBranchSummary[]
}
type WorkflowFlowNode = Node<WorkflowNodeData>
@@ -157,10 +161,25 @@ function toFlowEdges(definition: AIWorkflowDefinition): WorkflowFlowEdge[] {
type: "workflowEdge",
source: edge.source,
target: edge.target,
sourceHandle: getConditionBranchHandleForEdge(definition, edge.source, edge.target),
}))
}
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
function getConditionBranchHandleForEdge(
definition: AIWorkflowDefinition,
sourceNodeId: string,
targetNodeId: string
) {
const sourceNode = definition.nodes?.find((node) => node.id === sourceNodeId)
if (!sourceNode || sourceNode.type !== "condition") {
return undefined
}
const config = sourceNode.config as WorkflowNodeConfig | undefined
const branch = config?.branches?.find((item) => item.targetNodeId === targetNodeId)
return branch ? getConditionBranchHandleId(branch.id) : undefined
}
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]): WorkflowDraft {
return {
nodes: nodes.map((node) => ({
id: node.id,
@@ -177,6 +196,8 @@ function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})),
}
}
@@ -254,10 +275,6 @@ export function WorkflowEditor({
() => (propertyPanelNode ? getBranchSummaries(nodes, propertyPanelNode.id, nodeSpecs) : []),
[nodeSpecs, nodes, propertyPanelNode]
)
const propertyPanelBranchTargetOptions = useMemo(
() => (propertyPanelNode ? getBranchTargetOptions(nodes, edges, propertyPanelNode.id) : []),
[edges, nodes, propertyPanelNode]
)
useEffect(() => {
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange])
@@ -415,18 +432,28 @@ export function WorkflowEditor({
pushCurrentSnapshotToHistory()
const edge = {
...connection,
id: uniqueEdgeId(edges, connection.source, connection.target),
id: uniqueEdgeId(edges, connection.source, connection.target, connection.sourceHandle),
type: "workflowEdge",
} as WorkflowFlowEdge
setEdges((current) => addEdge(edge, current))
const nextEdges = [
...edges.filter((item) => !(
connection.sourceHandle
&& item.source === connection.source
&& item.sourceHandle === connection.sourceHandle
)),
edge,
]
setEdges((current) => addEdge(edge, current.filter((item) => !(
connection.sourceHandle
&& item.source === connection.source
&& item.sourceHandle === connection.sourceHandle
))))
setNodes((currentNodes) => {
const currentDraft = toDraft(currentNodes, [...edges, edge])
const nextDraft = applyAutoInputMappings(
currentDraft,
connection.source!,
connection.target!,
nodeSpecs
const connectedDraft = applyConditionBranchConnection(
toDraft(currentNodes, nextEdges),
edge
)
const nextDraft = applyAutoInputMappings(connectedDraft, connection.source!, connection.target!, nodeSpecs)
return currentNodes.map((node) => {
const nextNode = nextDraft.nodes.find((item) => item.id === node.id)
if (!nextNode) {
@@ -436,6 +463,7 @@ export function WorkflowEditor({
...node,
data: {
...node.data,
config: nextNode.data?.config ?? node.data.config,
inputs: nextNode.data?.inputs ?? node.data.inputs,
},
}
@@ -498,12 +526,41 @@ export function WorkflowEditor({
const onWorkflowEdgesChange = useCallback(
(changes: EdgeChange<WorkflowFlowEdge>[]) => {
if (changes.some((change) => change.type === "remove")) {
const removedEdges = changes
.filter((change) => change.type === "remove")
.map((change) => edges.find((edge) => edge.id === change.id))
.filter((edge): edge is WorkflowFlowEdge => Boolean(edge))
if (removedEdges.length > 0) {
pushCurrentSnapshotToHistory()
setNodes((currentNodes) => {
let draft = toDraft(currentNodes, edges.filter((edge) => !removedEdges.some((removed) => removed.id === edge.id)))
for (const removedEdge of removedEdges) {
draft = clearConditionBranchConnection(draft, {
id: removedEdge.id,
source: removedEdge.source,
target: removedEdge.target,
sourceHandle: removedEdge.sourceHandle,
targetHandle: removedEdge.targetHandle,
})
}
return currentNodes.map((node) => {
const nextNode = draft.nodes.find((item) => item.id === node.id)
if (!nextNode) {
return node
}
return {
...node,
data: {
...node.data,
config: nextNode.data?.config ?? node.data.config,
},
}
})
})
}
onEdgesChange(changes)
},
[onEdgesChange, pushCurrentSnapshotToHistory]
[edges, onEdgesChange, pushCurrentSnapshotToHistory, setNodes]
)
const onNodeDragStart = useCallback<OnNodeDrag<WorkflowFlowNode>>(() => {
@@ -938,7 +995,6 @@ export function WorkflowEditor({
nodeSpec={propertyPanelNodeSpec}
availableVariables={propertyPanelAvailableVariables}
branchSummaries={propertyPanelBranchSummaries}
branchTargetOptions={propertyPanelBranchTargetOptions}
onChange={updateNodeData}
/>
) : null}
@@ -972,12 +1028,13 @@ export function WorkflowEditor({
)
}
function uniqueEdgeId(edges: WorkflowFlowEdge[], source: string, target: string) {
function uniqueEdgeId(edges: WorkflowFlowEdge[], source: string, target: string, sourceHandle?: string | null) {
let nextIndex = edges.length + 1
let id = `edge_${source}_${target}_${nextIndex}`
const handleSuffix = sourceHandle ? `_${sourceHandle.replace(/[^a-zA-Z0-9_-]/g, "_")}` : ""
let id = `edge_${source}${handleSuffix}_${target}_${nextIndex}`
while (edges.some((edge) => edge.id === id)) {
nextIndex += 1
id = `edge_${source}_${target}_${nextIndex}`
id = `edge_${source}${handleSuffix}_${target}_${nextIndex}`
}
return id
}
@@ -1001,6 +1058,7 @@ function enrichNodesForRender(
inputCount: spec?.inputSchema?.length ?? 0,
outputCount: spec?.outputSchema?.length ?? 0,
missingInputs: missingInputs.map((input) => input.name),
branchSummaries: node.data.nodeType === "condition" ? getBranchSummaries(nodes, node.id, nodeSpecs) : undefined,
},
}
})
@@ -1042,22 +1100,6 @@ function getBranchSummaries(
})
}
function getBranchTargetOptions(
nodes: WorkflowFlowNode[],
edges: WorkflowFlowEdge[],
nodeId: string
): WorkflowBranchTargetOption[] {
return edges
.filter((edge) => edge.source === nodeId)
.map((edge) => {
const target = nodes.find((node) => node.id === edge.target)
return {
value: edge.target,
label: target?.data.name ?? target?.data.title ?? edge.target,
}
})
}
function formatConditionLabel(
condition: WorkflowCondition,
nodes: WorkflowFlowNode[],
@@ -1226,16 +1268,19 @@ function WorkflowCanvasEdge({
}
function WorkflowNodeHandle({
id,
type,
position,
className,
}: {
id?: string
type: "source" | "target"
position: Position
className?: string
}) {
return (
<Handle
id={id}
type={type}
position={position}
className={className}
@@ -1318,45 +1363,70 @@ function WorkflowCanvasNode({ id, data, selected }: NodeProps<WorkflowFlowNode>)
showHandles ? "pointer-events-auto opacity-100" : "pointer-events-none"
)
if (isConditionNode) {
const branches = data.branchSummaries ?? []
return (
<div
className="group/node relative flex size-36 items-center justify-center"
className={[
"group/node relative w-72 rounded-xl border bg-background shadow-[0_12px_34px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.12)]",
selected ? "border-primary ring-4 ring-primary/10" : "",
hasIssue ? "border-destructive/70" : "border-border/70",
].join(" ")}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div
className={[
"absolute inset-4 rotate-45 rounded-xl border bg-background shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-all",
selected ? "border-primary ring-4 ring-primary/10" : "",
hasIssue ? "border-destructive/70" : "border-border/70",
].join(" ")}
/>
<WorkflowNodeHandle
type="target"
position={Position.Left}
className={cn("!left-0", handleClassName)}
/>
<div className="relative z-10 flex max-w-24 flex-col items-center text-center">
{hasIssue ? (
<AlertCircleIcon className="mb-1 size-4 text-destructive" />
) : (
<CheckCircle2Icon className="mb-1 size-4 text-emerald-600" />
)}
<div className="line-clamp-2 text-sm font-medium leading-tight">{data.name ?? data.title}</div>
<div className="mt-1 text-[11px] text-muted-foreground"></div>
<div className="overflow-hidden rounded-xl">
<div className="flex items-start gap-2 border-b border-border/60 bg-muted/20 px-3 py-2.5">
<div
className={cn(
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg",
hasIssue ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-700"
)}
>
{hasIssue ? (
<AlertCircleIcon className="size-4" />
) : (
<CheckCircle2Icon className="size-4" />
)}
</div>
<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"></div>
</div>
</div>
<div className="divide-y divide-border/60 text-xs">
{branches.length > 0 ? branches.map((branch, index) => (
<div key={branch.branchId} className="relative flex items-center gap-2 px-3 py-2.5 pr-6">
<span
className={cn(
"shrink-0 rounded-sm px-1.5 py-0.5 font-medium",
branch.isDefault ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
)}
>
{branch.isDefault ? "ELSE" : index === 0 ? "IF" : "ELIF"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{branch.conditionLabel}</div>
<div className="mt-0.5 truncate text-muted-foreground">
{branch.targetNodeId ? `连接到:${branch.targetName}` : "未连接目标节点"}
</div>
</div>
<WorkflowNodeHandle
id={getConditionBranchHandleId(branch.branchId)}
type="source"
position={Position.Right}
className={cn("!right-[-8px] !top-1/2 !-translate-y-1/2", handleClassName)}
/>
</div>
)) : (
<div className="px-3 py-3 text-muted-foreground"></div>
)}
</div>
</div>
<WorkflowNodeHandle
type="source"
position={Position.Right}
className={cn("!right-0", handleClassName)}
/>
<WorkflowAddAfterButton
nodeId={id}
visible={showHandles}
className="right-4 top-4"
nodeSpecs={nodeSpecs}
onAddAfter={onAddAfter}
/>
</div>
)
}
@@ -80,6 +80,60 @@ describe("validateWorkflowDraft", () => {
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
})
it("rejects condition branch target without matching branch handle edge", async () => {
const { getConditionBranchHandleId, validateWorkflowDraft } = await loadModule()
const result = validateWorkflowDraft({
nodes: [
{ id: "start_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "start" } },
{
id: "condition_1",
type: "workflowNode",
position: { x: 200, y: 0 },
data: {
nodeType: "condition",
name: "Route",
config: {
branches: [
{
id: "direct",
name: "Direct",
targetNodeId: "send_1",
condition: {
left: { nodeId: "start_1", field: "userMessage" },
operator: "eq",
right: "hello",
},
},
{
id: "default",
name: "Else",
targetNodeId: "send_1",
default: true,
},
],
},
},
},
{ id: "send_1", type: "workflowNode", position: { x: 400, y: 0 }, data: { nodeType: "send_reply" } },
{ id: "end_1", type: "workflowNode", position: { x: 600, y: 0 }, data: { nodeType: "end" } },
],
edges: [
{ id: "e1", source: "start_1", target: "condition_1" },
{
id: "e2",
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("default"),
},
{ id: "e3", source: "send_1", target: "end_1" },
],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /对应分支连接点/)
})
})
describe("applyAutoInputMappings", () => {
@@ -363,6 +417,76 @@ describe("getAvailableVariables", () => {
})
describe("toApiDefinition", () => {
it("updates condition branch target from branch handle connection", async () => {
const { applyConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
const draft = applyConditionBranchConnection(
{
nodes: [
{
id: "condition_1",
type: "workflowNode",
position: { x: 0, y: 0 },
data: {
nodeType: "condition",
config: {
branches: [
{ id: "direct", name: "Direct", targetNodeId: "", condition: { operator: "eq" } },
{ id: "default", name: "Else", targetNodeId: "", default: true },
],
},
},
},
{ id: "send_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "send_reply" } },
],
edges: [],
},
{
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("direct"),
}
)
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "send_1")
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "")
})
it("clears condition branch target when the branch edge is removed", async () => {
const { clearConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
const draft = clearConditionBranchConnection(
{
nodes: [
{
id: "condition_1",
type: "workflowNode",
position: { x: 0, y: 0 },
data: {
nodeType: "condition",
config: {
branches: [
{ id: "direct", name: "Direct", targetNodeId: "send_1", condition: { operator: "eq" } },
{ id: "default", name: "Else", targetNodeId: "fallback_1", default: true },
],
},
},
},
],
edges: [],
},
{
id: "edge_condition_send",
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("direct"),
}
)
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "")
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "fallback_1")
})
it("keeps condition branches on the condition node config and exports plain edges", async () => {
const { toApiDefinition } = await loadModule()
@@ -48,6 +48,8 @@ export type WorkflowEditorEdge = {
id: string
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
}
export type WorkflowCondition = {
@@ -162,6 +164,19 @@ export type WorkflowHistoryChange<T> = {
const helperLineAlignmentThreshold = 6
const defaultWorkflowHistoryLimit = 50
const conditionBranchHandlePrefix = "condition-branch:"
export function getConditionBranchHandleId(branchId: string): string {
return `${conditionBranchHandlePrefix}${branchId}`
}
export function parseConditionBranchHandleId(handleId?: string | null): string | null {
if (!handleId?.startsWith(conditionBranchHandlePrefix)) {
return null
}
const branchId = handleId.slice(conditionBranchHandlePrefix.length)
return branchId || null
}
function cloneHistorySnapshot<T>(snapshot: T): T {
return JSON.parse(JSON.stringify(snapshot)) as T
@@ -373,6 +388,7 @@ export function validateWorkflowDraft(
const edgeIds = new Set<string>()
const outgoingTargets = new Map<string, Set<string>>()
const branchEdges = new Set<string>()
for (const edge of draft.edges) {
const id = edge.id.trim()
if (!id) {
@@ -391,19 +407,22 @@ export function validateWorkflowDraft(
outgoingTargets.set(edge.source, new Set())
}
outgoingTargets.get(edge.source)?.add(edge.target)
const branchId = parseConditionBranchHandleId(edge.sourceHandle)
if (branchId) {
branchEdges.add(`${edge.source}:${branchId}:${edge.target}`)
}
}
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}」,请选择上游节点的输出变量。`)
if (spec) {
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}」,请选择上游节点的输出变量。`)
}
}
}
if (nodeType === "condition") {
@@ -429,12 +448,17 @@ export function validateWorkflowDraft(
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」目标节点不存在。`)
} else if (!targets.has(branch.targetNodeId)) {
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」需要连接到目标节点。`)
} else if (branchEdges.size > 0 && !branchEdges.has(`${node.id}:${branch.id}:${branch.targetNodeId}`)) {
errors.push(`${node.data?.name ?? node.id} 的分支「${branchName}」需要从对应分支连接点连到目标节点。`)
}
if (branch.default) {
defaultCount += 1
if (branch.condition) {
errors.push(`${node.data?.name ?? node.id} 的默认分支不能配置条件。`)
}
if (branches.indexOf(branch) !== branches.length - 1) {
errors.push(`${node.data?.name ?? node.id} 的默认分支必须放在最后。`)
}
continue
}
if (!branch.condition?.left?.nodeId || !branch.condition.left.field) {
@@ -480,6 +504,69 @@ export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
}
}
export function applyConditionBranchConnection(
draft: WorkflowDraft,
connection: Pick<WorkflowEditorEdge, "source" | "target" | "sourceHandle">
): WorkflowDraft {
const branchId = parseConditionBranchHandleId(connection.sourceHandle)
if (!branchId || !connection.source || !connection.target) {
return draft
}
return updateConditionBranchTarget(draft, connection.source, branchId, connection.target)
}
export function clearConditionBranchConnection(
draft: WorkflowDraft,
edge: WorkflowEditorEdge
): WorkflowDraft {
const branchId = parseConditionBranchHandleId(edge.sourceHandle)
if (branchId) {
return updateConditionBranchTarget(draft, edge.source, branchId, "")
}
const sourceNode = draft.nodes.find((node) => node.id === edge.source)
if (!sourceNode || (sourceNode.data?.nodeType ?? sourceNode.type) !== "condition") {
return draft
}
const branch = sourceNode.data?.config?.branches?.find((item) => item.targetNodeId === edge.target)
return branch ? updateConditionBranchTarget(draft, edge.source, branch.id, "") : draft
}
function updateConditionBranchTarget(
draft: WorkflowDraft,
conditionNodeId: string,
branchId: string,
targetNodeId: string
): WorkflowDraft {
let changed = false
const nodes = draft.nodes.map((node) => {
if (node.id !== conditionNodeId || (node.data?.nodeType ?? node.type) !== "condition") {
return node
}
const branches = node.data?.config?.branches ?? []
const nextBranches = branches.map((branch) => {
if (branch.id !== branchId || branch.targetNodeId === targetNodeId) {
return branch
}
changed = true
return { ...branch, targetNodeId }
})
if (!changed) {
return node
}
return {
...node,
data: {
...node.data,
config: {
...(node.data?.config ?? {}),
branches: nextBranches,
},
},
}
})
return changed ? { ...draft, nodes } : draft
}
export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft {
return {
nodes: (definition.nodes ?? []).map((node) => ({