feat: add validation for default condition branch position and enhance branch connection handling
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user