Feat: Add InnerBlurInput component to avoid frequent updates of zustand causing the input box to lose focus #3221 (#7955)

### What problem does this PR solve?

Feat: Add InnerBlurInput component to avoid frequent updates of zustand
causing the input box to lose focus #3221
### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu 2025-05-29 19:52:56 +08:00 committed by GitHub
parent 49ff1ca934
commit e97fd2b5e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 249 additions and 37 deletions

View File

@ -1,4 +1,5 @@
import { Form, InputNumber } from 'antd'; import { Form, InputNumber } from 'antd';
import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -8,7 +9,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from './ui/form'; } from './ui/form';
import { Input } from './ui/input'; import { BlurInput, Input } from './ui/input';
const MessageHistoryWindowSizeItem = ({ const MessageHistoryWindowSizeItem = ({
initialValue, initialValue,
@ -31,10 +32,20 @@ const MessageHistoryWindowSizeItem = ({
export default MessageHistoryWindowSizeItem; export default MessageHistoryWindowSizeItem;
export function MessageHistoryWindowSizeFormField() { type MessageHistoryWindowSizeFormFieldProps = {
useBlurInput?: boolean;
};
export function MessageHistoryWindowSizeFormField({
useBlurInput = false,
}: MessageHistoryWindowSizeFormFieldProps) {
const form = useFormContext(); const form = useFormContext();
const { t } = useTranslation(); const { t } = useTranslation();
const NextInput = useMemo(() => {
return useBlurInput ? BlurInput : Input;
}, [useBlurInput]);
return ( return (
<FormField <FormField
control={form.control} control={form.control}
@ -45,7 +56,7 @@ export function MessageHistoryWindowSizeFormField() {
{t('flow.messageHistoryWindowSize')} {t('flow.messageHistoryWindowSize')}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} type={'number'}></Input> <NextInput {...field} type={'number'}></NextInput>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -1,7 +1,14 @@
'use client'; 'use client';
import { CheckIcon, ChevronDownIcon } from 'lucide-react'; import { CheckIcon, ChevronDownIcon } from 'lucide-react';
import { Fragment, useCallback, useEffect, useId, useState } from 'react'; import {
Fragment,
forwardRef,
useCallback,
useEffect,
useId,
useState,
} from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -72,11 +79,10 @@ export type SelectWithSearchFlagProps = {
onChange?(value: string): void; onChange?(value: string): void;
}; };
export function SelectWithSearch({ export const SelectWithSearch = forwardRef<
value: val = '', React.ElementRef<typeof Button>,
onChange, SelectWithSearchFlagProps
options = countries, >(({ value: val = '', onChange, options = countries }, ref) => {
}: SelectWithSearchFlagProps) {
const id = useId(); const id = useId();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@ -102,6 +108,7 @@ export function SelectWithSearch({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
ref={ref}
className="bg-background hover:bg-background border-input w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px]" className="bg-background hover:bg-background border-input w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px]"
> >
{value ? ( {value ? (
@ -160,4 +167,4 @@ export function SelectWithSearch({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} });

View File

@ -67,4 +67,42 @@ const SearchInput = (props: InputProps) => {
); );
}; };
type Value = string | readonly string[] | number | undefined;
export const InnerBlurInput = React.forwardRef<
HTMLInputElement,
InputProps & { value: Value; onChange(value: Value): void }
>(({ value, onChange, ...props }, ref) => {
const [val, setVal] = React.useState<Value>();
const handleChange: React.ChangeEventHandler<HTMLInputElement> =
React.useCallback((e) => {
setVal(e.target.value);
}, []);
const handleBlur: React.FocusEventHandler<HTMLInputElement> =
React.useCallback(
(e) => {
onChange?.(e.target.value);
},
[onChange],
);
React.useEffect(() => {
setVal(value);
}, [value]);
return (
<Input
{...props}
value={val}
onBlur={handleBlur}
onChange={handleChange}
ref={ref}
></Input>
);
});
export const BlurInput = React.memo(InnerBlurInput);
export { ExpandedInput, Input, SearchInput }; export { ExpandedInput, Input, SearchInput };

View File

@ -20,3 +20,42 @@ const Textarea = React.forwardRef<
Textarea.displayName = 'Textarea'; Textarea.displayName = 'Textarea';
export { Textarea }; export { Textarea };
type Value = string | readonly string[] | number | undefined;
export const BlurTextarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'> & {
value: Value;
onChange(value: Value): void;
}
>(({ value, onChange, ...props }, ref) => {
const [val, setVal] = React.useState<Value>();
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> =
React.useCallback((e) => {
setVal(e.target.value);
}, []);
const handleBlur: React.FocusEventHandler<HTMLTextAreaElement> =
React.useCallback(
(e) => {
onChange?.(e.target.value);
},
[onChange],
);
React.useEffect(() => {
setVal(value);
}, [value]);
return (
<Textarea
{...props}
value={val}
onBlur={handleBlur}
onChange={handleChange}
ref={ref}
></Textarea>
);
});

View File

@ -11,6 +11,7 @@ import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { get, isPlainObject, lowerFirst } from 'lodash'; import { get, isPlainObject, lowerFirst } from 'lodash';
import omit from 'lodash/omit';
import { Play, X } from 'lucide-react'; import { Play, X } from 'lucide-react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -54,7 +55,7 @@ const FormSheet = ({
const OperatorForm = currentFormMap.component ?? EmptyContent; const OperatorForm = currentFormMap.component ?? EmptyContent;
const form = useForm({ const form = useForm({
defaultValues: currentFormMap.defaultValues, values: currentFormMap.defaultValues,
resolver: zodResolver(currentFormMap.schema), resolver: zodResolver(currentFormMap.schema),
}); });
@ -89,10 +90,16 @@ const FormSheet = ({
if (isPlainObject(formData)) { if (isPlainObject(formData)) {
// form.setFieldsValue({ ...formData, items }); // form.setFieldsValue({ ...formData, items });
console.info('xxx'); console.info('xxx');
form.reset({ ...formData, items }); const nextValues = {
...omit(formData, 'category_description'),
items,
};
// Object.entries(nextValues).forEach(([key, value]) => {
// form.setValue(key, value, { shouldDirty: false });
// });
form.reset(nextValues);
} }
} } else if (operatorName === Operator.Message) {
if (operatorName === Operator.Message) {
form.reset({ form.reset({
...formData, ...formData,
content: convertToObjectArray(formData.content), content: convertToObjectArray(formData.content),

View File

@ -124,15 +124,26 @@ export function useFormConfigMap() {
presencePenaltyEnabled: true, presencePenaltyEnabled: true,
frequencyPenaltyEnabled: true, frequencyPenaltyEnabled: true,
maxTokensEnabled: true, maxTokensEnabled: true,
items: [],
}, },
schema: z.object({ schema: z.object({
parameter: z.string().optional(), parameter: z.string().optional(),
...LlmSettingSchema, ...LlmSettingSchema,
message_history_window_size: z.number(), message_history_window_size: z.coerce.number(),
items: z.array( items: z.array(
z.object({ z
.object({
name: z.string().min(1, t('flow.nameMessage')).trim(), name: z.string().min(1, t('flow.nameMessage')).trim(),
}), description: z.string().optional(),
// examples: z
// .array(
// z.object({
// value: z.string(),
// }),
// )
// .optional(),
})
.optional(),
), ),
}), }),
}, },
@ -180,6 +191,12 @@ export function useFormConfigMap() {
arguments: z.array( arguments: z.array(
z.object({ name: z.string(), component_id: z.string() }), z.object({ name: z.string(), component_id: z.string() }),
), ),
return: z.union([
z
.array(z.object({ name: z.string(), component_id: z.string() }))
.optional(),
z.object({ name: z.string(), component_id: z.string() }),
]),
}), }),
}, },
[Operator.WaitingDialogue]: { [Operator.WaitingDialogue]: {

View File

@ -12,8 +12,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select'; import { BlurTextarea } from '@/components/ui/textarea';
import { Textarea } from '@/components/ui/textarea';
import { useTranslate } from '@/hooks/common-hooks'; import { useTranslate } from '@/hooks/common-hooks';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { useUpdateNodeInternals } from '@xyflow/react'; import { useUpdateNodeInternals } from '@xyflow/react';
@ -23,6 +22,7 @@ import { ChevronsUpDown, X } from 'lucide-react';
import { import {
ChangeEventHandler, ChangeEventHandler,
FocusEventHandler, FocusEventHandler,
memo,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@ -104,7 +104,7 @@ const NameInput = ({
); );
}; };
const FormSet = ({ nodeId, index }: IProps & { index: number }) => { const InnerFormSet = ({ nodeId, index }: IProps & { index: number }) => {
const form = useFormContext(); const form = useFormContext();
const { t } = useTranslate('flow'); const { t } = useTranslate('flow');
const buildCategorizeToOptions = useBuildFormSelectOptions( const buildCategorizeToOptions = useBuildFormSelectOptions(
@ -152,13 +152,13 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => {
<FormItem> <FormItem>
<FormLabel>{t('description')}</FormLabel> <FormLabel>{t('description')}</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} rows={3} /> <BlurTextarea {...field} rows={3} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField {/* <FormField
control={form.control} control={form.control}
name={buildFieldName('examples')} name={buildFieldName('examples')}
render={({ field }) => ( render={({ field }) => (
@ -170,8 +170,8 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
<FormField {/* <FormField
control={form.control} control={form.control}
name={buildFieldName('to')} name={buildFieldName('to')}
render={({ field }) => ( render={({ field }) => (
@ -202,11 +202,13 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
</section> </section>
); );
}; };
const FormSet = memo(InnerFormSet);
const DynamicCategorize = ({ nodeId }: IProps) => { const DynamicCategorize = ({ nodeId }: IProps) => {
const updateNodeInternals = useUpdateNodeInternals(); const updateNodeInternals = useUpdateNodeInternals();
const form = useFormContext(); const form = useFormContext();
@ -219,6 +221,8 @@ const DynamicCategorize = ({ nodeId }: IProps) => {
const handleAdd = () => { const handleAdd = () => {
append({ append({
name: humanId(), name: humanId(),
description: '',
// examples: [],
}); });
if (nodeId) updateNodeInternals(nodeId); if (nodeId) updateNodeInternals(nodeId);
}; };
@ -226,7 +230,7 @@ const DynamicCategorize = ({ nodeId }: IProps) => {
return ( return (
<div className="flex flex-col gap-4 "> <div className="flex flex-col gap-4 ">
{fields.map((field, index) => ( {fields.map((field, index) => (
<Collapsible key={field.id}> <Collapsible key={field.id} defaultOpen>
<div className="flex items-center justify-between space-x-4"> <div className="flex items-center justify-between space-x-4">
<h4 className="font-bold"> <h4 className="font-bold">
{form.getValues(`items.${index}.name`)} {form.getValues(`items.${index}.name`)}
@ -262,4 +266,4 @@ const DynamicCategorize = ({ nodeId }: IProps) => {
); );
}; };
export default DynamicCategorize; export default memo(DynamicCategorize);

View File

@ -41,7 +41,9 @@ const CategorizeForm = ({ form, node }: INextOperatorForm) => {
/> />
<LargeModelFormField></LargeModelFormField> <LargeModelFormField></LargeModelFormField>
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField> <MessageHistoryWindowSizeFormField
useBlurInput
></MessageHistoryWindowSizeFormField>
<DynamicCategorize nodeId={node?.id}></DynamicCategorize> <DynamicCategorize nodeId={node?.id}></DynamicCategorize>
</form> </form>
</Form> </Form>

View File

@ -8,7 +8,7 @@ import {
FormItem, FormItem,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { BlurInput } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select'; import { RAGFlowSelect } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { RAGFlowNodeType } from '@/interfaces/database/flow'; import { RAGFlowNodeType } from '@/interfaces/database/flow';
@ -58,10 +58,10 @@ export function DynamicVariableForm({ node, name = 'arguments' }: IProps) {
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-2/5"> <FormItem className="w-2/5">
<FormControl> <FormControl>
<Input <BlurInput
{...field} {...field}
placeholder={t('common.pleaseInput')} placeholder={t('common.pleaseInput')}
></Input> ></BlurInput>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -37,18 +37,49 @@ export const useHandleFormValuesChange = (
[updateNodeForm, id], [updateNodeForm, id],
); );
const value = useWatch({ control: form?.control }); let values = useWatch({ control: form?.control });
console.log('🚀 ~ x:', value); // console.log('🚀 ~ x:', values);
useEffect(() => { useEffect(() => {
// Manually triggered form updates are synchronized to the canvas // Manually triggered form updates are synchronized to the canvas
if (id && form?.formState.isDirty) { if (id && form?.formState.isDirty) {
console.log('🚀 ~ useEffect ~ value:', value, operatorName); values = form?.getValues();
let nextValues: any = values;
// run(id, nextValues); // run(id, nextValues);
updateNodeForm(id, value);
const categoryDescriptionRegex = /items\.\d+\.name/g;
if (operatorName === Operator.Categorize) {
console.log('🚀 ~ useEffect ~ values:', values);
const categoryDescription = Array.isArray(values.items)
? buildCategorizeObjectFromList(values.items)
: {};
if (categoryDescription) {
nextValues = {
...omit(values, 'items'),
category_description: categoryDescription,
};
} }
}, [form?.formState.isDirty, id, operatorName, updateNodeForm, value]); } else if (operatorName === Operator.Message) {
nextValues = {
...values,
content: convertToStringArray(values.content),
};
}
updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, operatorName, updateNodeForm, values]);
// useEffect(() => {
// form?.subscribe({
// formState: { values: true },
// callback: ({ values }) => {
// // console.info('subscribe', values);
// },
// });
// }, [form]);
return { handleValuesChange }; return { handleValuesChange };

56
web/src/pages/demo.tsx Normal file
View File

@ -0,0 +1,56 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { useCallback, useState } from 'react';
import DynamicCategorize from './agent/form/categorize-form/dynamic-categorize';
const formSchema = z.object({
items: z
.array(
z
.object({
name: z.string().min(1, 'xxx').trim(),
description: z.string().optional(),
// examples: z
// .array(
// z.object({
// value: z.string(),
// }),
// )
// .optional(),
})
.optional(),
)
.optional(),
});
export function Demo() {
const [flag, setFlag] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
items: [],
},
});
const handleReset = useCallback(() => {
form?.reset();
}, [form]);
const handleSwitch = useCallback(() => {
setFlag(true);
}, []);
return (
<div>
<Form {...form}>
<DynamicCategorize></DynamicCategorize>
</Form>
<Button onClick={handleReset}>reset</Button>
<Button onClick={handleSwitch}>switch</Button>
</div>
);
}