### 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
| export const initialIterationValues = { | export const initialIterationValues = { | ||||
| items_ref: '', | items_ref: '', | ||||
| outputs: {}, | |||||
| }; | }; | ||||
| export const initialIterationStartValues = { | export const initialIterationStartValues = { | ||||
| outputs: { | outputs: { |
| export type OutputType = { | export type OutputType = { | ||||
| title: string; | title: string; | ||||
| type: string; | |||||
| type?: string; | |||||
| }; | }; | ||||
| type OutputProps = { | type OutputProps = { | ||||
| list: Array<OutputType>; | 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) { | export function Output({ list }: OutputProps) { | ||||
| return ( | return ( | ||||
| <section className="space-y-2"> | <section className="space-y-2"> |
| type QueryVariableProps = { name?: string; type?: VariableType }; | 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 { t } = useTranslation(); | ||||
| const form = useFormContext(); | const form = useFormContext(); | ||||
| const nextOptions = useBuildQueryVariableOptions(); | const nextOptions = useBuildQueryVariableOptions(); | ||||
| const finalOptions = useMemo(() => { | 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]); | }, [nextOptions, type]); | ||||
| return ( | return ( |
| '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> | |||||
| ); | |||||
| } |
| import { Form } from '@/components/ui/form'; | import { Form } from '@/components/ui/form'; | ||||
| import { zodResolver } from '@hookform/resolvers/zod'; | import { zodResolver } from '@hookform/resolvers/zod'; | ||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||
| import { useForm } from 'react-hook-form'; | |||||
| import { useForm, useWatch } from 'react-hook-form'; | |||||
| import { z } from 'zod'; | 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 { INextOperatorForm } from '../../interface'; | ||||
| import { Output } from '../components/output'; | import { Output } from '../components/output'; | ||||
| import { QueryVariable } from '../components/query-variable'; | import { QueryVariable } from '../components/query-variable'; | ||||
| import { DynamicOutput } from './dynamic-output'; | |||||
| import { OutputArray } from './interface'; | |||||
| import { useValues } from './use-values'; | import { useValues } from './use-values'; | ||||
| import { useWatchFormChange } from './use-watch-form-change'; | |||||
| const FormSchema = z.object({ | const FormSchema = z.object({ | ||||
| query: z.string().optional(), | 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 IterationForm = ({ node }: INextOperatorForm) => { | ||||
| const outputList = useMemo(() => { | |||||
| return [ | |||||
| { | |||||
| title: 'formalized_content', | |||||
| type: initialRetrievalValues.outputs.formalized_content.type, | |||||
| }, | |||||
| ]; | |||||
| }, []); | |||||
| const defaultValues = useValues(node); | const defaultValues = useValues(node); | ||||
| const form = useForm({ | const form = useForm({ | ||||
| resolver: zodResolver(FormSchema), | 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); | useWatchFormChange(node?.id, form); | ||||
| return ( | return ( | ||||
| type={VariableType.Array} | type={VariableType.Array} | ||||
| ></QueryVariable> | ></QueryVariable> | ||||
| </FormContainer> | </FormContainer> | ||||
| <DynamicOutput node={node}></DynamicOutput> | |||||
| <Output list={outputList}></Output> | <Output list={outputList}></Output> | ||||
| </form> | </form> | ||||
| </Form> | </Form> |
| export type OutputArray = Array<{ name: string; ref: string; type?: string }>; | |||||
| export type OutputObject = Record<string, { ref: string }>; |
| 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; | |||||
| } |
| import { isEmpty } from 'lodash'; | import { isEmpty } from 'lodash'; | ||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||
| import { initialIterationValues } from '../../constant'; | 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 values = useMemo(() => { | ||||
| const formData = node?.data?.form; | const formData = node?.data?.form; | ||||
| if (isEmpty(formData)) { | 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; | return values; | ||||
| } | } |
| 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]); | |||||
| } |
| }, []); | }, []); | ||||
| } | } | ||||
| function buildOutputOptions( | |||||
| export function buildOutputOptions( | |||||
| outputs: Record<string, any> = {}, | outputs: Record<string, any> = {}, | ||||
| nodeId?: string, | nodeId?: string, | ||||
| ) { | ) { |