Feat: Add loop operator node. #10427

This commit is contained in:
bill 2025-11-21 18:37:09 +08:00
parent 249296e417
commit 0c33508399
12 changed files with 178 additions and 90 deletions

View file

@ -118,6 +118,8 @@ export enum Operator {
Splitter = 'Splitter', Splitter = 'Splitter',
HierarchicalMerger = 'HierarchicalMerger', HierarchicalMerger = 'HierarchicalMerger',
Extractor = 'Extractor', Extractor = 'Extractor',
Loop = 'Loop',
LoopStart = 'LoopItem',
} }
export enum ComparisonOperator { export enum ComparisonOperator {

View file

@ -62,6 +62,7 @@ import { InvokeNode } from './node/invoke-node';
import { IterationNode, IterationStartNode } from './node/iteration-node'; import { IterationNode, IterationStartNode } from './node/iteration-node';
import { KeywordNode } from './node/keyword-node'; import { KeywordNode } from './node/keyword-node';
import { ListOperationsNode } from './node/list-operations-node'; import { ListOperationsNode } from './node/list-operations-node';
import { LoopNode, LoopStartNode } from './node/loop-node';
import { MessageNode } from './node/message-node'; import { MessageNode } from './node/message-node';
import NoteNode from './node/note-node'; import NoteNode from './node/note-node';
import ParserNode from './node/parser-node'; import ParserNode from './node/parser-node';
@ -105,6 +106,8 @@ export const nodeTypes: NodeTypes = {
listOperationsNode: ListOperationsNode, listOperationsNode: ListOperationsNode,
variableAssignerNode: VariableAssignerNode, variableAssignerNode: VariableAssignerNode,
variableAggregatorNode: VariableAggregatorNode, variableAggregatorNode: VariableAggregatorNode,
loopNode: LoopNode,
loopStartNode: LoopStartNode,
}; };
const edgeTypes = { const edgeTypes = {

View file

@ -1,58 +0,0 @@
import OperateDropdown from '@/components/operate-dropdown';
import { CopyOutlined } from '@ant-design/icons';
import { Flex, MenuProps } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Operator } from '../../constant';
import { useDuplicateNode } from '../../hooks';
import useGraphStore from '../../store';
interface IProps {
id: string;
iconFontColor?: string;
label: string;
}
const NodeDropdown = ({ id, iconFontColor, label }: IProps) => {
const { t } = useTranslation();
const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
const deleteIterationNodeById = useGraphStore(
(store) => store.deleteIterationNodeById,
);
const deleteNode = useCallback(() => {
if (label === Operator.Iteration) {
deleteIterationNodeById(id);
} else {
deleteNodeById(id);
}
}, [label, deleteIterationNodeById, id, deleteNodeById]);
const duplicateNode = useDuplicateNode();
const items: MenuProps['items'] = [
{
key: '2',
onClick: () => duplicateNode(id, label),
label: (
<Flex justify={'space-between'}>
{t('common.copy')}
<CopyOutlined />
</Flex>
),
},
];
return (
<OperateDropdown
iconFontSize={22}
height={14}
deleteItem={deleteNode}
items={items}
needsDeletionValidation={false}
iconFontColor={iconFontColor}
></OperateDropdown>
);
};
export default NodeDropdown;

View file

@ -62,6 +62,7 @@ export function AccordionOperators({
operators={[ operators={[
Operator.Switch, Operator.Switch,
Operator.Iteration, Operator.Iteration,
Operator.Loop,
Operator.Categorize, Operator.Categorize,
]} ]}
isCustomDropdown={isCustomDropdown} isCustomDropdown={isCustomDropdown}

View file

@ -56,7 +56,7 @@ export function InnerIterationNode({
); );
} }
function InnerIterationStartNode({ export function InnerIterationStartNode({
isConnectable = true, isConnectable = true,
id, id,
selected, selected,

View file

@ -0,0 +1,75 @@
import { Panel, type NodeProps, type PanelPosition } from '@xyflow/react';
import { type ComponentProps, type ReactNode } from 'react';
import { BaseNode } from '@/components/xyflow/base-node';
import { cn } from '@/lib/utils';
/* GROUP NODE Label ------------------------------------------------------- */
export type GroupNodeLabelProps = ComponentProps<'div'>;
export function GroupNodeLabel({
children,
className,
...props
}: GroupNodeLabelProps) {
return (
<div className="h-full w-full" {...props}>
<div
className={cn(
'text-card-foreground bg-secondary w-fit p-2 text-xs',
className,
)}
>
{children}
</div>
</div>
);
}
export type GroupNodeProps = Partial<NodeProps> & {
label?: ReactNode;
position?: PanelPosition;
};
/* GROUP NODE -------------------------------------------------------------- */
export function LabeledGroupNode({
label = '',
position,
...props
}: GroupNodeProps) {
const getLabelClassName = (position?: PanelPosition) => {
switch (position) {
case 'top-left':
return 'rounded-br-sm';
case 'top-center':
return 'rounded-b-sm';
case 'top-right':
return 'rounded-bl-sm';
case 'bottom-left':
return 'rounded-tr-sm';
case 'bottom-right':
return 'rounded-tl-sm';
case 'bottom-center':
return 'rounded-t-sm';
default:
return 'rounded-br-sm';
}
};
return (
<BaseNode
className="bg-opacity-50 h-full overflow-hidden rounded-sm"
{...props}
>
<Panel className="m-0 p-0" position={position}>
{label && (
<GroupNodeLabel className={getLabelClassName(position)}>
{label}
</GroupNodeLabel>
)}
</Panel>
</BaseNode>
);
}

View file

@ -0,0 +1,16 @@
import { BaseNode } from '@/interfaces/database/agent';
import { NodeProps } from '@xyflow/react';
import { memo } from 'react';
import { InnerIterationNode, InnerIterationStartNode } from './iteration-node';
export function InnerLoopNode({ ...props }: NodeProps<BaseNode<any>>) {
return <InnerIterationNode {...props}></InnerIterationNode>;
}
export const LoopNode = memo(InnerLoopNode);
export function InnerLoopStartNode({ ...props }: NodeProps<BaseNode<any>>) {
return <InnerIterationStartNode {...props}></InnerIterationStartNode>;
}
export const LoopStartNode = memo(InnerLoopStartNode);

View file

@ -58,7 +58,7 @@ export function ToolBar({
const deleteNode: MouseEventHandler<HTMLDivElement> = useCallback( const deleteNode: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => { (e) => {
e.stopPropagation(); e.stopPropagation();
if (label === Operator.Iteration) { if ([Operator.Iteration, Operator.Loop].includes(label as Operator)) {
deleteIterationNodeById(id); deleteIterationNodeById(id);
} else { } else {
deleteNodeById(id); deleteNodeById(id);

View file

@ -625,6 +625,8 @@ export const initialVariableAssignerValues = {};
export const initialVariableAggregatorValues = { outputs: {}, groups: [] }; export const initialVariableAggregatorValues = { outputs: {}, groups: [] };
export const initialLoopValues = { outputs: {} };
export const CategorizeAnchorPointPositions = [ export const CategorizeAnchorPointPositions = [
{ top: 1, right: 34 }, { top: 1, right: 34 },
{ top: 8, right: 18 }, { top: 8, right: 18 },
@ -707,6 +709,8 @@ export const RestrictedUpstreamMap = {
[Operator.Tokenizer]: [Operator.Begin], [Operator.Tokenizer]: [Operator.Begin],
[Operator.Extractor]: [Operator.Begin], [Operator.Extractor]: [Operator.Begin],
[Operator.File]: [Operator.Begin], [Operator.File]: [Operator.Begin],
[Operator.Loop]: [Operator.Begin],
[Operator.LoopStart]: [Operator.Begin],
}; };
export const NodeMap = { export const NodeMap = {
@ -759,6 +763,8 @@ export const NodeMap = {
[Operator.ListOperations]: 'listOperationsNode', [Operator.ListOperations]: 'listOperationsNode',
[Operator.VariableAssigner]: 'variableAssignerNode', [Operator.VariableAssigner]: 'variableAssignerNode',
[Operator.VariableAggregator]: 'variableAggregatorNode', [Operator.VariableAggregator]: 'variableAggregatorNode',
[Operator.Loop]: 'loopNode',
[Operator.LoopStart]: 'loopStartNode',
}; };
export enum BeginQueryType { export enum BeginQueryType {

View file

@ -32,6 +32,7 @@ import {
initialJin10Values, initialJin10Values,
initialKeywordExtractValues, initialKeywordExtractValues,
initialListOperationsValues, initialListOperationsValues,
initialLoopValues,
initialMessageValues, initialMessageValues,
initialNoteValues, initialNoteValues,
initialParserValues, initialParserValues,
@ -68,6 +69,63 @@ function isBottomSubAgent(type: string, position: Position) {
type === Operator.Tool type === Operator.Tool
); );
} }
const GroupStartNodeMap = {
[Operator.Iteration]: {
id: `${Operator.IterationStart}:${humanId()}`,
type: 'iterationStartNode',
position: { x: 50, y: 100 },
data: {
label: Operator.IterationStart,
name: Operator.IterationStart,
form: initialIterationStartValues,
},
extent: 'parent' as 'parent',
},
[Operator.Loop]: {
id: `${Operator.LoopStart}:${humanId()}`,
type: 'loopStartNode',
position: { x: 50, y: 100 },
data: {
label: Operator.LoopStart,
name: Operator.LoopStart,
form: {},
},
extent: 'parent' as 'parent',
},
};
function useAddGroupNode() {
const { addEdge, addNode } = useGraphStore((state) => state);
const addGroupNode = useCallback(
(operatorType: string, newNode: Node<any>, nodeId?: string) => {
newNode.width = 500;
newNode.height = 250;
const startNode: Node<any> =
GroupStartNodeMap[operatorType as keyof typeof GroupStartNodeMap];
startNode.parentId = newNode.id;
addNode(newNode);
addNode(startNode);
if (nodeId) {
addEdge({
source: nodeId,
target: newNode.id,
sourceHandle: NodeHandleId.Start,
targetHandle: NodeHandleId.End,
});
}
return newNode.id;
},
[addEdge, addNode],
);
return { addGroupNode };
}
export const useInitializeOperatorParams = () => { export const useInitializeOperatorParams = () => {
const llmId = useFetchModelId(); const llmId = useFetchModelId();
@ -133,6 +191,8 @@ export const useInitializeOperatorParams = () => {
[Operator.ListOperations]: initialListOperationsValues, [Operator.ListOperations]: initialListOperationsValues,
[Operator.VariableAssigner]: initialVariableAssignerValues, [Operator.VariableAssigner]: initialVariableAssignerValues,
[Operator.VariableAggregator]: initialVariableAggregatorValues, [Operator.VariableAggregator]: initialVariableAggregatorValues,
[Operator.Loop]: initialLoopValues,
[Operator.LoopStart]: {},
}; };
}, [llmId]); }, [llmId]);
@ -311,6 +371,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
const { addChildEdge } = useAddChildEdge(); const { addChildEdge } = useAddChildEdge();
const { addToolNode } = useAddToolNode(); const { addToolNode } = useAddToolNode();
const { resizeIterationNode } = useResizeIterationNode(); const { resizeIterationNode } = useResizeIterationNode();
const { addGroupNode } = useAddGroupNode();
// const [reactFlowInstance, setReactFlowInstance] = // const [reactFlowInstance, setReactFlowInstance] =
// useState<ReactFlowInstance<any, any>>(); // useState<ReactFlowInstance<any, any>>();
@ -376,33 +437,8 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
} }
} }
if (type === Operator.Iteration) { if ([Operator.Iteration, Operator.Loop].includes(type as Operator)) {
newNode.width = 500; return addGroupNode(type, newNode, nodeId);
newNode.height = 250;
const iterationStartNode: Node<any> = {
id: `${Operator.IterationStart}:${humanId()}`,
type: 'iterationStartNode',
position: { x: 50, y: 100 },
// draggable: false,
data: {
label: Operator.IterationStart,
name: Operator.IterationStart,
form: initialIterationStartValues,
},
parentId: newNode.id,
extent: 'parent',
};
addNode(newNode);
addNode(iterationStartNode);
if (nodeId) {
addEdge({
source: nodeId,
target: newNode.id,
sourceHandle: NodeHandleId.Start,
targetHandle: NodeHandleId.End,
});
}
return newNode.id;
} else if ( } else if (
type === Operator.Agent && type === Operator.Agent &&
params.position === Position.Bottom params.position === Position.Bottom
@ -456,6 +492,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
[ [
addChildEdge, addChildEdge,
addEdge, addEdge,
addGroupNode,
addNode, addNode,
addToolNode, addToolNode,
calculateNewlyBackChildPosition, calculateNewlyBackChildPosition,

View file

@ -14,6 +14,7 @@ export const useShowFormDrawer = () => {
setClickedNodeId, setClickedNodeId,
getNode, getNode,
setClickedToolId, setClickedToolId,
getOperatorTypeFromId,
} = useGraphStore((state) => state); } = useGraphStore((state) => state);
const { const {
visible: formDrawerVisible, visible: formDrawerVisible,
@ -25,14 +26,18 @@ export const useShowFormDrawer = () => {
(e: React.MouseEvent<Element>, nodeId: string) => { (e: React.MouseEvent<Element>, nodeId: string) => {
const tool = get(e.target, 'dataset.tool'); const tool = get(e.target, 'dataset.tool');
// TODO: Operator type judgment should be used // TODO: Operator type judgment should be used
if (nodeId.startsWith(Operator.Tool) && !tool) { const operatorType = getOperatorTypeFromId(nodeId);
if (
(operatorType === Operator.Tool && !tool) ||
[Operator.LoopStart].includes(operatorType as Operator)
) {
return; return;
} }
setClickedNodeId(nodeId); setClickedNodeId(nodeId);
setClickedToolId(tool); setClickedToolId(tool);
showFormDrawer(); showFormDrawer();
}, },
[setClickedNodeId, setClickedToolId, showFormDrawer], [getOperatorTypeFromId, setClickedNodeId, setClickedToolId, showFormDrawer],
); );
return { return {

View file

@ -14,7 +14,7 @@ import { ReactComponent as YahooFinanceIcon } from '@/assets/svg/yahoo-finance.s
import { IconFontFill } from '@/components/icon-font'; import { IconFontFill } from '@/components/icon-font';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FileCode, HousePlus } from 'lucide-react'; import { FileCode, HousePlus, Infinity as InfinityIcon } from 'lucide-react';
import { Operator } from './constant'; import { Operator } from './constant';
interface IProps { interface IProps {
@ -60,6 +60,7 @@ export const SVGIconMap = {
}; };
export const LucideIconMap = { export const LucideIconMap = {
[Operator.DataOperations]: FileCode, [Operator.DataOperations]: FileCode,
[Operator.Loop]: InfinityIcon,
}; };
const Empty = () => { const Empty = () => {