### What problem does this PR solve? feat: add RelevantForm #918 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.8.0
| addField: 'Add field', | addField: 'Add field', | ||||
| loop: 'Loop', | loop: 'Loop', | ||||
| createFlow: 'Create a workflow', | createFlow: 'Create a workflow', | ||||
| yes: 'Yes', | |||||
| no: 'No', | |||||
| }, | }, | ||||
| footer: { | footer: { | ||||
| profile: 'All rights reserved @ React', | profile: 'All rights reserved @ React', |
| import styles from './index.less'; | import styles from './index.less'; | ||||
| import { BeginNode } from './node/begin-node'; | import { BeginNode } from './node/begin-node'; | ||||
| import { CategorizeNode } from './node/categorize-node'; | import { CategorizeNode } from './node/categorize-node'; | ||||
| import { RelevantNode } from './node/relevant-node'; | |||||
| const nodeTypes = { | const nodeTypes = { | ||||
| ragNode: RagNode, | ragNode: RagNode, | ||||
| categorizeNode: CategorizeNode, | categorizeNode: CategorizeNode, | ||||
| beginNode: BeginNode, | beginNode: BeginNode, | ||||
| relevantNode: RelevantNode, | |||||
| }; | }; | ||||
| const edgeTypes = { | const edgeTypes = { |
| top: number; | top: number; | ||||
| right: number; | right: number; | ||||
| text: string; | text: string; | ||||
| idx: number; | |||||
| idx?: number; | |||||
| } | } | ||||
| const CategorizeHandle = ({ top, right, text, idx }: IProps) => { | const CategorizeHandle = ({ top, right, text, idx }: IProps) => { | ||||
| top: `${top}%`, | top: `${top}%`, | ||||
| right: `${right}%`, | right: `${right}%`, | ||||
| background: 'red', | background: 'red', | ||||
| color: 'black', | |||||
| }} | }} | ||||
| > | > | ||||
| <span className={styles.categorizeAnchorPointText}>{text}</span> | <span className={styles.categorizeAnchorPointText}>{text}</span> |
| import { Flex } from 'antd'; | import { Flex } from 'antd'; | ||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
| import get from 'lodash/get'; | |||||
| import pick from 'lodash/pick'; | import pick from 'lodash/pick'; | ||||
| import { Handle, NodeProps, Position } from 'reactflow'; | import { Handle, NodeProps, Position } from 'reactflow'; | ||||
| import { | |||||
| CategorizeAnchorPointPositions, | |||||
| Operator, | |||||
| operatorMap, | |||||
| } from '../../constant'; | |||||
| import { Operator, operatorMap } from '../../constant'; | |||||
| import { NodeData } from '../../interface'; | import { NodeData } from '../../interface'; | ||||
| import OperatorIcon from '../../operator-icon'; | import OperatorIcon from '../../operator-icon'; | ||||
| import CategorizeHandle from './categorize-handle'; | |||||
| import NodeDropdown from './dropdown'; | import NodeDropdown from './dropdown'; | ||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| isConnectable = true, | isConnectable = true, | ||||
| selected, | selected, | ||||
| }: NodeProps<NodeData>) { | }: NodeProps<NodeData>) { | ||||
| const isCategorize = data.label === Operator.Categorize; | |||||
| const categoryData = get(data, 'form.category_description') ?? {}; | |||||
| const style = operatorMap[data.label as Operator]; | const style = operatorMap[data.label as Operator]; | ||||
| return ( | return ( | ||||
| id="b" | id="b" | ||||
| ></Handle> | ></Handle> | ||||
| <Handle type="source" position={Position.Bottom} id="a" isConnectable /> | <Handle type="source" position={Position.Bottom} id="a" isConnectable /> | ||||
| {isCategorize && | |||||
| Object.keys(categoryData).map((x, idx) => ( | |||||
| <CategorizeHandle | |||||
| top={CategorizeAnchorPointPositions[idx].top} | |||||
| right={CategorizeAnchorPointPositions[idx].right} | |||||
| key={idx} | |||||
| text={x} | |||||
| idx={idx} | |||||
| ></CategorizeHandle> | |||||
| ))} | |||||
| <Flex vertical align="center" justify={'center'} gap={6}> | <Flex vertical align="center" justify={'center'} gap={6}> | ||||
| <OperatorIcon | <OperatorIcon | ||||
| name={data.label as Operator} | name={data.label as Operator} |
| import { Flex } from 'antd'; | |||||
| import classNames from 'classnames'; | |||||
| import pick from 'lodash/pick'; | |||||
| import { Handle, NodeProps, Position } from 'reactflow'; | |||||
| import { Operator, operatorMap } from '../../constant'; | |||||
| import { NodeData } from '../../interface'; | |||||
| import OperatorIcon from '../../operator-icon'; | |||||
| import NodeDropdown from './dropdown'; | |||||
| import CategorizeHandle from './categorize-handle'; | |||||
| import styles from './index.less'; | |||||
| export function RelevantNode({ id, data, selected }: NodeProps<NodeData>) { | |||||
| const style = operatorMap[data.label as Operator]; | |||||
| return ( | |||||
| <section | |||||
| className={classNames(styles.ragNode, { | |||||
| [styles.selectedNode]: selected, | |||||
| })} | |||||
| style={pick(style, ['backgroundColor', 'width', 'height', 'color'])} | |||||
| > | |||||
| <Handle | |||||
| type="target" | |||||
| position={Position.Left} | |||||
| isConnectable | |||||
| className={styles.handle} | |||||
| id={'a'} | |||||
| ></Handle> | |||||
| <Handle | |||||
| type="target" | |||||
| position={Position.Top} | |||||
| isConnectable | |||||
| className={styles.handle} | |||||
| id={'b'} | |||||
| ></Handle> | |||||
| <Handle | |||||
| type="target" | |||||
| position={Position.Bottom} | |||||
| isConnectable | |||||
| className={styles.handle} | |||||
| id={'c'} | |||||
| ></Handle> | |||||
| <CategorizeHandle top={20} right={6} text={'yes'}></CategorizeHandle> | |||||
| <CategorizeHandle top={80} right={6} text={'no'}></CategorizeHandle> | |||||
| <Flex vertical align="center" justify="center"> | |||||
| <OperatorIcon | |||||
| name={data.label as Operator} | |||||
| fontSize={style.iconFontSize} | |||||
| ></OperatorIcon> | |||||
| <span | |||||
| className={styles.type} | |||||
| style={{ fontSize: style.fontSize ?? 14 }} | |||||
| > | |||||
| {data.label} | |||||
| </span> | |||||
| <NodeDropdown id={id}></NodeDropdown> | |||||
| </Flex> | |||||
| <section className={styles.bottomBox}> | |||||
| <div className={styles.nodeName}>{data.name}</div> | |||||
| </section> | |||||
| </section> | |||||
| ); | |||||
| } |
| import { CloseOutlined } from '@ant-design/icons'; | import { CloseOutlined } from '@ant-design/icons'; | ||||
| import { Button, Card, Form, Input, Select, Typography } from 'antd'; | import { Button, Card, Form, Input, Select, Typography } from 'antd'; | ||||
| import { useUpdateNodeInternals } from 'reactflow'; | import { useUpdateNodeInternals } from 'reactflow'; | ||||
| import { Operator } from '../constant'; | |||||
| import { | |||||
| useBuildFormSelectOptions, | |||||
| useHandleFormSelectChange, | |||||
| } from '../form-hooks'; | |||||
| import { ICategorizeItem } from '../interface'; | import { ICategorizeItem } from '../interface'; | ||||
| import { useBuildCategorizeToOptions, useHandleToSelectChange } from './hooks'; | |||||
| interface IProps { | interface IProps { | ||||
| nodeId?: string; | nodeId?: string; | ||||
| const DynamicCategorize = ({ nodeId }: IProps) => { | const DynamicCategorize = ({ nodeId }: IProps) => { | ||||
| const updateNodeInternals = useUpdateNodeInternals(); | const updateNodeInternals = useUpdateNodeInternals(); | ||||
| const form = Form.useFormInstance(); | const form = Form.useFormInstance(); | ||||
| const buildCategorizeToOptions = useBuildCategorizeToOptions(); | |||||
| const { handleSelectChange } = useHandleToSelectChange(nodeId); | |||||
| const buildCategorizeToOptions = useBuildFormSelectOptions( | |||||
| Operator.Categorize, | |||||
| nodeId, | |||||
| ); | |||||
| const { handleSelectChange } = useHandleFormSelectChange(nodeId); | |||||
| const { t } = useTranslate('flow'); | const { t } = useTranslate('flow'); | ||||
| return ( | return ( |
| import omit from 'lodash/omit'; | import omit from 'lodash/omit'; | ||||
| import { useCallback, useEffect } from 'react'; | import { useCallback, useEffect } from 'react'; | ||||
| import { Edge, Node } from 'reactflow'; | import { Edge, Node } from 'reactflow'; | ||||
| import { Operator } from '../constant'; | |||||
| import { | import { | ||||
| ICategorizeItem, | ICategorizeItem, | ||||
| ICategorizeItemResult, | ICategorizeItemResult, | ||||
| } from '../interface'; | } from '../interface'; | ||||
| import useGraphStore from '../store'; | import useGraphStore from '../store'; | ||||
| // exclude some nodes downstream of the classification node | |||||
| const excludedNodes = [Operator.Categorize, Operator.Answer, Operator.Begin]; | |||||
| export const useBuildCategorizeToOptions = () => { | |||||
| const nodes = useGraphStore((state) => state.nodes); | |||||
| const buildCategorizeToOptions = useCallback( | |||||
| (toList: string[]) => { | |||||
| return nodes | |||||
| .filter( | |||||
| (x) => | |||||
| excludedNodes.every((y) => y !== x.data.label) && | |||||
| !toList.some((y) => y === x.id), // filter out selected values in other to fields from the current drop-down box options | |||||
| ) | |||||
| .map((x) => ({ label: x.data.name, value: x.id })); | |||||
| }, | |||||
| [nodes], | |||||
| ); | |||||
| return buildCategorizeToOptions; | |||||
| }; | |||||
| /** | /** | ||||
| * convert the following object into a list | * convert the following object into a list | ||||
| * | * | ||||
| return { handleValuesChange }; | return { handleValuesChange }; | ||||
| }; | }; | ||||
| export const useHandleToSelectChange = (nodeId?: string) => { | |||||
| const { addEdge, deleteEdgeBySourceAndSourceHandle } = useGraphStore( | |||||
| (state) => state, | |||||
| ); | |||||
| const handleSelectChange = useCallback( | |||||
| (name?: string) => (value?: string) => { | |||||
| if (nodeId && name) { | |||||
| if (value) { | |||||
| addEdge({ | |||||
| source: nodeId, | |||||
| target: value, | |||||
| sourceHandle: name, | |||||
| targetHandle: null, | |||||
| }); | |||||
| } else { | |||||
| // clear selected value | |||||
| deleteEdgeBySourceAndSourceHandle({ | |||||
| source: nodeId, | |||||
| sourceHandle: name, | |||||
| }); | |||||
| } | |||||
| } | |||||
| }, | |||||
| [addEdge, nodeId, deleteEdgeBySourceAndSourceHandle], | |||||
| ); | |||||
| return { handleSelectChange }; | |||||
| }; |
| }, | }, | ||||
| [Operator.Relevant]: { | [Operator.Relevant]: { | ||||
| description: 'BranchesOutlined description', | description: 'BranchesOutlined description', | ||||
| backgroundColor: 'white', | |||||
| backgroundColor: '#9fd94d', | |||||
| color: 'white', | |||||
| width: 70, | |||||
| height: 70, | |||||
| fontSize: 12, | |||||
| iconFontSize: 16, | |||||
| }, | }, | ||||
| [Operator.RewriteQuestion]: { | [Operator.RewriteQuestion]: { | ||||
| description: 'RewriteQuestion description', | description: 'RewriteQuestion description', | ||||
| [Operator.Generate]: initialGenerateValues, | [Operator.Generate]: initialGenerateValues, | ||||
| [Operator.Answer]: {}, | [Operator.Answer]: {}, | ||||
| [Operator.Categorize]: {}, | [Operator.Categorize]: {}, | ||||
| [Operator.Relevant]: {}, | |||||
| }; | }; | ||||
| export const CategorizeAnchorPointPositions = [ | export const CategorizeAnchorPointPositions = [ | ||||
| [Operator.Generate]: 'ragNode', | [Operator.Generate]: 'ragNode', | ||||
| [Operator.Answer]: 'ragNode', | [Operator.Answer]: 'ragNode', | ||||
| [Operator.Message]: 'ragNode', | [Operator.Message]: 'ragNode', | ||||
| [Operator.Relevant]: 'ragNode', | |||||
| [Operator.Relevant]: 'relevantNode', | |||||
| [Operator.RewriteQuestion]: 'ragNode', | [Operator.RewriteQuestion]: 'ragNode', | ||||
| }; | }; |
| import { useCallback } from 'react'; | |||||
| import { Operator } from './constant'; | |||||
| import useGraphStore from './store'; | |||||
| const ExcludedNodesMap = { | |||||
| // exclude some nodes downstream of the classification node | |||||
| [Operator.Categorize]: [Operator.Categorize, Operator.Answer, Operator.Begin], | |||||
| [Operator.Relevant]: [Operator.Begin], | |||||
| }; | |||||
| export const useBuildFormSelectOptions = ( | |||||
| operatorName: Operator, | |||||
| selfId?: string, // exclude the current node | |||||
| ) => { | |||||
| const nodes = useGraphStore((state) => state.nodes); | |||||
| const buildCategorizeToOptions = useCallback( | |||||
| (toList: string[]) => { | |||||
| const excludedNodes: Operator[] = ExcludedNodesMap[operatorName] ?? []; | |||||
| return nodes | |||||
| .filter( | |||||
| (x) => | |||||
| excludedNodes.every((y) => y !== x.data.label) && | |||||
| x.id !== selfId && | |||||
| !toList.some((y) => y === x.id), // filter out selected values in other to fields from the current drop-down box options | |||||
| ) | |||||
| .map((x) => ({ label: x.data.name, value: x.id })); | |||||
| }, | |||||
| [nodes, operatorName, selfId], | |||||
| ); | |||||
| return buildCategorizeToOptions; | |||||
| }; | |||||
| export const useHandleFormSelectChange = (nodeId?: string) => { | |||||
| const { addEdge, deleteEdgeBySourceAndSourceHandle } = useGraphStore( | |||||
| (state) => state, | |||||
| ); | |||||
| const handleSelectChange = useCallback( | |||||
| (name?: string) => (value?: string) => { | |||||
| if (nodeId && name) { | |||||
| if (value) { | |||||
| addEdge({ | |||||
| source: nodeId, | |||||
| target: value, | |||||
| sourceHandle: name, | |||||
| targetHandle: null, | |||||
| }); | |||||
| } else { | |||||
| // clear selected value | |||||
| deleteEdgeBySourceAndSourceHandle({ | |||||
| source: nodeId, | |||||
| sourceHandle: name, | |||||
| }); | |||||
| } | |||||
| } | |||||
| }, | |||||
| [addEdge, nodeId, deleteEdgeBySourceAndSourceHandle], | |||||
| ); | |||||
| return { handleSelectChange }; | |||||
| }; |
| category_description: ICategorizeItemResult; | category_description: ICategorizeItemResult; | ||||
| } | } | ||||
| export interface IRelevantForm extends IGenerateForm { | |||||
| yes: string; | |||||
| no: string; | |||||
| } | |||||
| export type NodeData = { | export type NodeData = { | ||||
| label: string; // operator type | label: string; // operator type | ||||
| name: string; // operator name | name: string; // operator name |
| import { useCallback, useEffect } from 'react'; | |||||
| import { Edge } from 'reactflow'; | |||||
| import { IOperatorForm } from '../interface'; | |||||
| import useGraphStore from '../store'; | |||||
| export const useBuildRelevantOptions = () => { | |||||
| const nodes = useGraphStore((state) => state.nodes); | |||||
| const buildRelevantOptions = useCallback( | |||||
| (toList: string[]) => { | |||||
| return nodes | |||||
| .filter( | |||||
| (x) => !toList.some((y) => y === x.id), // filter out selected values in other to fields from the current drop-down box options | |||||
| ) | |||||
| .map((x) => ({ label: x.data.name, value: x.id })); | |||||
| }, | |||||
| [nodes], | |||||
| ); | |||||
| return buildRelevantOptions; | |||||
| }; | |||||
| const getTargetOfEdge = (edges: Edge[], sourceHandle: string) => | |||||
| edges.find((x) => x.sourceHandle === sourceHandle)?.target; | |||||
| /** | |||||
| * monitor changes in the connection and synchronize the target to the yes and no fields of the form | |||||
| * similar to the categorize-form's useHandleFormValuesChange method | |||||
| * @param param0 | |||||
| */ | |||||
| export const useWatchConnectionChanges = ({ nodeId, form }: IOperatorForm) => { | |||||
| const edges = useGraphStore((state) => state.edges); | |||||
| const watchConnectionChanges = useCallback(() => { | |||||
| const edgeList = edges.filter((x) => x.source === nodeId); | |||||
| const yes = getTargetOfEdge(edgeList, 'yes'); | |||||
| const no = getTargetOfEdge(edgeList, 'no'); | |||||
| form?.setFieldsValue({ yes, no }); | |||||
| }, [edges, nodeId, form]); | |||||
| useEffect(() => { | |||||
| watchConnectionChanges(); | |||||
| }, [watchConnectionChanges]); | |||||
| }; |
| import LLMSelect from '@/components/llm-select'; | import LLMSelect from '@/components/llm-select'; | ||||
| import { useTranslate } from '@/hooks/commonHooks'; | import { useTranslate } from '@/hooks/commonHooks'; | ||||
| import { Form } from 'antd'; | |||||
| import { Form, Select } from 'antd'; | |||||
| import { Operator } from '../constant'; | |||||
| import { | |||||
| useBuildFormSelectOptions, | |||||
| useHandleFormSelectChange, | |||||
| } from '../form-hooks'; | |||||
| import { useSetLlmSetting } from '../hooks'; | import { useSetLlmSetting } from '../hooks'; | ||||
| import { IOperatorForm } from '../interface'; | import { IOperatorForm } from '../interface'; | ||||
| import { useWatchConnectionChanges } from './hooks'; | |||||
| const RelevantForm = ({ onValuesChange, form }: IOperatorForm) => { | |||||
| const { t } = useTranslate('chat'); | |||||
| const RelevantForm = ({ onValuesChange, form, node }: IOperatorForm) => { | |||||
| const { t } = useTranslate('flow'); | |||||
| useSetLlmSetting(form); | useSetLlmSetting(form); | ||||
| const buildRelevantOptions = useBuildFormSelectOptions( | |||||
| Operator.Relevant, | |||||
| node?.id, | |||||
| ); | |||||
| useWatchConnectionChanges({ nodeId: node?.id, form }); | |||||
| const { handleSelectChange } = useHandleFormSelectChange(node?.id); | |||||
| return ( | return ( | ||||
| <Form | <Form | ||||
| > | > | ||||
| <LLMSelect></LLMSelect> | <LLMSelect></LLMSelect> | ||||
| </Form.Item> | </Form.Item> | ||||
| <Form.Item label={t('yes')} name={'yes'}> | |||||
| <Select | |||||
| allowClear | |||||
| options={buildRelevantOptions([form?.getFieldValue('no')])} | |||||
| onChange={handleSelectChange('yes')} | |||||
| /> | |||||
| </Form.Item> | |||||
| <Form.Item label={t('no')} name={'no'}> | |||||
| <Select | |||||
| allowClear | |||||
| options={buildRelevantOptions([form?.getFieldValue('yes')])} | |||||
| onChange={handleSelectChange('no')} | |||||
| /> | |||||
| </Form.Item> | |||||
| </Form> | </Form> | ||||
| ); | ); | ||||
| }; | }; |
| return get().edges.find((x) => x.id === id); | return get().edges.find((x) => x.id === id); | ||||
| }, | }, | ||||
| deletePreviousEdgeOfClassificationNode: (connection: Connection) => { | deletePreviousEdgeOfClassificationNode: (connection: Connection) => { | ||||
| // Delete the edge on the classification node anchor when the anchor is connected to other nodes | |||||
| // Delete the edge on the classification node or relevant node anchor when the anchor is connected to other nodes | |||||
| const { edges, getOperatorTypeFromId } = get(); | const { edges, getOperatorTypeFromId } = get(); | ||||
| if (getOperatorTypeFromId(connection.source) === Operator.Categorize) { | |||||
| // the node containing the anchor | |||||
| const anchoredNodes = [Operator.Categorize, Operator.Relevant]; | |||||
| if ( | |||||
| anchoredNodes.some( | |||||
| (x) => x === getOperatorTypeFromId(connection.source), | |||||
| ) | |||||
| ) { | |||||
| const previousEdge = edges.find( | const previousEdge = edges.find( | ||||
| (x) => | (x) => | ||||
| x.source === connection.source && | x.source === connection.source && |