Feat: Add IterationNode component #3221 (#8461)

### What problem does this PR solve?

Feat: Add IterationNode component #3221
### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu 2025-06-24 18:01:30 +08:00 committed by GitHub
parent bc1b837616
commit 1c68c9ebd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 191 additions and 169 deletions

View File

@ -3,6 +3,7 @@
height: 100%;
:global(.react-flow__node-group) {
.commonNode();
border-radius: 0 0 10px 10px;
padding: 0;
border: 0;
background-color: transparent;

View File

@ -1,15 +1,17 @@
import { useTheme } from '@/components/theme-provider';
import {
IIterationNode,
IIterationStartNode,
} from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { Handle, NodeProps, NodeResizeControl, Position } from '@xyflow/react';
import { ListRestart } from 'lucide-react';
import { NodeProps, NodeResizeControl, Position } from '@xyflow/react';
import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import { NodeHandleId, Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
function ResizeIcon() {
return (
@ -50,47 +52,43 @@ export function InnerIterationNode({
isConnectable = true,
selected,
}: NodeProps<IIterationNode>) {
const { theme } = useTheme();
// const { theme } = useTheme();
return (
<section
className={cn(
'w-full h-full bg-zinc-200 opacity-70',
styles.iterationNode,
{
['bg-gray-800']: theme === 'dark',
[styles.selectedIterationNode]: selected,
},
)}
className={cn('h-full bg-transparent rounded-b-md', {
[styles.selectedHeader]: selected,
})}
>
<NodeResizeControl style={controlStyle} minWidth={100} minHeight={50}>
<ResizeIcon />
</NodeResizeControl>
<Handle
id="c"
type="source"
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
nodeId={id}
></CommonHandle>
<CommonHandle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
id="b"
style={RightHandleStyle}
></Handle>
nodeId={id}
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
wrapperClassName={cn(
'p-2 bg-white rounded-t-[10px] absolute w-full top-[-60px] left-[-0.3px]',
styles.iterationHeader,
'bg-background-header-bar p-2 rounded-t-[10px] absolute w-full top-[-44px] left-[-0.3px]',
// styles.iterationHe ader,
{
[`${styles.dark} text-white`]: theme === 'dark',
// [`${styles.dark} text-white`]: theme === 'dark',
[styles.selectedHeader]: selected,
},
)}
@ -101,29 +99,24 @@ export function InnerIterationNode({
function InnerIterationStartNode({
isConnectable = true,
selected,
id,
}: NodeProps<IIterationStartNode>) {
const { theme } = useTheme();
return (
<section
className={cn('bg-white p-2 rounded-xl', {
[styles.dark]: theme === 'dark',
[styles.selectedNode]: selected,
})}
>
<Handle
<NodeWrapper className="w-20">
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
isConnectableEnd={false}
></Handle>
id={NodeHandleId.Start}
nodeId={id}
></CommonHandle>
<div>
<ListRestart className="size-7" />
<OperatorIcon name={Operator.Begin}></OperatorIcon>
</div>
</section>
</NodeWrapper>
);
}

View File

@ -644,7 +644,7 @@ export const initialEmailValues = {
};
export const initialIterationValues = {
delimiter: ',',
items_ref: '',
};
export const initialIterationStartValues = {};
@ -665,6 +665,7 @@ export const initialWaitingDialogueValues = {};
export const initialAgentValues = {
...initialLlmBaseValues,
description: '',
sys_prompt: ``,
prompts: [{ role: PromptRole.User, content: `{${AgentGlobals.SysQuery}}` }],
message_history_window_size: 12,

View File

@ -23,7 +23,7 @@ import GithubForm from '../form/github-form';
import GoogleForm from '../form/google-form';
import GoogleScholarForm from '../form/google-scholar-form';
import InvokeForm from '../form/invoke-form';
import IterationForm from '../form/iteration-from';
import IterationForm from '../form/iteration-form';
import Jin10Form from '../form/jin10-form';
import KeywordExtractForm from '../form/keyword-extract-form';
import MessageForm from '../form/message-form';

View File

@ -10,15 +10,17 @@ import {
FormItem,
FormLabel,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { zodResolver } from '@hookform/resolvers/zod';
import { Position } from '@xyflow/react';
import { useContext, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Operator, initialAgentValues } from '../../constant';
import { NodeHandleId, Operator, initialAgentValues } from '../../constant';
import { AgentInstanceContext } from '../../context';
import { INextOperatorForm } from '../../interface';
import useGraphStore from '../../store';
import { Output } from '../components/output';
import { PromptEditor } from '../components/prompt-editor';
import { AgentTools } from './agent-tools';
@ -27,6 +29,7 @@ import { useWatchFormChange } from './use-watch-change';
const FormSchema = z.object({
sys_prompt: z.string(),
description: z.string().optional(),
prompts: z.string().optional(),
// prompts: z
// .array(
@ -49,9 +52,17 @@ const FormSchema = z.object({
const AgentForm = ({ node }: INextOperatorForm) => {
const { t } = useTranslation();
const { edges } = useGraphStore((state) => state);
const defaultValues = useValues(node);
const isSubAgent = useMemo(() => {
const edge = edges.find(
(x) => x.target === node?.id && x.targetHandle === NodeHandleId.AgentTop,
);
return !!edge;
}, [edges, node?.id]);
const outputList = useMemo(() => {
return [
{ title: 'content', type: initialAgentValues.outputs.content.type },
@ -76,6 +87,20 @@ const AgentForm = ({ node }: INextOperatorForm) => {
}}
>
<FormContainer>
{isSubAgent && (
<FormField
control={form.control}
name={`description`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field}></Textarea>
</FormControl>
</FormItem>
)}
/>
)}
<LargeModelFormField></LargeModelFormField>
<FormField
control={form.control}
@ -95,23 +120,28 @@ const AgentForm = ({ node }: INextOperatorForm) => {
/>
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
</FormContainer>
<FormContainer>
{/* <DynamicPrompt></DynamicPrompt> */}
<FormField
control={form.control}
name={`prompts`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>User Prompt</FormLabel>
<FormControl>
<section>
<PromptEditor {...field} showToolbar={false}></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</FormContainer>
{isSubAgent || (
<FormContainer>
{/* <DynamicPrompt></DynamicPrompt> */}
<FormField
control={form.control}
name={`prompts`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>User Prompt</FormLabel>
<FormControl>
<section>
<PromptEditor
{...field}
showToolbar={false}
></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</FormContainer>
)}
<FormContainer>
<AgentTools></AgentTools>
<BlockButton

View File

@ -10,7 +10,9 @@ import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
export function QueryVariable() {
type QueryVariableProps = { name?: string };
export function QueryVariable({ name = 'query' }: QueryVariableProps) {
const { t } = useTranslation();
const form = useFormContext();
@ -19,7 +21,7 @@ export function QueryVariable() {
return (
<FormField
control={form.control}
name="query"
name={name}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.modelTip')}>{t('flow.query')}</FormLabel>

View File

@ -0,0 +1,61 @@
import { FormContainer } from '@/components/form-container';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { initialRetrievalValues } from '../../constant';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
import { useValues } from './use-values';
const FormSchema = z.object({
query: z.string().optional(),
similarity_threshold: z.coerce.number(),
keywords_similarity_weight: z.coerce.number(),
top_n: z.coerce.number(),
top_k: z.coerce.number(),
kb_ids: z.array(z.string()),
rerank_id: z.string(),
empty_response: z.string(),
});
const IterationForm = ({ node }: INextOperatorForm) => {
const outputList = useMemo(() => {
return [
{
title: 'formalized_content',
type: initialRetrievalValues.outputs.formalized_content.type,
},
];
}, []);
const defaultValues = useValues(node);
const form = useForm({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<form
className="space-y-6 p-4"
onSubmit={(e) => {
e.preventDefault();
}}
>
<FormContainer>
<QueryVariable name="items_ref"></QueryVariable>
</FormContainer>
<Output list={outputList}></Output>
</form>
</Form>
);
};
export default IterationForm;

View File

@ -0,0 +1,25 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialIterationValues } from '../../constant';
export function useValues(node?: RAGFlowNodeType) {
const defaultValues = useMemo(
() => ({
...initialIterationValues,
}),
[],
);
const values = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return defaultValues;
}
return formData;
}, [defaultValues, node?.data?.form]);
return values;
}

View File

@ -1,94 +0,0 @@
import { CommaIcon, SemicolonIcon } from '@/assets/icon/Icon';
import { Form, Select } from 'antd';
import {
CornerDownLeft,
IndentIncrease,
Minus,
Slash,
Underline,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { IOperatorForm } from '../../interface';
import DynamicInputVariable from '../components/dynamic-input-variable';
const optionList = [
{
value: ',',
icon: CommaIcon,
text: 'comma',
},
{
value: '\n',
icon: CornerDownLeft,
text: 'lineBreak',
},
{
value: 'tab',
icon: IndentIncrease,
text: 'tab',
},
{
value: '_',
icon: Underline,
text: 'underline',
},
{
value: '/',
icon: Slash,
text: 'diagonal',
},
{
value: '-',
icon: Minus,
text: 'minus',
},
{
value: ';',
icon: SemicolonIcon,
text: 'semicolon',
},
];
const IterationForm = ({ onValuesChange, form, node }: IOperatorForm) => {
const { t } = useTranslation();
const options = useMemo(() => {
return optionList.map((x) => {
let Icon = x.icon;
return {
value: x.value,
label: (
<div className="flex items-center gap-2">
<Icon className={'size-4'}></Icon>
{t(`flow.delimiterOptions.${x.text}`)}
</div>
),
};
});
}, [t]);
return (
<Form
name="basic"
autoComplete="off"
form={form}
onValuesChange={onValuesChange}
layout={'vertical'}
>
<DynamicInputVariable node={node}></DynamicInputVariable>
<Form.Item
name={['delimiter']}
label={t('knowledgeDetails.delimiter')}
initialValue={`\\n!?;。;!?`}
rules={[{ required: true }]}
tooltip={t('flow.delimiterTip')}
>
<Select options={options}></Select>
</Form.Item>
</Form>
);
};
export default IterationForm;

View File

@ -51,7 +51,6 @@ import useGraphStore from '../store';
import {
generateNodeNamesWithIncreasingIndex,
getNodeDragHandle,
getRelativePositionToIterationNode,
} from '../utils';
export const useInitializeOperatorParams = () => {
@ -234,11 +233,9 @@ function useAddToolNode() {
}
export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
const addNode = useGraphStore((state) => state.addNode);
const getNode = useGraphStore((state) => state.getNode);
const addEdge = useGraphStore((state) => state.addEdge);
const nodes = useGraphStore((state) => state.nodes);
const edges = useGraphStore((state) => state.edges);
const { edges, nodes, addEdge, addNode, getNode } = useGraphStore(
(state) => state,
);
const getNodeName = useGetNodeName();
const initializeOperatorParams = useInitializeOperatorParams();
const { calculateNewlyBackChildPosition } = useCalculateNewlyChildPosition();
@ -257,6 +254,8 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
(event?: React.MouseEvent<HTMLElement>) => {
const nodeId = params.nodeId;
const node = getNode(nodeId);
// reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition
// and you don't need to subtract the reactFlowBounds.left/top anymore
// details: https://@xyflow/react.dev/whats-new/2023-11-10
@ -289,6 +288,11 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
dragHandle: getNodeDragHandle(type),
};
if (node && node.parentId) {
newNode.parentId = node.parentId;
newNode.extent = 'parent';
}
if (type === Operator.Iteration) {
newNode.width = 500;
newNode.height = 250;
@ -307,6 +311,14 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
};
addNode(newNode);
addNode(iterationStartNode);
if (nodeId) {
addEdge({
source: nodeId,
target: newNode.id,
sourceHandle: NodeHandleId.Start,
targetHandle: NodeHandleId.End,
});
}
} else if (
type === Operator.Agent &&
params.position === Position.Bottom
@ -345,15 +357,6 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
} else if (type === Operator.Tool) {
addToolNode(newNode, params.nodeId);
} else {
const subNodeOfIteration = getRelativePositionToIterationNode(
nodes,
position,
);
if (subNodeOfIteration) {
newNode.parentId = subNodeOfIteration.parentId;
newNode.position = subNodeOfIteration.position;
newNode.extent = 'parent';
}
addNode(newNode);
addChildEdge(params.position, {
source: params.nodeId,