### What problem does this PR solve? Feat: Customize the output variable name of the loop operator #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -646,6 +646,7 @@ export const initialEmailValues = { | |||
| export const initialIterationValues = { | |||
| items_ref: '', | |||
| outputs: {}, | |||
| }; | |||
| export const initialIterationStartValues = { | |||
| outputs: { | |||
| @@ -1,12 +1,19 @@ | |||
| export type OutputType = { | |||
| title: string; | |||
| type: string; | |||
| type?: string; | |||
| }; | |||
| type OutputProps = { | |||
| list: Array<OutputType>; | |||
| }; | |||
| export function transferOutputs(outputs: Record<string, any>) { | |||
| return Object.entries(outputs).map(([key, value]) => ({ | |||
| title: key, | |||
| type: value?.type, | |||
| })); | |||
| } | |||
| export function Output({ list }: OutputProps) { | |||
| return ( | |||
| <section className="space-y-2"> | |||
| @@ -14,19 +14,18 @@ import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query'; | |||
| type QueryVariableProps = { name?: string; type?: VariableType }; | |||
| export function QueryVariable({ | |||
| name = 'query', | |||
| type = VariableType.String, | |||
| }: QueryVariableProps) { | |||
| export function QueryVariable({ name = 'query', type }: QueryVariableProps) { | |||
| const { t } = useTranslation(); | |||
| const form = useFormContext(); | |||
| const nextOptions = useBuildQueryVariableOptions(); | |||
| const finalOptions = useMemo(() => { | |||
| return nextOptions.map((x) => { | |||
| return { ...x, options: x.options.filter((y) => y.type === type) }; | |||
| }); | |||
| return type | |||
| ? nextOptions.map((x) => { | |||
| return { ...x, options: x.options.filter((y) => y.type === type) }; | |||
| }) | |||
| : nextOptions; | |||
| }, [nextOptions, type]); | |||
| return ( | |||
| @@ -0,0 +1,97 @@ | |||
| 'use client'; | |||
| import { FormContainer } from '@/components/form-container'; | |||
| import { SelectWithSearch } from '@/components/originui/select-with-search'; | |||
| import { BlockButton, Button } from '@/components/ui/button'; | |||
| import { | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { Separator } from '@/components/ui/separator'; | |||
| import { RAGFlowNodeType } from '@/interfaces/database/flow'; | |||
| import { X } from 'lucide-react'; | |||
| import { ReactNode } from 'react'; | |||
| import { useFieldArray, useFormContext } from 'react-hook-form'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useBuildSubNodeOutputOptions } from './use-build-options'; | |||
| interface IProps { | |||
| node?: RAGFlowNodeType; | |||
| } | |||
| export function DynamicOutputForm({ node }: IProps) { | |||
| const { t } = useTranslation(); | |||
| const form = useFormContext(); | |||
| const options = useBuildSubNodeOutputOptions(node?.id); | |||
| const name = 'outputs'; | |||
| const { fields, remove, append } = useFieldArray({ | |||
| name: name, | |||
| control: form.control, | |||
| }); | |||
| return ( | |||
| <div className="space-y-5"> | |||
| {fields.map((field, index) => { | |||
| const typeField = `${name}.${index}.name`; | |||
| return ( | |||
| <div key={field.id} className="flex items-center gap-2"> | |||
| <FormField | |||
| control={form.control} | |||
| name={typeField} | |||
| render={({ field }) => ( | |||
| <FormItem className="flex-1"> | |||
| <FormControl> | |||
| <Input | |||
| {...field} | |||
| placeholder={t('common.pleaseInput')} | |||
| ></Input> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <Separator className="w-3 text-text-sub-title" /> | |||
| <FormField | |||
| control={form.control} | |||
| name={`${name}.${index}.ref`} | |||
| render={({ field }) => ( | |||
| <FormItem className="w-2/5"> | |||
| <FormControl> | |||
| <SelectWithSearch | |||
| options={options} | |||
| {...field} | |||
| ></SelectWithSearch> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <Button variant={'ghost'} onClick={() => remove(index)}> | |||
| <X className="text-text-sub-title-invert " /> | |||
| </Button> | |||
| </div> | |||
| ); | |||
| })} | |||
| <BlockButton onClick={() => append({ name: '', ref: undefined })}> | |||
| Add | |||
| </BlockButton> | |||
| </div> | |||
| ); | |||
| } | |||
| export function VariableTitle({ title }: { title: ReactNode }) { | |||
| return <div className="font-medium text-text-title pb-2">{title}</div>; | |||
| } | |||
| export function DynamicOutput({ node }: IProps) { | |||
| return ( | |||
| <FormContainer> | |||
| <VariableTitle title={'Output'}></VariableTitle> | |||
| <DynamicOutputForm node={node}></DynamicOutputForm> | |||
| </FormContainer> | |||
| ); | |||
| } | |||
| @@ -2,36 +2,23 @@ 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 { useForm, useWatch } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import { initialRetrievalValues, VariableType } from '../../constant'; | |||
| import { useWatchFormChange } from '../../hooks/use-watch-form-change'; | |||
| import { VariableType } from '../../constant'; | |||
| import { INextOperatorForm } from '../../interface'; | |||
| import { Output } from '../components/output'; | |||
| import { QueryVariable } from '../components/query-variable'; | |||
| import { DynamicOutput } from './dynamic-output'; | |||
| import { OutputArray } from './interface'; | |||
| import { useValues } from './use-values'; | |||
| import { useWatchFormChange } from './use-watch-form-change'; | |||
| 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(), | |||
| outputs: z.array(z.object({ name: z.string(), value: z.any() })).optional(), | |||
| }); | |||
| const IterationForm = ({ node }: INextOperatorForm) => { | |||
| const outputList = useMemo(() => { | |||
| return [ | |||
| { | |||
| title: 'formalized_content', | |||
| type: initialRetrievalValues.outputs.formalized_content.type, | |||
| }, | |||
| ]; | |||
| }, []); | |||
| const defaultValues = useValues(node); | |||
| const form = useForm({ | |||
| @@ -39,6 +26,15 @@ const IterationForm = ({ node }: INextOperatorForm) => { | |||
| resolver: zodResolver(FormSchema), | |||
| }); | |||
| const outputs: OutputArray = useWatch({ | |||
| control: form?.control, | |||
| name: 'outputs', | |||
| }); | |||
| const outputList = useMemo(() => { | |||
| return outputs.map((x) => ({ title: x.name, type: x?.type })); | |||
| }, [outputs]); | |||
| useWatchFormChange(node?.id, form); | |||
| return ( | |||
| @@ -55,6 +51,7 @@ const IterationForm = ({ node }: INextOperatorForm) => { | |||
| type={VariableType.Array} | |||
| ></QueryVariable> | |||
| </FormContainer> | |||
| <DynamicOutput node={node}></DynamicOutput> | |||
| <Output list={outputList}></Output> | |||
| </form> | |||
| </Form> | |||
| @@ -0,0 +1,2 @@ | |||
| export type OutputArray = Array<{ name: string; ref: string; type?: string }>; | |||
| export type OutputObject = Record<string, { ref: string }>; | |||
| @@ -0,0 +1,31 @@ | |||
| import { isEmpty } from 'lodash'; | |||
| import { useMemo } from 'react'; | |||
| import { Operator } from '../../constant'; | |||
| import { buildOutputOptions } from '../../hooks/use-get-begin-query'; | |||
| import useGraphStore from '../../store'; | |||
| export function useBuildSubNodeOutputOptions(nodeId?: string) { | |||
| const { nodes } = useGraphStore((state) => state); | |||
| const nodeOutputOptions = useMemo(() => { | |||
| if (!nodeId) { | |||
| return []; | |||
| } | |||
| const subNodeWithOutputList = nodes.filter( | |||
| (x) => | |||
| x.parentId === nodeId && | |||
| x.data.label !== Operator.IterationStart && | |||
| !isEmpty(x.data?.form?.outputs), | |||
| ); | |||
| return subNodeWithOutputList.map((x) => ({ | |||
| label: x.data.name, | |||
| value: x.id, | |||
| title: x.data.name, | |||
| options: buildOutputOptions(x.data.form.outputs, x.id), | |||
| })); | |||
| }, [nodeId, nodes]); | |||
| return nodeOutputOptions; | |||
| } | |||
| @@ -2,24 +2,25 @@ import { RAGFlowNodeType } from '@/interfaces/database/flow'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { useMemo } from 'react'; | |||
| import { initialIterationValues } from '../../constant'; | |||
| import { OutputObject } from './interface'; | |||
| export function useValues(node?: RAGFlowNodeType) { | |||
| const defaultValues = useMemo( | |||
| () => ({ | |||
| ...initialIterationValues, | |||
| }), | |||
| [], | |||
| ); | |||
| function convertToArray(outputObject: OutputObject) { | |||
| return Object.entries(outputObject).map(([key, value]) => ({ | |||
| name: key, | |||
| ref: value.ref, | |||
| })); | |||
| } | |||
| export function useValues(node?: RAGFlowNodeType) { | |||
| const values = useMemo(() => { | |||
| const formData = node?.data?.form; | |||
| if (isEmpty(formData)) { | |||
| return defaultValues; | |||
| return { ...initialIterationValues, outputs: [] }; | |||
| } | |||
| return formData; | |||
| }, [defaultValues, node?.data?.form]); | |||
| return { ...formData, outputs: convertToArray(formData.outputs) }; | |||
| }, [node?.data?.form]); | |||
| return values; | |||
| } | |||
| @@ -0,0 +1,29 @@ | |||
| import { useEffect } from 'react'; | |||
| import { UseFormReturn, useWatch } from 'react-hook-form'; | |||
| import useGraphStore from '../../store'; | |||
| import { OutputArray, OutputObject } from './interface'; | |||
| function transferToObject(list: OutputArray) { | |||
| return list.reduce<OutputObject>((pre, cur) => { | |||
| pre[cur.name] = { ref: cur.ref }; | |||
| return pre; | |||
| }, {}); | |||
| } | |||
| export function useWatchFormChange(id?: string, form?: UseFormReturn) { | |||
| let values = useWatch({ control: form?.control }); | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| useEffect(() => { | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (id && form?.formState.isDirty) { | |||
| values = form?.getValues(); | |||
| let nextValues: any = { | |||
| ...values, | |||
| outputs: transferToObject(values.outputs), | |||
| }; | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| }, [form?.formState.isDirty, id, updateNodeForm, values]); | |||
| } | |||
| @@ -58,7 +58,7 @@ function filterAllUpstreamNodeIds(edges: Edge[], nodeIds: string[]) { | |||
| }, []); | |||
| } | |||
| function buildOutputOptions( | |||
| export function buildOutputOptions( | |||
| outputs: Record<string, any> = {}, | |||
| nodeId?: string, | |||
| ) { | |||