feat: 重构 AIResponseBox 组件,简化用户交互逻辑并引入共用表单组件
This commit is contained in:
@@ -8,8 +8,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
HStack
|
||||||
Textarea
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
|
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
ToolModuleResponseItemType,
|
ToolModuleResponseItemType,
|
||||||
UserChatItemValueItemType
|
UserChatItemValueItemType
|
||||||
} from '@fastgpt/global/core/chat/type';
|
} from '@fastgpt/global/core/chat/type';
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||||
import {
|
import {
|
||||||
@@ -26,16 +25,14 @@ import {
|
|||||||
UserSelectInteractive
|
UserSelectInteractive
|
||||||
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
|
||||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
|
||||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
|
||||||
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
|
||||||
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
|
|
||||||
import { SendPromptFnType } from '../ChatContainer/ChatBox/type';
|
|
||||||
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
|
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
|
||||||
|
import {
|
||||||
|
SelectOptionsComponent,
|
||||||
|
SelectOption,
|
||||||
|
FormInputComponent,
|
||||||
|
FormItem
|
||||||
|
} from './Form/FormComponents';
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
value: UserChatItemValueItemType | AIChatItemValueItemType;
|
value: UserChatItemValueItemType | AIChatItemValueItemType;
|
||||||
@@ -43,7 +40,12 @@ type props = {
|
|||||||
isChatting: boolean;
|
isChatting: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSendPrompt: SendPromptFnType = (e) => eventBus.emit(EventNameEnum.sendQuestion, e);
|
interface SendPromptParams {
|
||||||
|
text: string;
|
||||||
|
isInteractivePrompt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSendPrompt = (e: SendPromptParams) => eventBus.emit(EventNameEnum.sendQuestion, e);
|
||||||
|
|
||||||
const RenderText = React.memo(function RenderText({
|
const RenderText = React.memo(function RenderText({
|
||||||
showAnimation,
|
showAnimation,
|
||||||
@@ -53,11 +55,9 @@ const RenderText = React.memo(function RenderText({
|
|||||||
text?: string;
|
text?: string;
|
||||||
}) {
|
}) {
|
||||||
let source = text || '';
|
let source = text || '';
|
||||||
// First empty line
|
|
||||||
// if (!source && !isLastChild) return null;
|
|
||||||
|
|
||||||
return <Markdown source={source} showAnimation={showAnimation} />;
|
return <Markdown source={source} showAnimation={showAnimation} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
const RenderTool = React.memo(
|
const RenderTool = React.memo(
|
||||||
function RenderTool({
|
function RenderTool({
|
||||||
showAnimation,
|
showAnimation,
|
||||||
@@ -140,6 +140,7 @@ ${toolResponse}`}
|
|||||||
},
|
},
|
||||||
(prevProps, nextProps) => isEqual(prevProps, nextProps)
|
(prevProps, nextProps) => isEqual(prevProps, nextProps)
|
||||||
);
|
);
|
||||||
|
|
||||||
const RenderResoningContent = React.memo(function RenderResoningContent({
|
const RenderResoningContent = React.memo(function RenderResoningContent({
|
||||||
content,
|
content,
|
||||||
isChatting,
|
isChatting,
|
||||||
@@ -192,149 +193,66 @@ const RenderResoningContent = React.memo(function RenderResoningContent({
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const RenderUserSelectInteractive = React.memo(function RenderInteractive({
|
const RenderUserSelectInteractive = React.memo(function RenderInteractive({
|
||||||
interactive
|
interactive
|
||||||
}: {
|
}: {
|
||||||
interactive: InteractiveBasicType & UserSelectInteractive;
|
interactive: InteractiveBasicType & UserSelectInteractive;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SelectOptionsComponent
|
||||||
{interactive?.params?.description && <Markdown source={interactive.params.description} />}
|
options={(interactive.params.userSelectOptions || []) as SelectOption[]}
|
||||||
<Flex flexDirection={'column'} gap={2} w={'250px'}>
|
description={interactive.params.description}
|
||||||
{interactive.params.userSelectOptions?.map((option) => {
|
selectedValue={interactive.params.userSelectedVal}
|
||||||
const selected = option.value === interactive?.params?.userSelectedVal;
|
onSelectOption={(value: string) => {
|
||||||
|
onSendPrompt({
|
||||||
return (
|
text: value,
|
||||||
<Button
|
isInteractivePrompt: true
|
||||||
key={option.key}
|
});
|
||||||
variant={'whitePrimary'}
|
}}
|
||||||
whiteSpace={'pre-wrap'}
|
isDisabled={interactive.params.userSelectedVal !== undefined}
|
||||||
isDisabled={interactive?.params?.userSelectedVal !== undefined}
|
variant="whitePrimary"
|
||||||
{...(selected
|
/>
|
||||||
? {
|
|
||||||
_disabled: {
|
|
||||||
cursor: 'default',
|
|
||||||
borderColor: 'primary.300',
|
|
||||||
bg: 'primary.50 !important',
|
|
||||||
color: 'primary.600'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
onClick={() => {
|
|
||||||
onSendPrompt({
|
|
||||||
text: option.value,
|
|
||||||
isInteractivePrompt: true
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.value}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const RenderUserFormInteractive = React.memo(function RenderFormInput({
|
const RenderUserFormInteractive = React.memo(function RenderFormInput({
|
||||||
interactive
|
interactive
|
||||||
}: {
|
}: {
|
||||||
interactive: InteractiveBasicType & UserInputInteractive;
|
interactive: InteractiveBasicType & UserInputInteractive;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { register, setValue, handleSubmit: handleSubmitChat, control, reset } = useForm();
|
|
||||||
|
|
||||||
const onSubmit = useCallback((data: any) => {
|
// 处理默认值
|
||||||
|
const defaultValues = useMemo(() => {
|
||||||
|
if (interactive.type === 'userInput') {
|
||||||
|
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => {
|
||||||
|
acc[item.label] = !!item.value ? item.value : item.defaultValue;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [interactive]);
|
||||||
|
|
||||||
|
// 提交表单时的处理
|
||||||
|
const handleFormSubmit = useCallback((data: Record<string, any>) => {
|
||||||
onSendPrompt({
|
onSendPrompt({
|
||||||
text: JSON.stringify(data),
|
text: JSON.stringify(data),
|
||||||
isInteractivePrompt: true
|
isInteractivePrompt: true
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (interactive.type === 'userInput') {
|
|
||||||
const defaultValues = interactive.params.inputForm?.reduce(
|
|
||||||
(acc: Record<string, any>, item) => {
|
|
||||||
acc[item.label] = !!item.value ? item.value : item.defaultValue;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
reset(defaultValues);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex flexDirection={'column'} gap={2} w={'250px'}>
|
<Flex flexDirection={'column'} gap={2} w={'250px'}>
|
||||||
{interactive.params.description && <Markdown source={interactive.params.description} />}
|
<FormInputComponent
|
||||||
{interactive.params.inputForm?.map((input) => (
|
inputForm={(interactive.params.inputForm || []) as FormItem[]}
|
||||||
<Box key={input.label}>
|
description={interactive.params.description}
|
||||||
<Flex mb={1} alignItems={'center'}>
|
onSubmit={handleFormSubmit}
|
||||||
<FormLabel required={input.required}>{input.label}</FormLabel>
|
isDisabled={interactive.params.submitted}
|
||||||
{input.description && <QuestionTip ml={1} label={input.description} />}
|
defaultValues={defaultValues}
|
||||||
</Flex>
|
submitButtonText="common:Submit"
|
||||||
{input.type === FlowNodeInputTypeEnum.input && (
|
isCompact={true}
|
||||||
<MyTextarea
|
/>
|
||||||
isDisabled={interactive.params.submitted}
|
|
||||||
{...register(input.label, {
|
|
||||||
required: input.required
|
|
||||||
})}
|
|
||||||
bg={'white'}
|
|
||||||
autoHeight
|
|
||||||
minH={40}
|
|
||||||
maxH={100}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.textarea && (
|
|
||||||
<Textarea
|
|
||||||
isDisabled={interactive.params.submitted}
|
|
||||||
bg={'white'}
|
|
||||||
{...register(input.label, {
|
|
||||||
required: input.required
|
|
||||||
})}
|
|
||||||
rows={5}
|
|
||||||
maxLength={input.maxLength || 4000}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.numberInput && (
|
|
||||||
<MyNumberInput
|
|
||||||
min={input.min}
|
|
||||||
max={input.max}
|
|
||||||
defaultValue={input.defaultValue}
|
|
||||||
isDisabled={interactive.params.submitted}
|
|
||||||
bg={'white'}
|
|
||||||
register={register}
|
|
||||||
name={input.label}
|
|
||||||
isRequired={input.required}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.select && (
|
|
||||||
<Controller
|
|
||||||
key={input.label}
|
|
||||||
control={control}
|
|
||||||
name={input.label}
|
|
||||||
rules={{ required: input.required }}
|
|
||||||
render={({ field: { ref, value } }) => {
|
|
||||||
if (!input.list) return <></>;
|
|
||||||
return (
|
|
||||||
<MySelect
|
|
||||||
ref={ref}
|
|
||||||
width={'100%'}
|
|
||||||
list={input.list}
|
|
||||||
value={value}
|
|
||||||
isDisabled={interactive.params.submitted}
|
|
||||||
onChange={(e) => setValue(input.label, e)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{!interactive.params.submitted && (
|
|
||||||
<Flex w={'full'} justifyContent={'end'}>
|
|
||||||
<Button onClick={handleSubmitChat(onSubmit)}>{t('common:Submit')}</Button>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -360,6 +278,8 @@ const AIResponseBox = ({ value, isLastResponseValue, isChatting }: props) => {
|
|||||||
if (value.interactive?.type === 'userInput')
|
if (value.interactive?.type === 'userInput')
|
||||||
return <RenderUserFormInteractive interactive={value.interactive} />;
|
return <RenderUserFormInteractive interactive={value.interactive} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(AIResponseBox);
|
export default React.memo(AIResponseBox);
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { Controller, useForm, UseFormProps, UseFormReturn, FieldValues } from 'react-hook-form';
|
||||||
|
import Markdown from '@/components/Markdown';
|
||||||
|
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||||
|
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||||
|
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||||
|
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
||||||
|
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
|
||||||
|
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||||
|
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||||
|
|
||||||
|
// 定义IconName类型 - 这应该与MyIcon组件要求的类型匹配
|
||||||
|
type IconName = 'core/workflow/debugNext' | 'common/loading' | 'core/chat/think';
|
||||||
|
|
||||||
|
// 定义选项接口
|
||||||
|
export interface SelectOption {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义SelectOptionsComponent接口
|
||||||
|
export interface SelectOptionsComponentProps {
|
||||||
|
options: SelectOption[];
|
||||||
|
description?: string;
|
||||||
|
selectedValue?: string;
|
||||||
|
onSelectOption: (value: string) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 共享的选项按钮组件
|
||||||
|
*/
|
||||||
|
export const SelectOptionsComponent = React.memo(function SelectOptionsComponent({
|
||||||
|
options = [],
|
||||||
|
description,
|
||||||
|
selectedValue,
|
||||||
|
onSelectOption,
|
||||||
|
isDisabled = false,
|
||||||
|
variant = 'outline'
|
||||||
|
}: SelectOptionsComponentProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{description && (
|
||||||
|
<Box
|
||||||
|
mb={4}
|
||||||
|
p={4}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="blue.200"
|
||||||
|
bg="blue.50"
|
||||||
|
borderRadius="md"
|
||||||
|
boxShadow="sm"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Markdown source={description} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Flex flexDirection={'column'} gap={3} maxW={'400px'} mx="auto">
|
||||||
|
{options.map((option: SelectOption) => {
|
||||||
|
const selected = option.value === selectedValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.key}
|
||||||
|
variant={variant}
|
||||||
|
height="auto"
|
||||||
|
py={3}
|
||||||
|
px={4}
|
||||||
|
fontWeight="medium"
|
||||||
|
borderWidth="1.5px"
|
||||||
|
whiteSpace={'pre-wrap'}
|
||||||
|
_hover={{
|
||||||
|
bg: 'primary.50',
|
||||||
|
borderColor: 'primary.300'
|
||||||
|
}}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
{...(selected
|
||||||
|
? {
|
||||||
|
borderColor: 'primary.500',
|
||||||
|
bg: 'primary.50',
|
||||||
|
color: 'primary.700',
|
||||||
|
_disabled: {
|
||||||
|
cursor: 'default',
|
||||||
|
borderColor: 'primary.500',
|
||||||
|
bg: 'primary.50 !important',
|
||||||
|
color: 'primary.700',
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
onClick={() => onSelectOption(option.value)}
|
||||||
|
>
|
||||||
|
{option.value}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义表单项接口
|
||||||
|
export interface FormItem {
|
||||||
|
label: string;
|
||||||
|
key?: string;
|
||||||
|
type: FlowNodeInputTypeEnum;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
defaultValue?: any;
|
||||||
|
value?: any;
|
||||||
|
maxLength?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
list?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义FormInputComponent接口
|
||||||
|
export interface FormInputComponentProps {
|
||||||
|
inputForm: FormItem[];
|
||||||
|
description?: string;
|
||||||
|
onSubmit?: (data: Record<string, any>) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
defaultValues?: Record<string, any>;
|
||||||
|
submitButtonText?: 'common:Submit' | string; // 使用联合类型指定特定的i18n键名
|
||||||
|
showSubmitButton?: boolean;
|
||||||
|
submitButtonIcon?: IconName;
|
||||||
|
isCompact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 共享的表单呈现组件
|
||||||
|
*/
|
||||||
|
export const FormInputComponent = React.memo(function FormInputComponent({
|
||||||
|
inputForm = [],
|
||||||
|
description,
|
||||||
|
onSubmit,
|
||||||
|
isDisabled = false,
|
||||||
|
defaultValues = {},
|
||||||
|
submitButtonText = 'common:Submit',
|
||||||
|
showSubmitButton = true,
|
||||||
|
submitButtonIcon,
|
||||||
|
isCompact = false
|
||||||
|
}: FormInputComponentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { register, setValue, handleSubmit, control, reset, getValues } = useForm({
|
||||||
|
defaultValues
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
(data: Record<string, any>) => {
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{description && (
|
||||||
|
<Box
|
||||||
|
mb={4}
|
||||||
|
p={4}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="blue.200"
|
||||||
|
bg="blue.50"
|
||||||
|
borderRadius="md"
|
||||||
|
boxShadow="sm"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Markdown source={description} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit(handleFormSubmit)}
|
||||||
|
maxW={isCompact ? 'auto' : '560px'}
|
||||||
|
mx="auto"
|
||||||
|
p={isCompact ? 0 : 4}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<Flex flexDirection={'column'} gap={5} w={'100%'}>
|
||||||
|
{inputForm.map((input: FormItem) => (
|
||||||
|
<Box key={input.label} mb={2}>
|
||||||
|
<Flex mb={2} alignItems={'center'}>
|
||||||
|
<FormLabel required={input.required} mb={0} fontWeight="medium" color="gray.700">
|
||||||
|
{input.label}
|
||||||
|
</FormLabel>
|
||||||
|
{input.description && <QuestionTip ml={1} label={input.description} />}
|
||||||
|
</Flex>
|
||||||
|
{input.type === FlowNodeInputTypeEnum.input && (
|
||||||
|
<MyTextarea
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
{...register(input.label, {
|
||||||
|
required: input.required
|
||||||
|
})}
|
||||||
|
bg={'white'}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.300"
|
||||||
|
_hover={{ borderColor: 'gray.400' }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: 'primary.500',
|
||||||
|
boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)'
|
||||||
|
}}
|
||||||
|
autoHeight
|
||||||
|
minH={40}
|
||||||
|
maxH={100}
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{input.type === FlowNodeInputTypeEnum.textarea && (
|
||||||
|
<Textarea
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
bg={'white'}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.300"
|
||||||
|
_hover={{ borderColor: 'gray.400' }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: 'primary.500',
|
||||||
|
boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)'
|
||||||
|
}}
|
||||||
|
{...register(input.label, {
|
||||||
|
required: input.required
|
||||||
|
})}
|
||||||
|
rows={5}
|
||||||
|
maxLength={input.maxLength || 4000}
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{input.type === FlowNodeInputTypeEnum.numberInput && (
|
||||||
|
<Box position="relative">
|
||||||
|
<MyNumberInput
|
||||||
|
min={input.min}
|
||||||
|
max={input.max}
|
||||||
|
defaultValue={input.defaultValue}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
bg={'white'}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="md"
|
||||||
|
borderColor="gray.300"
|
||||||
|
_hover={{ borderColor: 'gray.400' }}
|
||||||
|
_focus={{ borderColor: 'primary.500' }}
|
||||||
|
register={register}
|
||||||
|
name={input.label}
|
||||||
|
isRequired={input.required}
|
||||||
|
sx={{
|
||||||
|
'& input': {
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
px: 3,
|
||||||
|
borderRadius: 'md',
|
||||||
|
border: 'none',
|
||||||
|
_focus: { outline: 'none' }
|
||||||
|
},
|
||||||
|
'& button': {
|
||||||
|
border: 'none',
|
||||||
|
bg: 'transparent',
|
||||||
|
color: 'gray.500'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{input.type === FlowNodeInputTypeEnum.select && (
|
||||||
|
<Controller
|
||||||
|
key={input.label}
|
||||||
|
control={control}
|
||||||
|
name={input.label}
|
||||||
|
rules={{ required: input.required }}
|
||||||
|
render={({ field: { ref, value } }) => {
|
||||||
|
if (!input.list) return <></>;
|
||||||
|
return (
|
||||||
|
<MySelect
|
||||||
|
ref={ref}
|
||||||
|
width={'100%'}
|
||||||
|
variant="outline"
|
||||||
|
borderColor="gray.300"
|
||||||
|
borderRadius="md"
|
||||||
|
height="40px"
|
||||||
|
bg="white"
|
||||||
|
_hover={{ borderColor: 'gray.400' }}
|
||||||
|
list={input.list}
|
||||||
|
value={value}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onChange={(e) => setValue(input.label, e)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showSubmitButton && (
|
||||||
|
<Flex w={'full'} justifyContent={'flex-end'} mt={3} gap={2}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={
|
||||||
|
submitButtonIcon ? <MyIcon name={submitButtonIcon} w={'16px'} /> : undefined
|
||||||
|
}
|
||||||
|
colorScheme="blue"
|
||||||
|
variant="solid"
|
||||||
|
>
|
||||||
|
{t(submitButtonText as any)}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义FormHandler接口
|
||||||
|
export interface UseFormHandlerReturn<T extends FieldValues = Record<string, any>> {
|
||||||
|
register: UseFormReturn<T>['register'];
|
||||||
|
setValue: UseFormReturn<T>['setValue'];
|
||||||
|
handleSubmit: UseFormReturn<T>['handleSubmit'];
|
||||||
|
onSubmit: (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||||
|
control: UseFormReturn<T>['control'];
|
||||||
|
reset: UseFormReturn<T>['reset'];
|
||||||
|
getValues: UseFormReturn<T>['getValues'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建共享的表单Hook
|
||||||
|
*/
|
||||||
|
export const useFormHandler = <T extends FieldValues = Record<string, any>>(
|
||||||
|
formConfig: UseFormProps<T> = {},
|
||||||
|
onSubmitCallback?: (data: T) => void
|
||||||
|
): UseFormHandlerReturn<T> => {
|
||||||
|
const methods = useForm<T>(formConfig);
|
||||||
|
const { handleSubmit } = methods;
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(data: T) => {
|
||||||
|
if (onSubmitCallback) {
|
||||||
|
onSubmitCallback(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSubmitCallback]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...methods,
|
||||||
|
onSubmit: handleSubmit(onSubmit)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Button, Flex, Textarea, FormLabel as ChakraFormLabel } from '@chakra-ui/react';
|
import { Box } from '@chakra-ui/react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import Markdown from '@/components/Markdown';
|
|
||||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
|
||||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
|
||||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
|
||||||
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
|
||||||
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
|
|
||||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
|
||||||
import {
|
|
||||||
UserInputInteractive,
|
|
||||||
UserSelectInteractive
|
|
||||||
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
|
||||||
import { useContextSelector } from 'use-context-selector';
|
import { useContextSelector } from 'use-context-selector';
|
||||||
import { WorkflowContext } from '@/pageComponents/app/detail/WorkflowComponents/context';
|
import { WorkflowContext } from '@/pageComponents/app/detail/WorkflowComponents/context';
|
||||||
import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
|
import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
|
||||||
import { ChatItemType, UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
|
import { ChatItemType, UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
|
||||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
|
||||||
import { initWorkflowEdgeStatus } from '@fastgpt/global/core/workflow/runtime/utils';
|
import { initWorkflowEdgeStatus } from '@fastgpt/global/core/workflow/runtime/utils';
|
||||||
|
import {
|
||||||
|
UserInputInteractive,
|
||||||
|
UserSelectInteractive
|
||||||
|
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
||||||
|
import {
|
||||||
|
FormInputComponent,
|
||||||
|
FormItem,
|
||||||
|
SelectOption,
|
||||||
|
SelectOptionsComponent
|
||||||
|
} from './Form/FormComponents';
|
||||||
|
|
||||||
// 创建共用的交互式调试 Hook
|
// 创建共用的交互式调试 Hook
|
||||||
const useInteractiveDebug = (
|
const useInteractiveDebug = (
|
||||||
@@ -120,58 +117,13 @@ export const RenderUserSelectInteractive = React.memo(function RenderInteractive
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={4} py={3}>
|
<Box px={4} py={3}>
|
||||||
{interactive?.params?.description && (
|
<SelectOptionsComponent
|
||||||
<Box
|
options={(interactive.params.userSelectOptions || []) as SelectOption[]}
|
||||||
mb={4}
|
description={interactive.params.description}
|
||||||
p={3}
|
selectedValue={interactive.params.userSelectedVal}
|
||||||
borderLeft="4px solid"
|
onSelectOption={handleSelectAndNext}
|
||||||
borderColor="primary.100"
|
isDisabled={interactive.params.userSelectedVal !== undefined}
|
||||||
bg="primary.50"
|
/>
|
||||||
borderRadius="md"
|
|
||||||
>
|
|
||||||
<Markdown source={interactive.params.description} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Flex flexDirection={'column'} gap={3} maxW={'400px'} mx="auto">
|
|
||||||
{interactive.params.userSelectOptions?.map((option) => {
|
|
||||||
const selected = option.value === interactive?.params?.userSelectedVal;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={option.key}
|
|
||||||
variant={'outline'}
|
|
||||||
height="auto"
|
|
||||||
py={3}
|
|
||||||
px={4}
|
|
||||||
fontWeight="medium"
|
|
||||||
borderWidth="1.5px"
|
|
||||||
whiteSpace={'pre-wrap'}
|
|
||||||
_hover={{
|
|
||||||
bg: 'primary.50',
|
|
||||||
borderColor: 'primary.300'
|
|
||||||
}}
|
|
||||||
isDisabled={interactive?.params?.userSelectedVal !== undefined}
|
|
||||||
{...(selected
|
|
||||||
? {
|
|
||||||
borderColor: 'primary.500',
|
|
||||||
bg: 'primary.50',
|
|
||||||
color: 'primary.700',
|
|
||||||
_disabled: {
|
|
||||||
cursor: 'default',
|
|
||||||
borderColor: 'primary.500',
|
|
||||||
bg: 'primary.50 !important',
|
|
||||||
color: 'primary.700',
|
|
||||||
opacity: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
onClick={() => handleSelectAndNext(option.value)}
|
|
||||||
>
|
|
||||||
{option.value}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -184,23 +136,23 @@ export const RenderUserFormInteractive = React.memo(function RenderFormInput({
|
|||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
register,
|
|
||||||
setValue,
|
|
||||||
handleSubmit: handleSubmitChat,
|
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
getValues
|
|
||||||
} = useForm();
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const { startDebug } = useInteractiveDebug(interactive, nodeId);
|
const { startDebug } = useInteractiveDebug(interactive, nodeId);
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
// 处理默认值
|
||||||
(data: any) => {
|
const defaultValues = useMemo(() => {
|
||||||
|
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => {
|
||||||
|
acc[item.label] = !!item.value ? item.value : item.defaultValue;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [interactive.params.inputForm]);
|
||||||
|
|
||||||
|
// 提交表单时的处理
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
(formData: Record<string, any>) => {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
|
||||||
const formData = getValues();
|
|
||||||
startDebug(JSON.stringify(formData), (node) => ({
|
startDebug(JSON.stringify(formData), (node) => ({
|
||||||
...node,
|
...node,
|
||||||
inputs: node.inputs.map((input: { key: string }) => {
|
inputs: node.inputs.map((input: { key: string }) => {
|
||||||
@@ -215,177 +167,27 @@ export const RenderUserFormInteractive = React.memo(function RenderFormInput({
|
|||||||
formSubmitted: true
|
formSubmitted: true
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[nodeId, getValues, startDebug, interactive.params.inputForm]
|
[nodeId, startDebug, interactive.params.inputForm]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 设置已提交状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interactive.type === 'userInput') {
|
|
||||||
const defaultValues = interactive.params.inputForm?.reduce(
|
|
||||||
(acc: Record<string, any>, item) => {
|
|
||||||
acc[item.label] = !!item.value ? item.value : item.defaultValue;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
reset(defaultValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已经有表单结果,标记为已提交
|
|
||||||
if (interactive.params.submitted) {
|
if (interactive.params.submitted) {
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
}
|
}
|
||||||
}, [interactive, reset]);
|
}, [interactive.params.submitted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={4} py={4} bg="white" borderRadius="md">
|
<Box px={4} py={4} bg="white" borderRadius="md">
|
||||||
{interactive.params.description && (
|
<FormInputComponent
|
||||||
<Box
|
inputForm={(interactive.params.inputForm || []) as FormItem[]}
|
||||||
mb={4}
|
description={interactive.params.description}
|
||||||
p={3}
|
onSubmit={handleFormSubmit}
|
||||||
borderLeft="4px solid"
|
isDisabled={isSubmitted || interactive.params.submitted}
|
||||||
borderColor="blue.100"
|
defaultValues={defaultValues}
|
||||||
bg="blue.50"
|
submitButtonText="common:Submit"
|
||||||
borderRadius="md"
|
submitButtonIcon="core/workflow/debugNext"
|
||||||
>
|
/>
|
||||||
<Markdown source={interactive.params.description} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
as="form"
|
|
||||||
onSubmit={handleSubmitChat(onSubmit)}
|
|
||||||
maxW="560px"
|
|
||||||
mx="auto"
|
|
||||||
bg="white"
|
|
||||||
p={4}
|
|
||||||
borderRadius="md"
|
|
||||||
>
|
|
||||||
<Flex flexDirection={'column'} gap={5} w={'100%'}>
|
|
||||||
{interactive.params.inputForm?.map((input) => (
|
|
||||||
<Box key={input.label} mb={2}>
|
|
||||||
<Flex mb={2} alignItems={'center'}>
|
|
||||||
<FormLabel required={input.required} mb={0} fontWeight="medium" color="gray.700">
|
|
||||||
{input.label}
|
|
||||||
</FormLabel>
|
|
||||||
{input.description && <QuestionTip ml={1} label={input.description} />}
|
|
||||||
</Flex>
|
|
||||||
{input.type === FlowNodeInputTypeEnum.input && (
|
|
||||||
<MyTextarea
|
|
||||||
isDisabled={isSubmitted || interactive.params.submitted}
|
|
||||||
{...register(input.label, {
|
|
||||||
required: input.required
|
|
||||||
})}
|
|
||||||
bg={'white'}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor="gray.300"
|
|
||||||
_hover={{ borderColor: 'gray.400' }}
|
|
||||||
_focus={{
|
|
||||||
borderColor: 'primary.500',
|
|
||||||
boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)'
|
|
||||||
}}
|
|
||||||
autoHeight
|
|
||||||
minH={40}
|
|
||||||
maxH={100}
|
|
||||||
borderRadius="md"
|
|
||||||
p={3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.textarea && (
|
|
||||||
<Textarea
|
|
||||||
isDisabled={isSubmitted || interactive.params.submitted}
|
|
||||||
bg={'white'}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor="gray.300"
|
|
||||||
_hover={{ borderColor: 'gray.400' }}
|
|
||||||
_focus={{
|
|
||||||
borderColor: 'primary.500',
|
|
||||||
boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)'
|
|
||||||
}}
|
|
||||||
{...register(input.label, {
|
|
||||||
required: input.required
|
|
||||||
})}
|
|
||||||
rows={5}
|
|
||||||
maxLength={input.maxLength || 4000}
|
|
||||||
borderRadius="md"
|
|
||||||
p={3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.numberInput && (
|
|
||||||
<Box position="relative">
|
|
||||||
<MyNumberInput
|
|
||||||
min={input.min}
|
|
||||||
max={input.max}
|
|
||||||
defaultValue={input.defaultValue}
|
|
||||||
isDisabled={isSubmitted || interactive.params.submitted}
|
|
||||||
bg={'white'}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="md"
|
|
||||||
borderColor="gray.300"
|
|
||||||
_hover={{ borderColor: 'gray.400' }}
|
|
||||||
_focus={{ borderColor: 'primary.500' }}
|
|
||||||
register={register}
|
|
||||||
name={input.label}
|
|
||||||
isRequired={input.required}
|
|
||||||
sx={{
|
|
||||||
'& input': {
|
|
||||||
width: '100%',
|
|
||||||
height: '40px',
|
|
||||||
px: 3,
|
|
||||||
borderRadius: 'md',
|
|
||||||
border: 'none',
|
|
||||||
_focus: { outline: 'none' }
|
|
||||||
},
|
|
||||||
'& button': {
|
|
||||||
border: 'none',
|
|
||||||
bg: 'transparent',
|
|
||||||
color: 'gray.500'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{input.type === FlowNodeInputTypeEnum.select && (
|
|
||||||
<Controller
|
|
||||||
key={input.label}
|
|
||||||
control={control}
|
|
||||||
name={input.label}
|
|
||||||
rules={{ required: input.required }}
|
|
||||||
render={({ field: { ref, value } }) => {
|
|
||||||
if (!input.list) return <></>;
|
|
||||||
return (
|
|
||||||
<MySelect
|
|
||||||
ref={ref}
|
|
||||||
width={'100%'}
|
|
||||||
variant="outline"
|
|
||||||
borderColor="gray.300"
|
|
||||||
borderRadius="md"
|
|
||||||
height="40px"
|
|
||||||
bg="white"
|
|
||||||
_hover={{ borderColor: 'gray.400' }}
|
|
||||||
list={input.list}
|
|
||||||
value={value}
|
|
||||||
isDisabled={isSubmitted || interactive.params.submitted}
|
|
||||||
onChange={(e) => setValue(input.label, e)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Flex w={'full'} justifyContent={'flex-end'} mt={3} gap={2}>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<MyIcon name={'core/workflow/debugNext'} w={'16px'} />}
|
|
||||||
colorScheme="blue"
|
|
||||||
variant="solid"
|
|
||||||
>
|
|
||||||
{t('common:Submit')}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user