Commit 3d0e23ca by Archer Committed by GitHub

fix: use stable branch ids for ifelse node (#7276)

parent e02e1545
......@@ -9,6 +9,7 @@ export type ConditionListItemType = {
valueType?: 'input' | 'reference';
};
export type IfElseListItemType = {
branchId?: string;
condition: IfElseConditionType;
list: ConditionListItemType[];
};
import { getNanoid } from '../../../../../common/string/tools';
import { getElseIFLabel } from '../../../utils';
import type { IfElseListItemType } from './type';
export const createIfElseBranchId = () => getNanoid();
/**
* 返回判断器分支的稳定 handle key。旧数据没有 branchId 时回退到旧展示标签,
* 保证未迁移工作流的 IF / ELSE IF n 连线仍能匹配。
*/
export const getIfElseBranchHandleKey = (item: IfElseListItemType, index: number) =>
item.branchId || getElseIFLabel(index);
const ensureUniqueBranchIds = ({
list,
createFallback
}: {
list: IfElseListItemType[];
createFallback: (item: IfElseListItemType, index: number) => string;
}) => {
const usedBranchIds = new Set<string>();
return list.map((item, index) => {
const preferredBranchId = item.branchId || createFallback(item, index);
const branchId = (() => {
if (!usedBranchIds.has(preferredBranchId)) return preferredBranchId;
let nextBranchId = createIfElseBranchId();
while (usedBranchIds.has(nextBranchId)) {
nextBranchId = createIfElseBranchId();
}
return nextBranchId;
})();
usedBranchIds.add(branchId);
return {
...item,
branchId
};
});
};
/**
* 旧工作流加载兼容:缺失 branchId 的分支补旧 handle label,避免保存后断开旧 edge。
*/
export const normalizeIfElseList = (list: IfElseListItemType[] = []) =>
ensureUniqueBranchIds({
list,
createFallback: (_item, index) => getElseIFLabel(index)
});
/**
* 新建判断器节点初始化:默认分支必须生成随机 ID,不能继续复用 index label。
*/
export const initNewIfElseList = (list: IfElseListItemType[] = []) =>
ensureUniqueBranchIds({
list,
createFallback: () => createIfElseBranchId()
});
......@@ -39,6 +39,11 @@ import {
defaultQGConfig
} from '@fastgpt/global/core/app/constants';
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import {
getIfElseBranchHandleKey,
initNewIfElseList,
normalizeIfElseList
} from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
......@@ -1128,6 +1133,45 @@ describe('getElseIFLabel', () => {
});
});
describe('ifElse branch helpers', () => {
it('should normalize legacy branches with old handle labels', () => {
const result = normalizeIfElseList([
{ condition: 'AND', list: [] },
{ condition: 'OR', list: [] }
]);
expect(result[0].branchId).toBe(IfElseResultEnum.IF);
expect(result[1].branchId).toBe(`${IfElseResultEnum.ELSE_IF} 1`);
});
it('should initialize new branches with random branch ids', () => {
const result = initNewIfElseList([{ condition: 'AND', list: [] }]);
expect(result[0].branchId).toMatch(/^[a-z][a-zA-Z0-9]{15}$/);
expect(result[0].branchId).not.toBe(IfElseResultEnum.IF);
});
it('should keep the first duplicate branch id and regenerate the rest', () => {
const result = normalizeIfElseList([
{ branchId: 'same', condition: 'AND', list: [] },
{ branchId: 'same', condition: 'AND', list: [] }
]);
expect(result[0].branchId).toBe('same');
expect(result[1].branchId).toMatch(/^[a-z][a-zA-Z0-9]{15}$/);
expect(result[1].branchId).not.toBe('same');
});
it('should return branch id before falling back to label', () => {
expect(getIfElseBranchHandleKey({ branchId: 'stableId1', condition: 'AND', list: [] }, 1)).toBe(
'stableId1'
);
expect(getIfElseBranchHandleKey({ condition: 'AND', list: [] }, 1)).toBe(
`${IfElseResultEnum.ELSE_IF} 1`
);
});
});
describe('clientGetWorkflowToolRunUserQuery', () => {
it('should return user chat item with dataId', () => {
const pluginInputs: FlowNodeInputItemType[] = [
......
......@@ -15,6 +15,7 @@ import {
type IfElseConditionType,
type IfElseListItemType
} from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { getIfElseBranchHandleKey } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import { type ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type';
import { getElseIFLabel, getHandleId } from '@fastgpt/global/core/workflow/utils';
import { getReferenceVariableValue } from '@fastgpt/global/core/workflow/runtime/utils';
......@@ -137,37 +138,47 @@ function getResult(
export const dispatchIfElse = async (props: Props): Promise<Response> => {
const {
params,
runtimeEdges,
runtimeNodesMap,
variableState,
node: { nodeId }
} = props;
const { ifElseList } = params;
let res = IfElseResultEnum.ELSE as string;
let selectedLabel = IfElseResultEnum.ELSE as string;
let selectedHandleKey = IfElseResultEnum.ELSE as string;
for (let i = 0; i < ifElseList.length; i++) {
const item = ifElseList[i];
const result = getResult(item.condition, item.list, variableState, runtimeNodesMap);
if (result) {
res = getElseIFLabel(i);
selectedLabel = getElseIFLabel(i);
selectedHandleKey = getIfElseBranchHandleKey(item, i);
break;
}
}
const resArray = Array.from({ length: ifElseList.length + 1 }, (_, index) => {
const label = index < ifElseList.length ? getElseIFLabel(index) : IfElseResultEnum.ELSE;
return getHandleId(nodeId, 'source', label);
});
const selectedHandleId = getHandleId(nodeId, 'source', selectedHandleKey);
const sourceHandlePrefix = `${nodeId}-source-`;
const sourceHandleIds = Array.from(
new Set(
runtimeEdges
.filter(
(edge) => edge.source === nodeId && edge.sourceHandle.startsWith(sourceHandlePrefix)
)
.map((edge) => edge.sourceHandle)
)
);
return {
data: {
[NodeOutputKeyEnum.ifElseResult]: res
[NodeOutputKeyEnum.ifElseResult]: selectedLabel
},
[DispatchNodeResponseKeyEnum.nodeResponse]: {
totalPoints: 0,
ifElseResult: res
ifElseResult: selectedLabel
},
[DispatchNodeResponseKeyEnum.skipHandleId]: resArray.filter(
(item) => item !== getHandleId(nodeId, 'source', res)
[DispatchNodeResponseKeyEnum.skipHandleId]: sourceHandleIds.filter(
(handleId) => handleId !== selectedHandleId
)
};
};
import { describe, expect, it } from 'vitest';
import { VARIABLE_NODE_ID, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { dispatchIfElse } from '@fastgpt/service/core/workflow/dispatch/tools/runIfElse';
type DispatchProps = Parameters<typeof dispatchIfElse>[0];
const variableState = (variables: Record<string, unknown>) =>
({
toRuntimeRecord: () => variables
}) as DispatchProps['variableState'];
const ref = (key: string) => [VARIABLE_NODE_ID, key] as [string, string];
const edge = (sourceHandle: string) =>
({
source: 'ifElse',
target: `${sourceHandle}-target`,
sourceHandle,
targetHandle: 'target-left',
status: 'waiting'
}) as DispatchProps['runtimeEdges'][number];
const buildProps = ({
ifElseList,
value,
sourceHandles
}: {
ifElseList: IfElseListItemType[];
value: string;
sourceHandles: string[];
}) =>
({
params: { ifElseList },
node: { nodeId: 'ifElse' },
runtimeEdges: sourceHandles.map(edge),
runtimeNodesMap: new Map(),
variableState: variableState({ input: value })
}) as unknown as DispatchProps;
describe('dispatchIfElse branch handles', () => {
it('should use legacy labels for old branches and skip stale source handles', async () => {
const result = await dispatchIfElse(
buildProps({
value: 'b',
ifElseList: [
{
condition: 'AND',
list: [{ variable: ref('input'), condition: VariableConditionEnum.equalTo, value: 'a' }]
},
{
condition: 'AND',
list: [{ variable: ref('input'), condition: VariableConditionEnum.equalTo, value: 'b' }]
}
],
sourceHandles: [
getHandleId('ifElse', 'source', IfElseResultEnum.IF),
getHandleId('ifElse', 'source', `${IfElseResultEnum.ELSE_IF} 1`),
getHandleId('ifElse', 'source', `${IfElseResultEnum.ELSE_IF} 2`),
getHandleId('ifElse', 'source', IfElseResultEnum.ELSE)
]
})
);
expect(result.data?.[NodeOutputKeyEnum.ifElseResult]).toBe(`${IfElseResultEnum.ELSE_IF} 1`);
expect(result[DispatchNodeResponseKeyEnum.skipHandleId]).toEqual([
getHandleId('ifElse', 'source', IfElseResultEnum.IF),
getHandleId('ifElse', 'source', `${IfElseResultEnum.ELSE_IF} 2`),
getHandleId('ifElse', 'source', IfElseResultEnum.ELSE)
]);
});
it('should use branchId as selected handle while keeping display result label', async () => {
const result = await dispatchIfElse(
buildProps({
value: 'a',
ifElseList: [
{
branchId: 'stableA',
condition: 'AND',
list: [{ variable: ref('input'), condition: VariableConditionEnum.equalTo, value: 'a' }]
},
{
branchId: 'stableB',
condition: 'AND',
list: [{ variable: ref('input'), condition: VariableConditionEnum.equalTo, value: 'b' }]
}
],
sourceHandles: [
getHandleId('ifElse', 'source', 'stableA'),
getHandleId('ifElse', 'source', 'stableB'),
getHandleId('ifElse', 'source', IfElseResultEnum.ELSE)
]
})
);
expect(result.data?.[NodeOutputKeyEnum.ifElseResult]).toBe(IfElseResultEnum.IF);
expect(result[DispatchNodeResponseKeyEnum.skipHandleId]).toEqual([
getHandleId('ifElse', 'source', 'stableB'),
getHandleId('ifElse', 'source', IfElseResultEnum.ELSE)
]);
});
});
......@@ -4,13 +4,13 @@ import {
type DraggableStateSnapshot
} from '@fastgpt/web/components/common/DndDrag/index';
import Container from '../../components/Container';
import { MinusIcon } from '@chakra-ui/icons';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { type ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io';
import { useTranslation } from 'next-i18next';
import { ReferSelector, useReference } from '../render/RenderInput/templates/Reference';
import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { getIfElseBranchHandleKey } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import {
VariableConditionEnum,
allConditionList,
......@@ -58,7 +58,11 @@ const ListItem = ({
const { t } = useTranslation();
const { getZoom } = useReactFlow();
const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge);
const handleId = getHandleId(nodeId, 'source', getElseIFLabel(conditionIndex));
const handleId = getHandleId(
nodeId,
'source',
getIfElseBranchHandleKey(conditionItem, conditionIndex)
);
const Render = useMemo(() => {
return (
......@@ -291,16 +295,20 @@ const ListItem = ({
]);
return (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
>
{Render}
</Box>
<>
{/* eslint-disable react-hooks/refs -- react-beautiful-dnd requires passing provided refs and props during render. */}
<Box
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
>
{Render}
</Box>
{/* eslint-enable react-hooks/refs */}
</>
);
};
......
......@@ -6,6 +6,10 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { type NodeProps, Position } from 'reactflow';
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import {
createIfElseBranchId,
getIfElseBranchHandleKey
} from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import { useContextSelector } from 'use-context-selector';
import Container from '../../components/Container';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag/index';
......@@ -69,8 +73,8 @@ const NodeIfElse = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
<Box {...provided.droppableProps} ref={provided.innerRef}>
{ifElseList.map((conditionItem, conditionIndex) => (
<Draggable
key={conditionIndex}
draggableId={conditionIndex.toString()}
key={getIfElseBranchHandleKey(conditionItem, conditionIndex)}
draggableId={getIfElseBranchHandleKey(conditionItem, conditionIndex)}
index={conditionIndex}
>
{(provided, snapshot) => (
......@@ -118,12 +122,14 @@ const NodeIfElse = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
onUpdateIfElseList([
...ifElseList,
{
branchId: createIfElseBranchId(),
condition: 'AND',
list: [
{
variable: undefined,
condition: undefined,
value: undefined
value: undefined,
valueType: 'input'
}
]
}
......
import React, { useMemo } from 'react';
import { Position } from 'reactflow';
import { MySourceHandle, MyTargetHandle } from '.';
import { getHandleId, getElseIFLabel } from '@fastgpt/global/core/workflow/utils';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../../../../context/workflowInitContext';
import { WorkflowActionsContext } from '../../../../context/workflowActionsContext';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { getIfElseBranchHandleKey } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
export const ConnectionSourceHandle = ({
nodeId,
......@@ -40,7 +42,13 @@ export const ConnectionSourceHandle = ({
return getHandleId(nodeId, 'source', options[0].key);
}
} else if (node.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
return getHandleId(nodeId, 'source', getElseIFLabel(0));
const ifElseList = node.inputs.find(
(input) => input.key === NodeInputKeyEnum.ifElseList
)?.value as IfElseListItemType[] | undefined;
const firstIfElse = ifElseList?.[0];
if (firstIfElse) {
return getHandleId(nodeId, 'source', getIfElseBranchHandleKey(firstIfElse, 0));
}
} else if (node.flowNodeType === FlowNodeTypeEnum.classifyQuestion) {
const options = node?.inputs?.find(
(input) => input.key === NodeInputKeyEnum.agents
......
......@@ -9,6 +9,9 @@ import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { getIfElseBranchHandleKey } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
// Get sort index from source node's output handle order
export const getHandleIndex = (
......@@ -29,14 +32,26 @@ export const getHandleIndex = (
}
}
// ifElseNode: IF=0, ELSE IF=1/2/3..., ELSE=999
// ifElseNode: sort by stable branch handle, ELSE always last.
if (flowNodeType === FlowNodeTypeEnum.ifElseNode) {
if (handleId.includes(IfElseResultEnum.ELSE_IF)) {
const match = handleId.match(/ELSE IF (\d+)/);
return match ? parseInt(match[1]) : 1;
const ifElseList = inputs?.find((i) => i.key === NodeInputKeyEnum.ifElseList)?.value as
| IfElseListItemType[]
| undefined;
if (Array.isArray(ifElseList)) {
const idx = ifElseList.findIndex((item, index) => {
const itemHandleId = getHandleId(
sourceNode.data.nodeId,
'source',
getIfElseBranchHandleKey(item, index)
);
return itemHandleId === handleId;
});
if (idx >= 0) return idx;
}
if (handleId.endsWith(`-${IfElseResultEnum.IF}`)) return 0;
if (handleId.endsWith(`-${IfElseResultEnum.ELSE}`)) return 999;
if (handleId === getHandleId(sourceNode.data.nodeId, 'source', IfElseResultEnum.ELSE)) {
return 999;
}
return 998;
}
// classifyQuestion: sort by agent index
......
......@@ -32,6 +32,10 @@ import {
type ReferenceValueType
} from '@fastgpt/global/core/workflow/type/io';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import {
initNewIfElseList,
normalizeIfElseList
} from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type';
......@@ -49,6 +53,17 @@ import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
* 这里仅处理旧字段到新字段的 key 和 valueType 迁移。
*/
export const adaptStoreNodeInputs = (storeNode: StoreNodeItemType): FlowNodeInputItemType[] => {
if (storeNode.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
return storeNode.inputs.map((input) => {
if (input.key !== NodeInputKeyEnum.ifElseList) return input;
return {
...input,
value: normalizeIfElseList(input.value as IfElseListItemType[])
};
});
}
if (storeNode.flowNodeType !== FlowNodeTypeEnum.datasetSearchNode) {
return storeNode.inputs;
}
......@@ -91,6 +106,16 @@ export const nodeTemplate2FlowNode = ({
nodeId: getNanoid(),
parentNodeId
};
if (moduleItem.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
moduleItem.inputs = moduleItem.inputs.map((input) => {
if (input.key !== NodeInputKeyEnum.ifElseList) return input;
return {
...input,
value: initNewIfElseList(input.value as IfElseListItemType[])
};
});
}
return {
id: moduleItem.nodeId,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment