V4.7-alpha (#985)

Co-authored-by: heheer <71265218+newfish-cmyk@users.noreply.github.com>
This commit is contained in:
Archer
2024-03-13 10:50:02 +08:00
committed by GitHub
parent 5bca15f12f
commit 9501c3f3a1
170 changed files with 5786 additions and 2342 deletions

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { ModalBody, Box, useTheme, Flex, Image } from '@chakra-ui/react';
import { ChatItemType } from '@fastgpt/global/core/chat/type';
import { ModalBody, Box, useTheme } from '@chakra-ui/react';
import MyModal from '../MyModal';
import { DispatchNodeResponseType } from '@fastgpt/global/core/module/runtime/type.d';
const ContextModal = ({
context = [],
onClose
}: {
context: ChatItemType[];
context: DispatchNodeResponseType['historyPreview'];
onClose: () => void;
}) => {
const theme = useTheme();
@@ -17,7 +17,7 @@ const ContextModal = ({
isOpen={true}
onClose={onClose}
iconSrc="/imgs/modal/chatHistory.svg"
title={`完整对话记录(${context.length}条)`}
title={`上下文预览(${context.length}条)`}
h={['90vh', '80vh']}
minW={['90vw', '600px']}
isCentered

View File

@@ -1,36 +1,24 @@
import { useSpeech } from '@/web/common/hooks/useSpeech';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { Box, Flex, Image, Spinner, Textarea } from '@chakra-ui/react';
import React, { useRef, useEffect, useCallback, useState, useTransition } from 'react';
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import MyTooltip from '../MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { compressImgFileAndUpload } from '@/web/common/file/controller';
import { customAlphabet } from 'nanoid';
import { IMG_BLOCK_KEY } from '@fastgpt/global/core/chat/constants';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { addDays } from 'date-fns';
import { useRequest } from '@/web/common/hooks/useRequest';
import { MongoImageTypeEnum } from '@fastgpt/global/common/file/image/constants';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from './type';
import { textareaMinH } from './constants';
import { UseFormReturn, useFieldArray } from 'react-hook-form';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 6);
enum FileTypeEnum {
image = 'image',
file = 'file'
}
type FileItemType = {
id: string;
rawFile: File;
type: `${FileTypeEnum}`;
name: string;
icon: string; // img is base64
src?: string;
};
const MessageInput = ({
onChange,
onSendMessage,
onStop,
isChatting,
@@ -40,17 +28,29 @@ const MessageInput = ({
shareId,
outLinkUid,
teamId,
teamToken
teamToken,
chatForm
}: OutLinkChatAuthProps & {
onChange?: (e: string) => void;
onSendMessage: (e: string) => void;
onSendMessage: (val: ChatBoxInputType) => void;
onStop: () => void;
isChatting: boolean;
showFileSelector?: boolean;
TextareaDom: React.MutableRefObject<HTMLTextAreaElement | null>;
resetInputVal: (val: string) => void;
resetInputVal: (val: ChatBoxInputType) => void;
chatForm: UseFormReturn<ChatBoxInputFormType>;
}) => {
const [, startSts] = useTransition();
const { setValue, watch, control } = chatForm;
const inputValue = watch('input');
const {
update: updateFile,
remove: removeFile,
fields: fileList,
append: appendFile,
replace: replaceFile
} = useFieldArray({
control,
name: 'files'
});
const {
isSpeaking,
@@ -64,45 +64,38 @@ const MessageInput = ({
const { isPc } = useSystemStore();
const canvasRef = useRef<HTMLCanvasElement>(null);
const { t } = useTranslation();
const textareaMinH = '22px';
const [fileList, setFileList] = useState<FileItemType[]>([]);
const havInput = !!TextareaDom.current?.value || fileList.length > 0;
const havInput = !!inputValue || fileList.length > 0;
/* file selector and upload */
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: 'image/*',
multiple: true,
maxCount: 10
});
const { mutate: uploadFile } = useRequest({
mutationFn: async (file: FileItemType) => {
if (file.type === FileTypeEnum.image) {
mutationFn: async ({ file, fileIndex }: { file: UserInputFileItemType; fileIndex: number }) => {
if (file.type === ChatFileTypeEnum.image && file.rawFile) {
try {
const src = await compressImgFileAndUpload({
const url = await compressImgFileAndUpload({
type: MongoImageTypeEnum.chatImage,
file: file.rawFile,
maxW: 4329,
maxH: 4329,
maxSize: 1024 * 1024 * 5,
// 30 day expired.
// 7 day expired.
expiredTime: addDays(new Date(), 7),
shareId,
outLinkUid,
teamId,
teamToken
});
setFileList((state) =>
state.map((item) =>
item.id === file.id
? {
...item,
src: `${location.origin}${src}`
}
: item
)
);
updateFile(fileIndex, {
...file,
url: `${location.origin}${url}`
});
} catch (error) {
setFileList((state) => state.filter((item) => item.id !== file.id));
removeFile(fileIndex);
console.log(error);
return Promise.reject(error);
}
@@ -110,7 +103,6 @@ const MessageInput = ({
},
errorToast: t('common.Upload File Failed')
});
const onSelectFile = useCallback(
async (files: File[]) => {
if (!files || files.length === 0) {
@@ -119,7 +111,7 @@ const MessageInput = ({
const loadFiles = await Promise.all(
files.map(
(file) =>
new Promise<FileItemType>((resolve, reject) => {
new Promise<UserInputFileItemType>((resolve, reject) => {
if (file.type.includes('image')) {
const reader = new FileReader();
reader.readAsDataURL(file);
@@ -127,11 +119,10 @@ const MessageInput = ({
const item = {
id: nanoid(),
rawFile: file,
type: FileTypeEnum.image,
type: ChatFileTypeEnum.image,
name: file.name,
icon: reader.result as string
};
uploadFile(item);
resolve(item);
};
reader.onerror = () => {
@@ -141,7 +132,7 @@ const MessageInput = ({
resolve({
id: nanoid(),
rawFile: file,
type: FileTypeEnum.file,
type: ChatFileTypeEnum.file,
name: file.name,
icon: 'file/pdf'
});
@@ -149,29 +140,28 @@ const MessageInput = ({
})
)
);
appendFile(loadFiles);
setFileList((state) => [...state, ...loadFiles]);
loadFiles.forEach((file, i) =>
uploadFile({
file,
fileIndex: i + fileList.length
})
);
},
[uploadFile]
[appendFile, fileList.length, uploadFile]
);
/* on send */
const handleSend = useCallback(async () => {
const textareaValue = TextareaDom.current?.value || '';
const images = fileList.filter((item) => item.type === FileTypeEnum.image);
const imagesText =
images.length === 0
? ''
: `\`\`\`${IMG_BLOCK_KEY}
${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
\`\`\`
`;
const inputMessage = `${imagesText}${textareaValue}`;
onSendMessage(inputMessage);
setFileList([]);
}, [TextareaDom, fileList, onSendMessage]);
onSendMessage({
text: textareaValue.trim(),
files: fileList
});
replaceFile([]);
}, [TextareaDom, fileList, onSendMessage, replaceFile]);
useEffect(() => {
if (!stream) {
@@ -231,7 +221,7 @@ ${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
{/* file preview */}
<Flex wrap={'wrap'} px={[2, 4]} userSelect={'none'}>
{fileList.map((item) => (
{fileList.map((item, index) => (
<Box
key={item.id}
border={'1px solid rgba(0,0,0,0.12)'}
@@ -240,11 +230,11 @@ ${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
rounded={'md'}
position={'relative'}
_hover={{
'.close-icon': { display: item.src ? 'block' : 'none' }
'.close-icon': { display: item.url ? 'block' : 'none' }
}}
>
{/* uploading */}
{!item.src && (
{!item.url && (
<Flex
position={'absolute'}
alignItems={'center'}
@@ -272,12 +262,12 @@ ${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
right={'-8px'}
top={'-8px'}
onClick={() => {
setFileList((state) => state.filter((file) => file.id !== item.id));
removeFile(index);
}}
className="close-icon"
display={['', 'none']}
/>
{item.type === FileTypeEnum.image && (
{item.type === ChatFileTypeEnum.image && (
<Image
alt={'img'}
src={item.icon}
@@ -335,14 +325,12 @@ ${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
boxShadow={'none !important'}
color={'myGray.900'}
isDisabled={isSpeaking}
value={inputValue}
onChange={(e) => {
const textarea = e.target;
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
startSts(() => {
onChange?.(textarea.value);
});
setValue('input', textarea.value);
}}
onKeyDown={(e) => {
// enter send.(pc or iframe && enter and unPress shift)
@@ -406,7 +394,7 @@ ${images.map((img) => JSON.stringify({ src: img.src })).join('\n')}
if (isSpeaking) {
return stopSpeak();
}
startSpeak(resetInputVal);
startSpeak((text) => resetInputVal({ text }));
}}
>
<MyTooltip label={isSpeaking ? t('core.chat.Stop Speak') : t('core.chat.Record')}>

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { type ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { DispatchNodeResponseType } from '@fastgpt/global/core/module/runtime/type.d';
import type { ChatItemType } from '@fastgpt/global/core/chat/type';
import { Flex, BoxProps, useDisclosure, useTheme, Box } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
@@ -14,15 +15,18 @@ import ChatBoxDivider from '@/components/core/chat/Divider';
import { strIsLink } from '@fastgpt/global/common/string/tools';
import MyIcon from '@fastgpt/web/components/common/Icon';
const QuoteModal = dynamic(() => import('./QuoteModal'), { ssr: false });
const ContextModal = dynamic(() => import('./ContextModal'), { ssr: false });
const WholeResponseModal = dynamic(() => import('./WholeResponseModal'), { ssr: false });
const QuoteModal = dynamic(() => import('./QuoteModal'));
const ContextModal = dynamic(() => import('./ContextModal'));
const WholeResponseModal = dynamic(() => import('./WholeResponseModal'));
const isLLMNode = (item: ChatHistoryItemResType) =>
item.moduleType === FlowNodeTypeEnum.chatNode || item.moduleType === FlowNodeTypeEnum.tools;
const ResponseTags = ({
responseData = [],
flowResponses = [],
showDetail
}: {
responseData?: ChatHistoryItemResType[];
flowResponses?: ChatHistoryItemResType[];
showDetail: boolean;
}) => {
const theme = useTheme();
@@ -36,7 +40,8 @@ const ResponseTags = ({
sourceName: string;
};
}>();
const [contextModalData, setContextModalData] = useState<ChatItemType[]>();
const [contextModalData, setContextModalData] =
useState<DispatchNodeResponseType['historyPreview']>();
const {
isOpen: isOpenWholeModal,
onOpen: onOpenWholeModal,
@@ -44,18 +49,29 @@ const ResponseTags = ({
} = useDisclosure();
const {
chatAccount,
llmModuleAccount,
quoteList = [],
sourceList = [],
historyPreview = [],
runningTime = 0
} = useMemo(() => {
const chatData = responseData.find((item) => item.moduleType === FlowNodeTypeEnum.chatNode);
const quoteList = responseData
.filter((item) => item.moduleType === FlowNodeTypeEnum.chatNode)
const flatResponse = flowResponses
.map((item) => {
if (item.pluginDetail || item.toolDetail) {
return [item, ...(item.pluginDetail || []), ...(item.toolDetail || [])];
}
return item;
})
.flat();
const chatData = flatResponse.find(isLLMNode);
const quoteList = flatResponse
.filter((item) => item.moduleType === FlowNodeTypeEnum.datasetSearchNode)
.map((item) => item.quoteList)
.flat()
.filter(Boolean) as SearchDataResponseItemType[];
const sourceList = quoteList.reduce(
(acc: Record<string, SearchDataResponseItemType[]>, cur) => {
if (!acc[cur.collectionId]) {
@@ -67,8 +83,7 @@ const ResponseTags = ({
);
return {
chatAccount: responseData.filter((item) => item.moduleType === FlowNodeTypeEnum.chatNode)
.length,
llmModuleAccount: flatResponse.filter(isLLMNode).length,
quoteList,
sourceList: Object.values(sourceList)
.flat()
@@ -80,16 +95,16 @@ const ResponseTags = ({
collectionId: item.collectionId
})),
historyPreview: chatData?.historyPreview,
runningTime: +responseData.reduce((sum, item) => sum + (item.runningTime || 0), 0).toFixed(2)
runningTime: +flowResponses.reduce((sum, item) => sum + (item.runningTime || 0), 0).toFixed(2)
};
}, [showDetail, responseData]);
}, [showDetail, flowResponses]);
const TagStyles: BoxProps = {
mr: 2,
bg: 'transparent'
};
return responseData.length === 0 ? null : (
return flowResponses.length === 0 ? null : (
<>
{sourceList.length > 0 && (
<>
@@ -148,10 +163,10 @@ const ResponseTags = ({
</Tag>
</MyTooltip>
)}
{chatAccount === 1 && (
{llmModuleAccount === 1 && (
<>
{historyPreview.length > 0 && (
<MyTooltip label={'点击查看完整对话记录'}>
<MyTooltip label={'点击查看上下文预览'}>
<Tag
colorSchema="green"
cursor={'pointer'}
@@ -164,7 +179,7 @@ const ResponseTags = ({
)}
</>
)}
{chatAccount > 1 && (
{llmModuleAccount > 1 && (
<Tag colorSchema="blue" {...TagStyles}>
AI
</Tag>
@@ -196,7 +211,7 @@ const ResponseTags = ({
)}
{isOpenWholeModal && (
<WholeResponseModal
response={responseData}
response={flowResponses}
showDetail={showDetail}
onClose={onCloseWholeModal}
/>

View File

@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { Box, useTheme, Flex, Image } from '@chakra-ui/react';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { useTranslation } from 'next-i18next';
import { moduleTemplatesFlat } from '@/web/core/modules/template/system';
import { moduleTemplatesFlat } from '@fastgpt/global/core/module/template/constants';
import Tabs from '../Tabs';
import MyModal from '../MyModal';
@@ -143,6 +143,11 @@ const ResponseBox = React.memo(function ResponseBox({
/>
<Row label={t('core.chat.response.module model')} value={activeModule?.model} />
<Row label={t('core.chat.response.module tokens')} value={`${activeModule?.tokens}`} />
<Row
label={t('core.chat.response.Tool call tokens')}
value={`${activeModule?.toolCallTokens}`}
/>
<Row label={t('core.chat.response.module query')} value={activeModule?.query} />
<Row
label={t('core.chat.response.context total length')}
@@ -182,12 +187,6 @@ const ResponseBox = React.memo(function ResponseBox({
)
}
/>
{activeModule.quoteList && activeModule.quoteList.length > 0 && (
<Row
label={t('core.chat.response.module quoteList')}
rawDom={<QuoteList showDetail={showDetail} rawSearch={activeModule.quoteList} />}
/>
)}
</>
{/* dataset search */}
@@ -213,6 +212,12 @@ const ResponseBox = React.memo(function ResponseBox({
label={t('support.wallet.usage.Extension result')}
value={`${activeModule?.extensionResult}`}
/>
{activeModule.quoteList && activeModule.quoteList.length > 0 && (
<Row
label={t('core.chat.response.module quoteList')}
rawDom={<QuoteList showDetail={showDetail} rawSearch={activeModule.quoteList} />}
/>
)}
</>
{/* classify question */}
@@ -276,7 +281,7 @@ const ResponseBox = React.memo(function ResponseBox({
)}
{activeModule?.pluginDetail && activeModule?.pluginDetail.length > 0 && (
<Row
label={t('core.chat.response.Plugin Resonse Detail')}
label={t('core.chat.response.Plugin response detail')}
rawDom={<ResponseBox response={activeModule.pluginDetail} showDetail={showDetail} />}
/>
)}
@@ -284,6 +289,14 @@ const ResponseBox = React.memo(function ResponseBox({
{/* text output */}
<Row label={t('core.chat.response.text output')} value={activeModule?.textOutput} />
{/* tool call */}
{activeModule?.toolDetail && activeModule?.toolDetail.length > 0 && (
<Row
label={t('core.chat.response.Tool call response detail')}
rawDom={<ResponseBox response={activeModule.toolDetail} showDetail={showDetail} />}
/>
)}
</Box>
</>
);

View File

@@ -0,0 +1,23 @@
import Avatar from '@/components/Avatar';
import { Box } from '@chakra-ui/react';
import { useTheme } from '@chakra-ui/system';
import React from 'react';
const ChatAvatar = ({ src, type }: { src?: string; type: 'Human' | 'AI' }) => {
const theme = useTheme();
return (
<Box
w={['28px', '34px']}
h={['28px', '34px']}
p={'2px'}
borderRadius={'sm'}
border={theme.borders.base}
boxShadow={'0 0 5px rgba(0,0,0,0.1)'}
bg={type === 'Human' ? 'white' : 'primary.50'}
>
<Avatar src={src} w={'100%'} h={'100%'} />
</Box>
);
};
export default React.memo(ChatAvatar);

View File

@@ -0,0 +1,236 @@
import { useCopyData } from '@/web/common/hooks/useCopyData';
import { useAudioPlay } from '@/web/common/utils/voice';
import { Flex, FlexProps, Image, css, useTheme } from '@chakra-ui/react';
import { ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import { AppTTSConfigType } from '@fastgpt/global/core/module/type';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { formatChatValue2InputType } from '../utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
export type ChatControllerProps = {
isChatting: boolean;
chat: ChatSiteItemType;
setChatHistories?: React.Dispatch<React.SetStateAction<ChatSiteItemType[]>>;
showVoiceIcon?: boolean;
ttsConfig?: AppTTSConfigType;
onRetry?: () => void;
onDelete?: () => void;
onMark?: () => void;
onReadUserDislike?: () => void;
onCloseUserLike?: () => void;
onAddUserLike?: () => void;
onAddUserDislike?: () => void;
};
const ChatController = ({
isChatting,
chat,
setChatHistories,
showVoiceIcon,
ttsConfig,
onReadUserDislike,
onCloseUserLike,
onMark,
onRetry,
onDelete,
onAddUserDislike,
onAddUserLike,
shareId,
outLinkUid,
teamId,
teamToken
}: OutLinkChatAuthProps & ChatControllerProps & FlexProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { copyData } = useCopyData();
const { audioLoading, audioPlaying, hasAudio, playAudio, cancelAudio } = useAudioPlay({
ttsConfig,
shareId,
outLinkUid,
teamId,
teamToken
});
const controlIconStyle = {
w: '14px',
cursor: 'pointer',
p: '5px',
bg: 'white',
borderRight: theme.borders.base
};
const controlContainerStyle = {
className: 'control',
color: 'myGray.400',
display: 'flex'
};
return (
<Flex
{...controlContainerStyle}
borderRadius={'sm'}
overflow={'hidden'}
border={theme.borders.base}
// 最后一个子元素没有border
css={css({
'& > *:last-child, & > *:last-child svg': {
borderRight: 'none',
borderRadius: 'md'
}
})}
>
<MyTooltip label={t('common.Copy')}>
<MyIcon
{...controlIconStyle}
name={'copy'}
_hover={{ color: 'primary.600' }}
onClick={() => copyData(formatChatValue2InputType(chat.value).text || '')}
/>
</MyTooltip>
{!!onDelete && !isChatting && (
<>
{onRetry && (
<MyTooltip label={t('core.chat.retry')}>
<MyIcon
{...controlIconStyle}
name={'common/retryLight'}
_hover={{ color: 'green.500' }}
onClick={onRetry}
/>
</MyTooltip>
)}
<MyTooltip label={t('common.Delete')}>
<MyIcon
{...controlIconStyle}
name={'delete'}
_hover={{ color: 'red.600' }}
onClick={onDelete}
/>
</MyTooltip>
</>
)}
{showVoiceIcon &&
hasAudio &&
(audioLoading ? (
<MyTooltip label={t('common.Loading')}>
<MyIcon {...controlIconStyle} name={'common/loading'} />
</MyTooltip>
) : audioPlaying ? (
<Flex alignItems={'center'}>
<MyTooltip label={t('core.chat.tts.Stop Speech')}>
<MyIcon
{...controlIconStyle}
borderRight={'none'}
name={'core/chat/stopSpeech'}
color={'#E74694'}
onClick={() => cancelAudio()}
/>
</MyTooltip>
<Image src="/icon/speaking.gif" w={'23px'} alt={''} borderRight={theme.borders.base} />
</Flex>
) : (
<MyTooltip label={t('core.app.TTS')}>
<MyIcon
{...controlIconStyle}
name={'common/voiceLight'}
_hover={{ color: '#E74694' }}
onClick={async () => {
const response = await playAudio({
buffer: chat.ttsBuffer,
chatItemId: chat.dataId,
text: formatChatValue2InputType(chat.value).text || ''
});
if (!setChatHistories || !response.buffer) return;
setChatHistories((state) =>
state.map((item) =>
item.dataId === chat.dataId
? {
...item,
ttsBuffer: response.buffer
}
: item
)
);
}}
/>
</MyTooltip>
))}
{!!onMark && (
<MyTooltip label={t('core.chat.Mark')}>
<MyIcon
{...controlIconStyle}
name={'core/app/markLight'}
_hover={{ color: '#67c13b' }}
onClick={onMark}
/>
</MyTooltip>
)}
{chat.obj === ChatRoleEnum.AI && (
<>
{!!onCloseUserLike && chat.userGoodFeedback && (
<MyTooltip label={t('core.chat.feedback.Close User Like')}>
<MyIcon
{...controlIconStyle}
color={'white'}
bg={'green.500'}
fontWeight={'bold'}
name={'core/chat/feedback/goodLight'}
onClick={onCloseUserLike}
/>
</MyTooltip>
)}
{!!onReadUserDislike && chat.userBadFeedback && (
<MyTooltip label={t('core.chat.feedback.Read User dislike')}>
<MyIcon
{...controlIconStyle}
color={'white'}
bg={'#FC9663'}
fontWeight={'bold'}
name={'core/chat/feedback/badLight'}
onClick={onReadUserDislike}
/>
</MyTooltip>
)}
{!!onAddUserLike && (
<MyIcon
{...controlIconStyle}
{...(!!chat.userGoodFeedback
? {
color: 'white',
bg: 'green.500',
fontWeight: 'bold'
}
: {
_hover: { color: 'green.600' }
})}
name={'core/chat/feedback/goodLight'}
onClick={onAddUserLike}
/>
)}
{!!onAddUserDislike && (
<MyIcon
{...controlIconStyle}
{...(!!chat.userBadFeedback
? {
color: 'white',
bg: '#FC9663',
fontWeight: 'bold',
onClick: onAddUserDislike
}
: {
_hover: { color: '#FB7C3C' },
onClick: onAddUserDislike
})}
name={'core/chat/feedback/badLight'}
/>
)}
</>
)}
</Flex>
);
};
export default React.memo(ChatController);

View File

@@ -0,0 +1,236 @@
import {
Box,
BoxProps,
Card,
Flex,
useTheme,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Button,
Image,
Grid
} from '@chakra-ui/react';
import React, { useMemo } from 'react';
import ChatController, { type ChatControllerProps } from './ChatController';
import ChatAvatar from './ChatAvatar';
import { MessageCardStyle } from '../constants';
import { formatChatValue2InputType } from '../utils';
import Markdown, { CodeClassName } from '@/components/Markdown';
import styles from '../index.module.scss';
import MyIcon from '@fastgpt/web/components/common/Icon';
import {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatStatusEnum
} from '@fastgpt/global/core/chat/constants';
import FilesBlock from './FilesBox';
const colorMap = {
[ChatStatusEnum.loading]: {
bg: 'myGray.100',
color: 'myGray.600'
},
[ChatStatusEnum.running]: {
bg: 'green.50',
color: 'green.700'
},
[ChatStatusEnum.finish]: {
bg: 'green.50',
color: 'green.700'
}
};
const ChatItem = ({
type,
avatar,
statusBoxData,
children,
isLastChild,
questionGuides = [],
...chatControllerProps
}: {
type: ChatRoleEnum.Human | ChatRoleEnum.AI;
avatar?: string;
statusBoxData?: {
status: `${ChatStatusEnum}`;
name: string;
};
isLastChild?: boolean;
questionGuides?: string[];
children?: React.ReactNode;
} & ChatControllerProps) => {
const theme = useTheme();
const styleMap: BoxProps =
type === ChatRoleEnum.Human
? {
order: 0,
borderRadius: '8px 0 8px 8px',
justifyContent: 'flex-end',
textAlign: 'right',
bg: 'primary.100'
}
: {
order: 1,
borderRadius: '0 8px 8px 8px',
justifyContent: 'flex-start',
textAlign: 'left',
bg: 'myGray.50'
};
const { chat, isChatting } = chatControllerProps;
const ContentCard = useMemo(() => {
if (type === 'Human') {
const { text, files = [] } = formatChatValue2InputType(chat.value);
return (
<>
{files.length > 0 && <FilesBlock files={files} />}
<Markdown source={text} isChatting={false} />
</>
);
}
/* AI */
return (
<Flex flexDirection={'column'} gap={2}>
{chat.value.map((value, i) => {
const key = `${chat.dataId}-ai-${i}`;
if (value.text) {
let source = value.text?.content || '';
if (isLastChild && !isChatting && questionGuides.length > 0) {
source = `${source}
\`\`\`${CodeClassName.questionGuide}
${JSON.stringify(questionGuides)}`;
}
return <Markdown key={key} source={source} isChatting={isLastChild && isChatting} />;
}
if (value.type === ChatItemValueTypeEnum.tool && value.tools) {
return (
<Box key={key}>
{value.tools.map((tool) => {
const toolParams = (() => {
try {
return JSON.stringify(JSON.parse(tool.params), null, 2);
} catch (error) {
return tool.params;
}
})();
const toolResponse = (() => {
try {
return JSON.stringify(JSON.parse(tool.response), null, 2);
} catch (error) {
return tool.response;
}
})();
return (
<Box key={tool.id}>
<Accordion allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton
w={'auto'}
bg={'white'}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'1'}
_hover={{
bg: 'auto',
color: 'primary.600'
}}
>
<Image src={tool.toolAvatar} alt={''} w={'14px'} mr={2} />
<Box mr={1}>{tool.toolName}</Box>
{isChatting && !tool.response && (
<MyIcon name={'common/loading'} w={'14px'} />
)}
<AccordionIcon color={'myGray.600'} ml={5} />
</AccordionButton>
<AccordionPanel
py={0}
px={0}
mt={0}
borderRadius={'md'}
overflow={'hidden'}
maxH={'500px'}
overflowY={'auto'}
>
{toolParams && (
<Markdown
source={`~~~json#Input
${toolParams}`}
/>
)}
{toolResponse && (
<Markdown
source={`~~~json#Response
${toolResponse}`}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
</Box>
);
})}
</Box>
);
}
})}
</Flex>
);
}, [chat.dataId, chat.value, isChatting, isLastChild, questionGuides, type]);
const chatStatusMap = useMemo(() => {
if (!statusBoxData?.status) return;
return colorMap[statusBoxData.status];
}, [statusBoxData?.status]);
return (
<>
{/* control icon */}
<Flex w={'100%'} alignItems={'center'} gap={2} justifyContent={styleMap.justifyContent}>
{isChatting && type === ChatRoleEnum.AI && isLastChild ? null : (
<Box order={styleMap.order} ml={styleMap.ml}>
<ChatController {...chatControllerProps} />
</Box>
)}
<ChatAvatar src={avatar} type={type} />
{!!chatStatusMap && statusBoxData && isLastChild && (
<Flex alignItems={'center'} px={3} py={'1.5px'} borderRadius="md" bg={chatStatusMap.bg}>
<Box
className={styles.statusAnimation}
bg={chatStatusMap.color}
w="8px"
h="8px"
borderRadius={'50%'}
mt={'1px'}
/>
<Box ml={2} color={'myGray.600'}>
{statusBoxData.name}
</Box>
</Flex>
)}
</Flex>
{/* content */}
<Box mt={['6px', 2]} textAlign={styleMap.textAlign}>
<Card
className="markdown"
{...MessageCardStyle}
bg={styleMap.bg}
borderRadius={styleMap.borderRadius}
textAlign={'left'}
>
{ContentCard}
{children}
</Card>
</Box>
</>
);
};
export default ChatItem;

View File

@@ -0,0 +1,23 @@
import Markdown from '@/components/Markdown';
import { useMarkdown } from '@/web/common/hooks/useMarkdown';
import { Box, Card } from '@chakra-ui/react';
import React from 'react';
const Empty = () => {
const { data: chatProblem } = useMarkdown({ url: '/chatProblem.md' });
const { data: versionIntro } = useMarkdown({ url: '/versionIntro.md' });
return (
<Box pt={6} w={'85%'} maxW={'600px'} m={'auto'} alignItems={'center'} justifyContent={'center'}>
{/* version intro */}
<Card p={4} mb={10} minH={'200px'}>
<Markdown source={versionIntro} />
</Card>
<Card p={4} minH={'600px'}>
<Markdown source={chatProblem} />
</Card>
</Box>
);
};
export default React.memo(Empty);

View File

@@ -0,0 +1,22 @@
import { Box, Flex, Grid } from '@chakra-ui/react';
import MdImage from '@/components/Markdown/img/Image';
import { UserInputFileItemType } from '@/components/ChatBox/type';
const FilesBlock = ({ files }: { files: UserInputFileItemType[] }) => {
return (
<Grid gridTemplateColumns={['1fr', '1fr 1fr']} gap={4}>
{files.map(({ id, type, name, url }, i) => {
if (type === 'image') {
return (
<Box key={i} rounded={'md'} flex={'1 0 0'} minW={'120px'}>
<MdImage src={url} />
</Box>
);
}
return null;
})}
</Grid>
);
};
export default FilesBlock;

View File

@@ -0,0 +1,119 @@
import { VariableItemType } from '@fastgpt/global/core/module/type';
import React, { useState } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { Box, Button, Card, Input, Textarea } from '@chakra-ui/react';
import ChatAvatar from './ChatAvatar';
import { MessageCardStyle } from '../constants';
import { VariableInputEnum } from '@fastgpt/global/core/module/constants';
import MySelect from '@fastgpt/web/components/common/MySelect';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { ChatBoxInputFormType } from '../type.d';
const VariableInput = ({
appAvatar,
variableModules,
variableIsFinish,
chatForm,
onSubmitVariables
}: {
appAvatar?: string;
variableModules: VariableItemType[];
variableIsFinish: boolean;
onSubmitVariables: (e: Record<string, any>) => void;
chatForm: UseFormReturn<ChatBoxInputFormType>;
}) => {
const { t } = useTranslation();
const [refresh, setRefresh] = useState(false);
const { register, setValue, handleSubmit: handleSubmitChat, watch } = chatForm;
const variables = watch('variables');
return (
<Box py={3}>
{/* avatar */}
<ChatAvatar src={appAvatar} type={'AI'} />
{/* message */}
<Box textAlign={'left'}>
<Card
order={2}
mt={2}
w={'400px'}
{...MessageCardStyle}
bg={'white'}
boxShadow={'0 0 8px rgba(0,0,0,0.15)'}
>
{variableModules.map((item) => (
<Box key={item.id} mb={4}>
<Box as={'label'} display={'inline-block'} position={'relative'} mb={1}>
{item.label}
{item.required && (
<Box
position={'absolute'}
top={'-2px'}
right={'-10px'}
color={'red.500'}
fontWeight={'bold'}
>
*
</Box>
)}
</Box>
{item.type === VariableInputEnum.input && (
<Input
isDisabled={variableIsFinish}
bg={'myWhite.400'}
{...register(`variables.${item.key}`, {
required: item.required
})}
/>
)}
{item.type === VariableInputEnum.textarea && (
<Textarea
isDisabled={variableIsFinish}
bg={'myWhite.400'}
{...register(`variables.${item.key}`, {
required: item.required
})}
rows={5}
maxLength={4000}
/>
)}
{item.type === VariableInputEnum.select && (
<MySelect
width={'100%'}
isDisabled={variableIsFinish}
list={(item.enums || []).map((item) => ({
label: item.value,
value: item.value
}))}
{...register(`variables.${item.key}`, {
required: item.required
})}
value={variables[item.key]}
onchange={(e) => {
setValue(`variables.${item.key}`, e);
setRefresh((state) => !state);
}}
/>
)}
</Box>
))}
{!variableIsFinish && (
<Button
leftIcon={<MyIcon name={'core/chat/chatFill'} w={'16px'} />}
size={'sm'}
maxW={'100px'}
onClick={handleSubmitChat((data) => {
onSubmitVariables(data);
})}
>
{t('core.chat.Start Chat')}
</Button>
)}
</Card>
</Box>
</Box>
);
};
export default React.memo(VariableInput);

View File

@@ -0,0 +1,28 @@
import { Box, Card } from '@chakra-ui/react';
import React from 'react';
import { MessageCardStyle } from '../constants';
import Markdown from '@/components/Markdown';
import ChatAvatar from './ChatAvatar';
const WelcomeBox = ({ appAvatar, welcomeText }: { appAvatar?: string; welcomeText: string }) => {
return (
<Box py={3}>
{/* avatar */}
<ChatAvatar src={appAvatar} type={'AI'} />
{/* message */}
<Box textAlign={'left'}>
<Card
order={2}
mt={2}
{...MessageCardStyle}
bg={'white'}
boxShadow={'0 0 8px rgba(0,0,0,0.15)'}
>
<Markdown source={`~~~guide \n${welcomeText}`} isChatting={false} />
</Card>
</Box>
</Box>
);
};
export default WelcomeBox;

View File

@@ -0,0 +1,13 @@
import { BoxProps } from '@chakra-ui/react';
export const textareaMinH = '22px';
export const MessageCardStyle: BoxProps = {
px: 4,
py: 3,
borderRadius: '0 8px 8px 8px',
boxShadow: 'none',
display: 'inline-block',
maxW: ['calc(100% - 25px)', 'calc(100% - 40px)'],
color: 'myGray.900'
};

View File

@@ -0,0 +1,78 @@
import { ExportChatType } from '@/types/chat';
import { ChatItemType } from '@fastgpt/global/core/chat/type';
import { useCallback } from 'react';
import { htmlTemplate } from '@/constants/common';
import { fileDownload } from '@/web/common/file/utils';
export const useChatBox = () => {
const onExportChat = useCallback(
({ type, history }: { type: ExportChatType; history: ChatItemType[] }) => {
const getHistoryHtml = () => {
const historyDom = document.getElementById('history');
if (!historyDom) return;
const dom = Array.from(historyDom.children).map((child, i) => {
const avatar = `<img src="${
child.querySelector<HTMLImageElement>('.avatar')?.src
}" alt="" />`;
const chatContent = child.querySelector<HTMLDivElement>('.markdown');
if (!chatContent) {
return '';
}
const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement;
const codeHeader = chatContentClone.querySelectorAll('.code-header');
codeHeader.forEach((childElement: any) => {
childElement.remove();
});
return `<div class="chat-item">
${avatar}
${chatContentClone.outerHTML}
</div>`;
});
const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n'));
return html;
};
const map: Record<ExportChatType, () => void> = {
md: () => {
fileDownload({
text: history.map((item) => item.value).join('\n\n'),
type: 'text/markdown',
filename: 'chat.md'
});
},
html: () => {
const html = getHistoryHtml();
html &&
fileDownload({
text: html,
type: 'text/html',
filename: '聊天记录.html'
});
},
pdf: () => {
const html = getHistoryHtml();
html &&
// @ts-ignore
html2pdf(html, {
margin: 0,
filename: `聊天记录.pdf`
});
}
};
map[type]();
},
[]
);
return {
onExportChat
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatItemValueItemType,
ChatSiteItemType,
ToolModuleResponseItemType
} from '@fastgpt/global/core/chat/type';
import { SseResponseEventEnum } from '@fastgpt/global/core/module/runtime/constants';
export type generatingMessageProps = {
event: `${SseResponseEventEnum}`;
text?: string;
name?: string;
status?: 'running' | 'finish';
tool?: ToolModuleResponseItemType;
};
export type UserInputFileItemType = {
id: string;
rawFile?: File;
type: `${ChatFileTypeEnum}`;
name: string;
icon: string; // img is base64
url?: string;
};
export type ChatBoxInputFormType = {
input: string;
files: UserInputFileItemType[];
variables: Record<string, any>;
chatStarted: boolean;
};
export type ChatBoxInputType = {
text?: string;
files?: UserInputFileItemType[];
};
export type StartChatFnProps = {
chatList: ChatSiteItemType[];
messages: ChatCompletionMessageParam[];
controller: AbortController;
variables: Record<string, any>;
generatingMessage: (e: generatingMessageProps) => void;
};
export type ComponentRef = {
getChatHistories: () => ChatSiteItemType[];
resetVariables: (data?: Record<string, any>) => void;
resetHistory: (history: ChatSiteItemType[]) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto') => void;
sendPrompt: (question: string) => void;
};

View File

@@ -0,0 +1,33 @@
import { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { ChatBoxInputType, UserInputFileItemType } from './type';
import { getNanoid } from '@fastgpt/global/common/string/tools';
export const formatChatValue2InputType = (value: ChatItemValueItemType[]): ChatBoxInputType => {
if (!Array.isArray(value)) {
console.error('value is error', value);
return { text: '', files: [] };
}
const text = value
.filter((item) => item.text?.content)
.map((item) => item.text?.content || '')
.join('');
const files =
(value
.map((item) =>
item.type === 'file' && item.file
? {
id: getNanoid(),
type: item.file.type,
name: item.file.name,
icon: '',
url: item.file.url
}
: undefined
)
.filter(Boolean) as UserInputFileItemType[]) || [];
return {
text,
files
};
};

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import Icon from '@fastgpt/web/components/common/Icon';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import { useTranslation } from 'next-i18next';
const codeLight: { [key: string]: React.CSSProperties } = {
'code[class*=language-]': {
@@ -294,24 +295,41 @@ const CodeLight = ({
inline?: boolean;
match: RegExpExecArray | null;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
if (!inline) {
const codeBoxName = useMemo(() => {
const input = match?.['input'] || '';
if (!input) return match?.[1];
const splitInput = input.split('#');
return splitInput[1] || match?.[1];
}, [match]);
return (
<Box my={3} borderRadius={'md'} overflow={'overlay'} backgroundColor={'#222'}>
<Box
my={3}
borderRadius={'md'}
overflow={'overlay'}
bg={'myGray.900'}
boxShadow={
'0px 0px 1px 0px rgba(19, 51, 107, 0.08), 0px 1px 2px 0px rgba(19, 51, 107, 0.05)'
}
>
<Flex
className="code-header"
py={2}
px={5}
backgroundColor={useColorModeValue('#323641', 'gray.600')}
color={'#fff'}
bg={'myGray.600'}
color={'white'}
fontSize={'sm'}
userSelect={'none'}
>
<Box flex={1}>{match?.[1]}</Box>
<Box flex={1}>{codeBoxName}</Box>
<Flex cursor={'pointer'} onClick={() => copyData(String(children))} alignItems={'center'}>
<Icon name={'copy'} width={15} height={15} fill={'#fff'}></Icon>
<Box ml={1}></Box>
<Icon name={'copy'} width={15} height={15}></Icon>
<Box ml={1}>{t('common.Copy')}</Box>
</Flex>
</Flex>
<SyntaxHighlighter style={codeLight as any} language={match?.[1]} PreTag="pre">

View File

@@ -1,34 +0,0 @@
import { Box, Flex, Grid } from '@chakra-ui/react';
import MdImage from '../img/Image';
import { useMemo } from 'react';
const ImageBlock = ({ images }: { images: string }) => {
const formatData = useMemo(
() =>
images
.split('\n')
.filter((item) => item)
.map((item) => {
try {
return JSON.parse(item) as { src: string };
} catch (error) {
return { src: '' };
}
}),
[images]
);
return (
<Grid gridTemplateColumns={['1fr', '1fr 1fr']} gap={4}>
{formatData.map(({ src }) => {
return (
<Box key={src} rounded={'md'} flex={'1 0 0'} minW={'120px'}>
<MdImage src={src} />
</Box>
);
})}
</Grid>
);
};
export default ImageBlock;

View File

@@ -24,7 +24,6 @@ const EChartsCodeBlock = dynamic(() => import('./img/EChartsCodeBlock'));
const ChatGuide = dynamic(() => import('./chat/Guide'));
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'));
const ImageBlock = dynamic(() => import('./chat/Image'));
export enum CodeClassName {
guide = 'guide',
@@ -32,10 +31,16 @@ export enum CodeClassName {
mermaid = 'mermaid',
echarts = 'echarts',
quote = 'quote',
img = 'img'
files = 'files'
}
const Markdown = ({ source, isChatting = false }: { source: string; isChatting?: boolean }) => {
const Markdown = ({
source = '',
isChatting = false
}: {
source?: string;
isChatting?: boolean;
}) => {
const components = useMemo<any>(
() => ({
img: Image,
@@ -91,9 +96,7 @@ const Code = React.memo(function Code(e: any) {
if (codeType === CodeClassName.echarts) {
return <EChartsCodeBlock code={strChildren} />;
}
if (codeType === CodeClassName.img) {
return <ImageBlock images={strChildren} />;
}
return (
<CodeLight className={className} inline={inline} match={match}>
{children}

View File

@@ -103,7 +103,7 @@ const QuoteItem = ({
fontSize={'sm'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
_hover={{ '& .hover-data': { display: 'flex' } }}
_hover={{ '& .hover-data': { visibility: 'visible' } }}
h={'100%'}
display={'flex'}
flexDirection={'column'}
@@ -218,7 +218,8 @@ const QuoteItem = ({
<MyTooltip label={t('core.dataset.data.Edit')}>
<Box
className="hover-data"
display={['flex', 'none']}
visibility={'hidden'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
>
@@ -245,7 +246,7 @@ const QuoteItem = ({
<Link
as={NextLink}
className="hover-data"
display={'none'}
visibility={'hidden'}
alignItems={'center'}
color={'primary.500'}
href={`/dataset/detail?datasetId=${quoteItem.datasetId}&currentTab=dataCard&collectionId=${quoteItem.collectionId}`}

View File

@@ -86,10 +86,12 @@ const DatasetParamsModal = ({
const cfbBgDesc = watch('datasetSearchExtensionBg');
const chatModelSelectList = (() =>
llmModelList.map((item) => ({
value: item.model,
label: item.name
})))();
llmModelList
.filter((model) => model.usedInQueryExtension)
.map((item) => ({
value: item.model,
label: item.name
})))();
const searchModeList = useMemo(() => {
const list = Object.values(DatasetSearchModeMap);

View File

@@ -13,7 +13,8 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { streamFetch } from '@/web/common/api/fetch';
import MyTooltip from '@/components/MyTooltip';
import { useUserStore } from '@/web/support/user/useUserStore';
import ChatBox, { type ComponentRef, type StartChatFnProps } from '@/components/ChatBox';
import ChatBox from '@/components/ChatBox';
import type { ComponentRef, StartChatFnProps } from '@/components/ChatBox/type.d';
import { getGuideModule } from '@fastgpt/global/core/module/utils';
import { checkChatSupportSelectFileByModules } from '@/web/core/chat/utils';
import { ModuleInputKeyEnum } from '@fastgpt/global/core/module/constants';

View File

@@ -12,7 +12,10 @@ import type {
FlowModuleItemType,
FlowModuleTemplateType
} from '@fastgpt/global/core/module/type.d';
import type { FlowNodeChangeProps } from '@fastgpt/global/core/module/node/type';
import type {
FlowNodeChangeProps,
FlowNodeInputItemType
} from '@fastgpt/global/core/module/node/type';
import React, {
type SetStateAction,
type Dispatch,
@@ -20,13 +23,18 @@ import React, {
useCallback,
createContext,
useRef,
useEffect
useEffect,
useMemo
} from 'react';
import { customAlphabet } from 'nanoid';
import { appModule2FlowEdge, appModule2FlowNode } from '@/utils/adapt';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/module/node/constant';
import { ModuleIOValueTypeEnum } from '@fastgpt/global/core/module/constants';
import {
ModuleIOValueTypeEnum,
ModuleInputKeyEnum,
ModuleOutputKeyEnum
} from '@fastgpt/global/core/module/constants';
import { useTranslation } from 'next-i18next';
import { ModuleItemType } from '@fastgpt/global/core/module/type.d';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
@@ -34,6 +42,14 @@ import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 6);
type OnChange<ChangesType> = (changes: ChangesType[]) => void;
type requestEventType =
| 'onChangeNode'
| 'onCopyNode'
| 'onResetNode'
| 'onDelNode'
| 'onDelConnect'
| 'setNodes';
export type useFlowProviderStoreType = {
reactFlowWrapper: null | React.RefObject<HTMLDivElement>;
mode: 'app' | 'plugin';
@@ -57,14 +73,16 @@ export type useFlowProviderStoreType = {
onDelConnect: (id: string) => void;
onConnect: ({ connect }: { connect: Connection }) => any;
initData: (modules: ModuleItemType[]) => void;
splitToolInputs: (
inputs: FlowNodeInputItemType[],
moduleId: string
) => {
isTool: boolean;
toolInputs: FlowNodeInputItemType[];
commonInputs: FlowNodeInputItemType[];
};
hasToolNode: boolean;
};
type requestEventType =
| 'onChangeNode'
| 'onCopyNode'
| 'onResetNode'
| 'onDelNode'
| 'onDelConnect'
| 'setNodes';
const StateContext = createContext<useFlowProviderStoreType>({
reactFlowWrapper: null,
@@ -116,7 +134,18 @@ const StateContext = createContext<useFlowProviderStoreType>({
},
onResetNode: function (e): void {
throw new Error('Function not implemented.');
}
},
splitToolInputs: function (
inputs: FlowNodeInputItemType[],
moduleId: string
): {
isTool: boolean;
toolInputs: FlowNodeInputItemType[];
commonInputs: FlowNodeInputItemType[];
} {
throw new Error('Function not implemented.');
},
hasToolNode: false
});
export const useFlowProviderStore = () => useContext(StateContext);
@@ -135,6 +164,10 @@ export const FlowProvider = ({
const [nodes = [], setNodes, onNodesChange] = useNodesState<FlowModuleItemType>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const hasToolNode = useMemo(() => {
return !!nodes.find((node) => node.data.flowType === FlowNodeTypeEnum.tools);
}, [nodes]);
const onFixView = useCallback(() => {
const btn = document.querySelector('.react-flow__controls-fitview') as HTMLButtonElement;
@@ -180,10 +213,13 @@ export const FlowProvider = ({
const type = source?.outputs.find(
(output) => output.key === connect.sourceHandle
)?.valueType;
console.log(type);
if (source?.flowType === FlowNodeTypeEnum.classifyQuestion && !type) {
return ModuleIOValueTypeEnum.boolean;
}
if (source?.flowType === FlowNodeTypeEnum.tools) {
return ModuleIOValueTypeEnum.tools;
}
if (source?.flowType === FlowNodeTypeEnum.pluginInput) {
return source?.inputs.find((input) => input.key === connect.sourceHandle)?.valueType;
}
@@ -193,14 +229,17 @@ export const FlowProvider = ({
const targetType = nodes
.find((node) => node.id === connect.target)
?.data?.inputs.find((input) => input.key === connect.targetHandle)?.valueType;
console.log(source, targetType);
if (!sourceType || !targetType) {
if (
connect.sourceHandle === ModuleOutputKeyEnum.selectedTools &&
connect.targetHandle === ModuleOutputKeyEnum.selectedTools
) {
} else if (!sourceType || !targetType) {
return toast({
status: 'warning',
title: t('app.Connection is invalid')
});
}
if (
} else if (
sourceType !== ModuleIOValueTypeEnum.any &&
targetType !== ModuleIOValueTypeEnum.any &&
sourceType !== targetType
@@ -215,16 +254,13 @@ export const FlowProvider = ({
addEdge(
{
...connect,
type: EDGE_TYPE,
data: {
onDelete: onDelConnect
}
type: EDGE_TYPE
},
state
)
);
},
[nodes, onDelConnect, setEdges, t, toast]
[nodes, setEdges, t, toast]
);
const onDelNode = useCallback(
@@ -359,6 +395,26 @@ export const FlowProvider = ({
[setNodes]
);
/* If the module is connected by a tool, the tool input and the normal input are separated */
const splitToolInputs = useCallback(
(inputs: FlowNodeInputItemType[], moduleId: string) => {
const isTool = !!edges.find(
(edge) =>
edge.targetHandle === ModuleOutputKeyEnum.selectedTools && edge.target === moduleId
);
return {
isTool,
toolInputs: inputs.filter((item) => isTool && item.toolDescription),
commonInputs: inputs.filter((item) => {
if (!isTool) return true;
return !item.toolDescription && item.key !== ModuleInputKeyEnum.switch;
})
};
},
[edges]
);
// reset a node data. delete edge and replace it
const onResetNode = useCallback(
({ id, module }: { id: string; module: FlowModuleTemplateType }) => {
@@ -465,7 +521,9 @@ export const FlowProvider = ({
onDelEdge,
onDelConnect,
onConnect,
initData
initData,
splitToolInputs,
hasToolNode
};
return <StateContext.Provider value={value}>{children}</StateContext.Provider>;

View File

@@ -17,7 +17,7 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/module/node/constant';
import { getPreviewPluginModule } from '@/web/core/plugin/api';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { moduleTemplatesList } from '@/web/core/modules/template/system';
import { moduleTemplatesList } from '@fastgpt/global/core/module/template/constants';
export type ModuleTemplateProps = {
templates: FlowModuleTemplateType[];

View File

@@ -3,6 +3,7 @@ import { BezierEdge, getBezierPath, EdgeLabelRenderer, EdgeProps } from 'reactfl
import { onDelConnect, useFlowProviderStore } from '../../FlowProvider';
import { Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { ModuleOutputKeyEnum } from '@fastgpt/global/core/module/constants';
const ButtonEdge = (props: EdgeProps) => {
const { nodes } = useFlowProviderStore();
@@ -15,6 +16,8 @@ const ButtonEdge = (props: EdgeProps) => {
sourcePosition,
targetPosition,
selected,
sourceHandleId,
animated,
style = {}
} = props;
@@ -34,6 +37,8 @@ const ButtonEdge = (props: EdgeProps) => {
targetPosition
});
const isToolEdge = sourceHandleId === ModuleOutputKeyEnum.selectedTools;
const memoEdgeLabel = useMemo(() => {
return (
<EdgeLabelRenderer>
@@ -60,29 +65,31 @@ const ButtonEdge = (props: EdgeProps) => {
<MyIcon
name="closeSolid"
w={'100%'}
color={active ? 'primary.800' : 'myGray.400'}
></MyIcon>
</Flex>
<Flex
alignItems={'center'}
justifyContent={'center'}
position={'absolute'}
transform={`translate(-78%, -50%) translate(${targetX}px,${targetY}px)`}
pointerEvents={'all'}
w={'16px'}
h={'16px'}
bg={'white'}
zIndex={active ? 1000 : 0}
>
<MyIcon
name={'common/rightArrowLight'}
w={'100%'}
color={active ? 'primary.800' : 'myGray.400'}
color={active ? 'primary.700' : 'myGray.400'}
></MyIcon>
</Flex>
{!isToolEdge && (
<Flex
alignItems={'center'}
justifyContent={'center'}
position={'absolute'}
transform={`translate(-78%, -50%) translate(${targetX}px,${targetY}px)`}
pointerEvents={'all'}
w={'16px'}
h={'16px'}
bg={'white'}
zIndex={active ? 1000 : 0}
>
<MyIcon
name={'common/rightArrowLight'}
w={'100%'}
color={active ? 'primary.700' : 'myGray.400'}
></MyIcon>
</Flex>
)}
</EdgeLabelRenderer>
);
}, [id, labelX, labelY, active, targetX, targetY]);
}, [labelX, labelY, active, isToolEdge, targetX, targetY, id]);
const memoBezierEdge = useMemo(() => {
const edgeStyle: React.CSSProperties = {
@@ -96,7 +103,7 @@ const ButtonEdge = (props: EdgeProps) => {
};
return <BezierEdge {...props} style={edgeStyle} />;
}, [props, active, style]);
}, [style, active, props]);
return (
<>

View File

@@ -4,7 +4,7 @@ import { BoxProps } from '@chakra-ui/react';
const Container = ({ children, ...props }: BoxProps) => {
return (
<Box px={4} py={3} position={'relative'} {...props}>
<Box px={'16px'} py={'10px'} position={'relative'} {...props}>
{children}
</Box>
);

View File

@@ -2,7 +2,13 @@ import React from 'react';
import { Box, useTheme } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
const Divider = ({ text }: { text?: 'Input' | 'Output' | string }) => {
const Divider = ({
text,
showBorderBottom = true
}: {
text?: 'Input' | 'Output' | string;
showBorderBottom?: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -14,10 +20,10 @@ const Divider = ({ text }: { text?: 'Input' | 'Output' | string }) => {
bg={'#f8f8f8'}
py={isDivider ? '0' : 2}
borderTop={theme.borders.base}
borderBottom={theme.borders.base}
borderBottom={showBorderBottom ? theme.borders.base : 0}
fontSize={'lg'}
>
{text ? t(`common.${text}`) : ''}
{text}
</Box>
);
};

View File

@@ -5,14 +5,29 @@ import { FlowModuleItemType } from '@fastgpt/global/core/module/type.d';
import Container from '../modules/Container';
import RenderInput from '../render/RenderInput';
import RenderOutput from '../render/RenderOutput';
import { useFlowProviderStore } from '../../FlowProvider';
import Divider from '../modules/Divider';
import RenderToolInput from '../render/RenderToolInput';
import { useTranslation } from 'next-i18next';
const NodeAnswer = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
const { t } = useTranslation();
const { moduleId, inputs, outputs } = data;
const { splitToolInputs } = useFlowProviderStore();
const { toolInputs, commonInputs } = splitToolInputs(inputs, moduleId);
return (
<NodeCard minW={'400px'} selected={selected} {...data}>
<Container borderTop={'2px solid'} borderTopColor={'myGray.200'}>
<RenderInput moduleId={moduleId} flowInputList={inputs} />
{toolInputs.length > 0 && (
<>
<Divider text={t('core.module.tool.Tool input')} />
<Container>
<RenderToolInput moduleId={moduleId} inputs={toolInputs} />
</Container>
</>
)}
<RenderInput moduleId={moduleId} flowInputList={commonInputs} />
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
</NodeCard>

View File

@@ -23,7 +23,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
return (
<NodeCard minW={'400px'} selected={selected} {...data}>
<Divider text="Input" />
<Divider text={t('common.Input')} />
<Container>
<RenderInput
moduleId={moduleId}

View File

@@ -26,117 +26,132 @@ import ExtractFieldModal, { defaultField } from './ExtractFieldModal';
import { ModuleInputKeyEnum } from '@fastgpt/global/core/module/constants';
import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/module/node/constant';
import { ModuleIOValueTypeEnum } from '@fastgpt/global/core/module/constants';
import { onChangeNode } from '../../../FlowProvider';
import { onChangeNode, useFlowProviderStore } from '../../../FlowProvider';
import RenderToolInput from '../../render/RenderToolInput';
const NodeExtract = ({ data }: NodeProps<FlowModuleItemType>) => {
const { inputs, outputs, moduleId } = data;
const { splitToolInputs } = useFlowProviderStore();
const { toolInputs, commonInputs } = splitToolInputs(inputs, moduleId);
const { t } = useTranslation();
const [editExtractFiled, setEditExtractField] = useState<ContextExtractAgentItemType>();
return (
<NodeCard minW={'400px'} {...data}>
<Divider text="Input" />
<Container>
<RenderInput
moduleId={moduleId}
flowInputList={inputs}
CustomComponent={{
[ModuleInputKeyEnum.extractKeys]: ({
value: extractKeys = [],
...props
}: {
value?: ContextExtractAgentItemType[];
}) => (
<Box>
<Flex alignItems={'center'}>
<Box flex={'1 0 0'}>{t('core.module.extract.Target field')}</Box>
<Button
size={'sm'}
variant={'whitePrimary'}
leftIcon={<AddIcon fontSize={'10px'} />}
onClick={() => setEditExtractField(defaultField)}
{toolInputs.length > 0 && (
<>
<Divider text={t('core.module.tool.Tool input')} />
<Container>
<RenderToolInput moduleId={moduleId} inputs={toolInputs} />
</Container>
</>
)}
<>
<Divider text={t('common.Input')} />
<Container>
<RenderInput
moduleId={moduleId}
flowInputList={commonInputs}
CustomComponent={{
[ModuleInputKeyEnum.extractKeys]: ({
value: extractKeys = [],
...props
}: {
value?: ContextExtractAgentItemType[];
}) => (
<Box>
<Flex alignItems={'center'}>
<Box flex={'1 0 0'}>{t('core.module.extract.Target field')}</Box>
<Button
size={'sm'}
variant={'whitePrimary'}
leftIcon={<AddIcon fontSize={'10px'} />}
onClick={() => setEditExtractField(defaultField)}
>
{t('core.module.extract.Add field')}
</Button>
</Flex>
<Box
mt={2}
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
borderBottom="none"
>
{t('core.module.extract.Add field')}
</Button>
</Flex>
<Box
mt={2}
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
borderBottom="none"
>
<TableContainer>
<Table bg={'white'}>
<Thead>
<Tr>
<Th bg={'myGray.50'}> key</Th>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
</Tr>
</Thead>
<Tbody>
{extractKeys.map((item, index) => (
<Tr
key={index}
position={'relative'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
>
<Td>{item.key}</Td>
<Td>{item.desc}</Td>
<Td>{item.required ? '✔' : ''}</Td>
<Td whiteSpace={'nowrap'}>
<MyIcon
mr={3}
name={'common/settingLight'}
w={'16px'}
cursor={'pointer'}
onClick={() => {
setEditExtractField(item);
}}
/>
<MyIcon
name={'delete'}
w={'16px'}
cursor={'pointer'}
onClick={() => {
onChangeNode({
moduleId,
type: 'updateInput',
key: ModuleInputKeyEnum.extractKeys,
value: {
...props,
value: extractKeys.filter(
(extract) => item.key !== extract.key
)
}
});
onChangeNode({
moduleId,
type: 'delOutput',
key: item.key
});
}}
/>
</Td>
<TableContainer>
<Table bg={'white'}>
<Thead>
<Tr>
<Th bg={'myGray.50'}> key</Th>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Thead>
<Tbody>
{extractKeys.map((item, index) => (
<Tr
key={index}
position={'relative'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
>
<Td>{item.key}</Td>
<Td>{item.desc}</Td>
<Td>{item.required ? '✔' : ''}</Td>
<Td whiteSpace={'nowrap'}>
<MyIcon
mr={3}
name={'common/settingLight'}
w={'16px'}
cursor={'pointer'}
onClick={() => {
setEditExtractField(item);
}}
/>
<MyIcon
name={'delete'}
w={'16px'}
cursor={'pointer'}
onClick={() => {
onChangeNode({
moduleId,
type: 'updateInput',
key: ModuleInputKeyEnum.extractKeys,
value: {
...props,
value: extractKeys.filter(
(extract) => item.key !== extract.key
)
}
});
onChangeNode({
moduleId,
type: 'delOutput',
key: item.key
});
}}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</Box>
</Box>
)
}}
/>
</Container>
<Divider text="Output" />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
)
}}
/>
</Container>
</>
<>
<Divider text={t('common.Output')} />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
</>
{!!editExtractFiled && (
<ExtractFieldModal

View File

@@ -1,7 +1,7 @@
import React from 'react';
import MyModal from '@/components/MyModal';
import { ModalBody, Button, ModalFooter, useDisclosure, Textarea, Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'next-i18next';
import { onChangeNode } from '../../../FlowProvider';
import { ModuleInputKeyEnum } from '@fastgpt/global/core/module/constants';
import { FlowNodeInputItemType } from '@fastgpt/global/core/module/node/type';

View File

@@ -38,6 +38,7 @@ import { EditorVariablePickerType } from '@fastgpt/web/components/common/Textare
import HttpInput from '@fastgpt/web/components/common/Input/HttpInput';
import dynamic from 'next/dynamic';
import MySelect from '@fastgpt/web/components/common/MySelect';
import RenderToolInput from '../../render/RenderToolInput';
const OpenApiImportModal = dynamic(() => import('./OpenApiImportModal'));
enum TabEnum {
@@ -137,12 +138,12 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({
return (
<Box>
<Box mb={2} display={'flex'} justifyContent={'space-between'}>
<span>{t('core.module.Http request settings')}</span>
<span>
<Box>{t('core.module.Http request settings')}</Box>
<Box>
<OpenApiImportModal moduleId={moduleId} inputs={inputs}>
<Button variant={'link'}>{t('core.module.http.OpenAPI import')}</Button>
</OpenApiImportModal>
</span>
</Box>
</Box>
<Flex alignItems={'center'} className="nodrag">
<MySelect
@@ -252,7 +253,7 @@ function RenderHttpProps({
];
const moduleVariables = formatEditorVariablePickerIcon(
inputs
.filter((input) => input.edit)
.filter((input) => input.edit || input.toolDescription)
.map((item) => ({
key: item.key,
label: item.label
@@ -593,6 +594,8 @@ const RenderPropsItem = ({ text, num }: { text: string; num: number }) => {
const NodeHttp = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
const { t } = useTranslation();
const { moduleId, inputs, outputs } = data;
const { splitToolInputs, hasToolNode } = useFlowProviderStore();
const { toolInputs, commonInputs } = splitToolInputs(inputs, moduleId);
const CustomComponents = useMemo(
() => ({
@@ -613,18 +616,30 @@ const NodeHttp = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
return (
<NodeCard minW={'350px'} selected={selected} {...data}>
<Divider text="Input" />
<Container>
<RenderInput
moduleId={moduleId}
flowInputList={inputs}
CustomComponent={CustomComponents}
/>
</Container>
<Divider text="Output" />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
{hasToolNode && (
<>
<Divider text={t('core.module.tool.Tool input')} />
<Container>
<RenderToolInput moduleId={moduleId} inputs={toolInputs} canEdit />
</Container>
</>
)}
<>
<Divider text={t('common.Input')} />
<Container>
<RenderInput
moduleId={moduleId}
flowInputList={commonInputs}
CustomComponent={CustomComponents}
/>
</Container>
</>
<>
<Divider text={t('common.Output')} />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
</>
</NodeCard>
);
};

View File

@@ -15,6 +15,7 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@/components/MyTooltip';
import SourceHandle from '../render/SourceHandle';
import type {
EditInputFieldMap,
EditNodeFieldType,
FlowNodeInputItemType,
FlowNodeOutputItemType
@@ -32,13 +33,14 @@ const defaultCreateField: EditNodeFieldType = {
valueType: ModuleIOValueTypeEnum.string,
required: true
};
const createEditField = {
const createEditField: EditInputFieldMap = {
key: true,
name: true,
description: true,
required: true,
dataType: true,
inputType: true
inputType: true,
isToolInput: true
};
const NodePluginInput = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
@@ -73,7 +75,8 @@ const NodePluginInput = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
key: item.key,
label: item.label,
description: item.description,
required: item.required
required: item.required,
isToolInput: !!item.toolDescription
})
}
/>
@@ -148,6 +151,7 @@ const NodePluginInput = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
type: data.inputType,
required: data.required,
description: data.description,
toolDescription: data.isToolInput ? data.description : undefined,
edit: true,
editField: createEditField
}
@@ -191,6 +195,7 @@ const NodePluginInput = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
required: data.required,
label: data.label,
description: data.description,
toolDescription: data.isToolInput ? data.description : undefined,
...(data.inputType === FlowNodeInputTypeEnum.addInputParam
? {
editField: {
@@ -218,7 +223,7 @@ const NodePluginInput = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
key: data.key,
label: data.label
};
console.log(data);
if (changeKey) {
onChangeNode({
moduleId,

View File

@@ -6,8 +6,10 @@ import Divider from '../modules/Divider';
import Container from '../modules/Container';
import RenderInput from '../render/RenderInput';
import RenderOutput from '../render/RenderOutput';
import { useTranslation } from 'next-i18next';
const NodeRunAPP = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
const { t } = useTranslation();
const { moduleId, inputs, outputs } = data;
return (
@@ -15,7 +17,7 @@ const NodeRunAPP = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
<Container borderTop={'2px solid'} borderTopColor={'myGray.200'}>
<RenderInput moduleId={moduleId} flowInputList={inputs} />
</Container>
<Divider text="Output" />
<Divider text={t('common.Output')} />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import { FlowModuleItemType } from '@fastgpt/global/core/module/type.d';
@@ -6,23 +6,42 @@ import Divider from '../modules/Divider';
import Container from '../modules/Container';
import RenderInput from '../render/RenderInput';
import RenderOutput from '../render/RenderOutput';
import RenderToolInput from '../render/RenderToolInput';
import { useTranslation } from 'next-i18next';
import { useFlowProviderStore } from '../../FlowProvider';
const NodeSimple = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
const { t } = useTranslation();
const { splitToolInputs } = useFlowProviderStore();
const { moduleId, inputs, outputs } = data;
const { toolInputs, commonInputs } = splitToolInputs(inputs, moduleId);
const filterHiddenInputs = useMemo(
() => commonInputs.filter((item) => item.type !== 'hidden'),
[commonInputs]
);
return (
<NodeCard minW={'350px'} selected={selected} {...data}>
{inputs.length > 0 && (
{toolInputs.length > 0 && (
<>
<Divider text="Input" />
<Divider text={t('core.module.tool.Tool input')} />
<Container>
<RenderInput moduleId={moduleId} flowInputList={inputs} />
<RenderToolInput moduleId={moduleId} inputs={toolInputs} />
</Container>
</>
)}
{filterHiddenInputs.length > 0 && (
<>
<Divider text={t('common.Input')} />
<Container>
<RenderInput moduleId={moduleId} flowInputList={commonInputs} />
</Container>
</>
)}
{outputs.length > 0 && (
<>
<Divider text="Output" />
<Divider text={t('common.Output')} />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import { FlowModuleItemType } from '@fastgpt/global/core/module/type.d';
import Divider from '../modules/Divider';
import Container from '../modules/Container';
import RenderInput from '../render/RenderInput';
import RenderOutput from '../render/RenderOutput';
import { useTranslation } from 'next-i18next';
import { ToolSourceHandle } from '../render/ToolHandle';
import { Box } from '@chakra-ui/react';
const NodeTools = ({ data, selected }: NodeProps<FlowModuleItemType>) => {
const { t } = useTranslation();
const { moduleId, inputs, outputs } = data;
return (
<NodeCard minW={'350px'} selected={selected} {...data}>
<Divider text={t('common.Input')} />
<Container>
<RenderInput moduleId={moduleId} flowInputList={inputs} />
</Container>
<Divider text={t('common.Output')} />
<Container>
<RenderOutput moduleId={moduleId} flowOutputList={outputs} />
</Container>
<Box position={'relative'}>
<Box borderBottomLeftRadius={'md'} borderBottomRadius={'md'} overflow={'hidden'}>
<Divider showBorderBottom={false} text={t('core.module.template.Tool module')} />
</Box>
<ToolSourceHandle moduleId={moduleId} />
</Box>
</NodeCard>
);
};
export default React.memo(NodeTools);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Box,
Button,
@@ -95,11 +95,12 @@ const FieldEditModal = ({
});
const inputType = watch('inputType');
const outputType = watch('outputType');
const valueType = watch('valueType');
const required = watch('required');
const [refresh, setRefresh] = useState(false);
const showDataTypeSelect = useMemo(() => {
if (!editField.dataType) return false;
if (inputType === undefined) return true;
if (inputType === FlowNodeInputTypeEnum.target) return true;
if (outputType === FlowNodeOutputTypeEnum.source) return true;
@@ -109,8 +110,8 @@ const FieldEditModal = ({
const showRequired = useMemo(() => {
if (inputType === FlowNodeInputTypeEnum.addInputParam) return false;
return editField.required;
}, [editField.required, inputType]);
return editField.required || editField.defaultValue;
}, [editField.defaultValue, editField.required, inputType]);
const showNameInput = useMemo(() => {
return editField.name;
@@ -126,6 +127,37 @@ const FieldEditModal = ({
return editField.description;
}, [editField.description]);
const onSubmitSuccess = useCallback(
(data: EditNodeFieldType) => {
if (!data.key) return;
if (isCreate && keys.includes(data.key)) {
return toast({
status: 'warning',
title: t('core.module.edit.Field Already Exist')
});
}
onSubmit({
data,
changeKey: !keys.includes(data.key)
});
},
[isCreate, keys, onSubmit, t, toast]
);
const onSubmitError = useCallback(
(e: Object) => {
for (const item of Object.values(e)) {
if (item.message) {
toast({
status: 'warning',
title: item.message
});
break;
}
}
},
[toast]
);
return (
<MyModal
isOpen={true}
@@ -164,7 +196,31 @@ const FieldEditModal = ({
{showRequired && (
<Flex alignItems={'center'} mb={5}>
<Box flex={'0 0 70px'}>{t('common.Require Input')}</Box>
<Switch {...register('required')} />
<Switch
{...register('required', {
onChange(e) {
if (!e.target.checked) {
setValue('defaultValue', '');
}
}
})}
/>
</Flex>
)}
{showRequired && required && editField.defaultValue && (
<Flex alignItems={'center'} mb={5}>
<Box flex={['0 0 70px']}>{t('core.module.Default value')}</Box>
<Input
bg={'myGray.50'}
placeholder={t('core.module.Default value placeholder')}
{...register('defaultValue')}
/>
</Flex>
)}
{editField.isToolInput && (
<Flex alignItems={'center'} mb={5}>
<Box flex={'0 0 70px'}></Box>
<Switch {...register('isToolInput')} />
</Flex>
)}
{showDataTypeSelect && (
@@ -194,18 +250,28 @@ const FieldEditModal = ({
{showNameInput && (
<Flex mb={5} alignItems={'center'}>
<Box flex={'0 0 70px'}>{t('core.module.Field Name')}</Box>
<Input placeholder="预约字段/sql语句……" {...register('label', { required: true })} />
<Input
bg={'myGray.50'}
placeholder="预约字段/sql语句……"
{...register('label', { required: true })}
/>
</Flex>
)}
{showKeyInput && (
<Flex mb={5} alignItems={'center'}>
<Box flex={'0 0 70px'}>{t('core.module.Field key')}</Box>
<Input
bg={'myGray.50'}
placeholder="appointment/sql"
{...register('key', {
required: true,
pattern: {
value: /^[a-zA-Z]+[0-9]*$/,
message: '字段key必须是纯英文字母或数字并且不能以数字开头。'
},
onChange: (e) => {
const value = e.target.value;
// auto fill label
if (!showNameInput) {
setValue('label', value);
}
@@ -215,10 +281,17 @@ const FieldEditModal = ({
</Flex>
)}
{showDescriptionInput && (
<Flex mb={5} alignItems={'flex-start'}>
<Box flex={'0 0 70px'}>{t('core.module.Field Description')}</Box>
<Textarea placeholder={t('common.choosable')} rows={3} {...register('description')} />
</Flex>
<Box mb={5} alignItems={'flex-start'}>
<Box flex={'0 0 70px'} mb={'1px'}>
{t('core.module.Field Description')}
</Box>
<Textarea
bg={'myGray.50'}
placeholder={t('common.choosable')}
rows={5}
{...register('description')}
/>
</Box>
)}
</ModalBody>
@@ -226,21 +299,7 @@ const FieldEditModal = ({
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common.Close')}
</Button>
<Button
onClick={handleSubmit((data) => {
if (!data.key) return;
if (isCreate && keys.includes(data.key)) {
return toast({
status: 'warning',
title: t('core.module.edit.Field Already Exist')
});
}
onSubmit({
data,
changeKey: !keys.includes(data.key)
});
})}
>
<Button onClick={handleSubmit(onSubmitSuccess, onSubmitError)}>
{t('common.Confirm')}
</Button>
</ModalFooter>

View File

@@ -1,14 +1,18 @@
import React, { useMemo } from 'react';
import { Box, Flex, useTheme, MenuButton } from '@chakra-ui/react';
import { Box, Button, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@/components/Avatar';
import type { FlowModuleItemType } from '@fastgpt/global/core/module/type.d';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { useTranslation } from 'next-i18next';
import { useEditTitle } from '@/web/common/hooks/useEditTitle';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { onChangeNode, onCopyNode, onResetNode, onDelNode } from '../../FlowProvider';
import {
onChangeNode,
onCopyNode,
onResetNode,
onDelNode,
useFlowProviderStore
} from '../../FlowProvider';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/module/node/constant';
import { ModuleInputKeyEnum } from '@fastgpt/global/core/module/constants';
import { useSystemStore } from '@/web/common/system/useSystemStore';
@@ -16,7 +20,8 @@ import { getPreviewPluginModule } from '@/web/core/plugin/api';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useConfirm } from '@/web/common/hooks/useConfirm';
import { LOGO_ICON } from '@fastgpt/global/common/system/constants';
import MyMenu from '@/components/MyMenu';
import { ToolTargetHandle } from './ToolHandle';
import { useEditTextarea } from '@fastgpt/web/hooks/useEditTextarea';
type Props = FlowModuleItemType & {
children?: React.ReactNode | React.ReactNode[] | string;
@@ -37,12 +42,18 @@ const NodeCard = (props: Props) => {
flowType,
inputs,
selected,
forbidMenu
forbidMenu,
isTool = false
} = props;
const theme = useTheme();
const { toast } = useToast();
const { setLoading } = useSystemStore();
const { nodes, splitToolInputs } = useFlowProviderStore();
const { onOpenModal: onOpenIntroModal, EditModal: EditIntroModal } = useEditTextarea({
title: t('core.module.Edit intro'),
tip: '调整该模块会对工具调用时机有影响。\n你可以通过精确的描述该模块功能引导模型进行工具调用。',
canEmpty: false
});
// custom title edit
const { onOpenModal, EditModal: EditTitleModal } = useEditTitle({
@@ -53,13 +64,23 @@ const NodeCard = (props: Props) => {
content: t('module.Confirm Sync Plugin')
});
const menuList = useMemo(
() => [
const showToolHandle = useMemo(
() => isTool && !!nodes.find((item) => item.data?.flowType === FlowNodeTypeEnum.tools),
[isTool, nodes]
);
const moduleIsTool = useMemo(() => {
const { isTool } = splitToolInputs([], moduleId);
return isTool;
}, [moduleId, splitToolInputs]);
const Header = useMemo(() => {
const menuList = [
...(flowType === FlowNodeTypeEnum.pluginModule
? [
{
icon: 'common/refreshLight',
label: t('plugin.Synchronous version'),
variant: 'whiteBase',
onClick: () => {
const pluginId = inputs.find(
(item) => item.key === ModuleInputKeyEnum.pluginId
@@ -88,6 +109,7 @@ const NodeCard = (props: Props) => {
{
icon: 'edit',
label: t('common.Rename'),
variant: 'whiteBase',
onClick: () =>
onOpenModal({
defaultVal: name,
@@ -111,65 +133,130 @@ const NodeCard = (props: Props) => {
{
icon: 'copy',
label: t('common.Copy'),
variant: 'whiteBase',
onClick: () => onCopyNode(moduleId)
},
{
icon: 'delete',
label: t('common.Delete'),
variant: 'whiteDanger',
onClick: () => onDelNode(moduleId)
}
],
[flowType, inputs, moduleId, name, onOpenModal, openConfirm, setLoading, t, toast]
);
];
return (
<Box className="custom-drag-handle" px={4} py={3} position={'relative'}>
{showToolHandle && <ToolTargetHandle moduleId={moduleId} />}
<Flex alignItems={'center'}>
<Avatar src={avatar} borderRadius={'0'} objectFit={'contain'} w={'30px'} h={'30px'} />
<Box ml={3} fontSize={'lg'}>
{t(name)}
</Box>
</Flex>
{!forbidMenu && (
<Box
className="nodrag controller-menu"
display={'none'}
flexDirection={'column'}
gap={3}
position={'absolute'}
top={'-20px'}
right={0}
transform={'translateX(90%)'}
pl={'17px'}
pr={'10px'}
pb={'20px'}
pt={'20px'}
>
{menuList.map((item) => (
<Box key={item.icon}>
<Button
size={'xs'}
variant={item.variant}
leftIcon={<MyIcon name={item.icon as any} w={'12px'} />}
onClick={item.onClick}
>
{item.label}
</Button>
</Box>
))}
</Box>
)}
<Flex alignItems={'flex-end'} py={1}>
<Box fontSize={'xs'} color={'myGray.600'} flex={'1 0 0'}>
{t(intro)}
</Box>
{moduleIsTool && (
<Button
size={'xs'}
variant={'whiteBase'}
onClick={() => {
onOpenIntroModal({
defaultVal: intro,
onSuccess(e) {
onChangeNode({
moduleId,
type: 'attr',
key: 'intro',
value: e
});
}
});
}}
>
{t('core.module.Edit intro')}
</Button>
)}
</Flex>
</Box>
);
}, [
avatar,
flowType,
forbidMenu,
inputs,
intro,
moduleId,
moduleIsTool,
name,
onOpenIntroModal,
onOpenModal,
openConfirm,
setLoading,
showToolHandle,
t,
toast
]);
const RenderModal = useMemo(() => {
return (
<>
<EditTitleModal maxLength={20} />
{moduleIsTool && <EditIntroModal maxLength={500} />}
<ConfirmModal />
</>
);
}, [ConfirmModal, EditIntroModal, EditTitleModal, moduleIsTool]);
return (
<Box
minW={minW}
maxW={'500px'}
maxW={'600px'}
bg={'white'}
borderWidth={'1px'}
borderColor={selected ? 'primary.600' : 'borderColor.base'}
borderRadius={'md'}
boxShadow={'1'}
_hover={{
boxShadow: '4'
boxShadow: '4',
'& .controller-menu': {
display: 'flex'
}
}}
>
<Box className="custom-drag-handle" px={4} py={3}>
<Flex alignItems={'center'}>
<Avatar src={avatar} borderRadius={'0'} objectFit={'contain'} w={'30px'} h={'30px'} />
<Box ml={3} fontSize={'lg'}>
{t(name)}
</Box>
<Box flex={1} />
{!forbidMenu && (
<MyMenu
offset={[-60, 5]}
width={120}
Button={
<MenuButton
className={'nodrag'}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
borderRadius={'md'}
onClick={(e) => {
e.stopPropagation();
}}
>
<MyIcon name={'more'} w={'14px'} p={2} />
</MenuButton>
}
menuList={menuList}
/>
)}
</Flex>
<Box fontSize={'xs'} color={'myGray.600'}>
{t(intro)}
</Box>
</Box>
{Header}
{children}
<EditTitleModal />
<ConfirmModal />
{RenderModal}
</Box>
);
};

View File

@@ -78,32 +78,33 @@ const RenderInput = ({ flowInputList, moduleId, CustomComponent }: Props) => {
const sortInputs = useMemo(
() =>
flowInputList.sort((a, b) => {
if (a.type === FlowNodeInputTypeEnum.addInputParam) {
return 1;
}
if (b.type === FlowNodeInputTypeEnum.addInputParam) {
return -1;
}
JSON.stringify(
[...flowInputList].sort((a, b) => {
if (a.type === FlowNodeInputTypeEnum.addInputParam) {
return 1;
}
if (b.type === FlowNodeInputTypeEnum.addInputParam) {
return -1;
}
if (a.type === FlowNodeInputTypeEnum.switch) {
return -1;
}
if (a.type === FlowNodeInputTypeEnum.switch) {
return -1;
}
return 0;
}),
return 0;
})
),
[flowInputList]
);
const filterInputs = useMemo(
() =>
sortInputs.filter((input) => {
if (mode === 'app' && input.hideInApp) return false;
if (mode === 'plugin' && input.hideInPlugin) return false;
const filterInputs = useMemo(() => {
const parseSortInputs = JSON.parse(sortInputs) as FlowNodeInputItemType[];
return parseSortInputs.filter((input) => {
if (mode === 'app' && input.hideInApp) return false;
if (mode === 'plugin' && input.hideInPlugin) return false;
return true;
}),
[mode, sortInputs]
);
return true;
});
}, [mode, sortInputs]);
const memoCustomComponent = useMemo(() => CustomComponent || {}, [CustomComponent]);
@@ -135,7 +136,7 @@ const RenderInput = ({ flowInputList, moduleId, CustomComponent }: Props) => {
</Box>
) : null;
});
}, [memoCustomComponent, filterInputs, mode, moduleId]);
}, [filterInputs, memoCustomComponent, mode, moduleId]);
return <>{Render}</>;
};

View File

@@ -3,14 +3,24 @@ import type { RenderInputProps } from '../type';
import { onChangeNode } from '../../../../FlowProvider';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import SelectAiModel from '@/components/Select/SelectAiModel';
import { llmModelTypeFilterMap } from '@fastgpt/global/core/ai/constants';
const SelectAiModelRender = ({ item, inputs = [], moduleId }: RenderInputProps) => {
const { llmModelList } = useSystemStore();
const modelList = llmModelList.map((item) => ({
model: item.model,
name: item.name,
maxResponse: item.maxResponse
}));
const modelList = llmModelList
.filter((model) => {
if (!item.llmModelType) return true;
const filterField = llmModelTypeFilterMap[item.llmModelType];
if (!filterField) return true;
//@ts-ignore
return !!model[filterField];
})
.map((item) => ({
model: item.model,
name: item.name,
maxResponse: item.maxResponse
}));
const onChangeModel = useCallback(
(e: string) => {

View File

@@ -48,7 +48,9 @@ const OutputLabel = ({
label: item.label,
description: item.description,
valueType: item.valueType,
outputType: item.type
outputType: item.type,
required: item.required,
defaultValue: item.defaultValue
})
}
/>
@@ -74,7 +76,20 @@ const OutputLabel = ({
<QuestionOutlineIcon display={['none', 'inline']} mr={1} />
</MyTooltip>
)}
<Box>{t(label)}</Box>
<Box position={'relative'}>
{item.required && (
<Box
position={'absolute'}
top={'-2px'}
left={'-5px'}
color={'red.500'}
fontWeight={'bold'}
>
*
</Box>
)}
{t(label)}
</Box>
{item.type === FlowNodeOutputTypeEnum.source && (
<SourceHandle handleKey={outputKey} valueType={item.valueType} />
@@ -95,7 +110,9 @@ const OutputLabel = ({
valueType: data.valueType,
key: data.key,
label: data.label,
description: data.description
description: data.description,
required: data.required,
defaultValue: data.defaultValue
};
if (changeKey) {

View File

@@ -17,7 +17,7 @@ const RenderList: {
}
];
const RenderOutput = ({
const RenderToolOutput = ({
moduleId,
flowOutputList
}: {
@@ -77,4 +77,4 @@ const RenderOutput = ({
);
};
export default React.memo(RenderOutput);
export default React.memo(RenderToolOutput);

View File

@@ -43,6 +43,7 @@ const AddOutputParam = ({ outputs = [], item, moduleId }: RenderOutputProps) =>
label: data.label,
description: data.description,
required: data.required,
defaultValue: data.defaultValue,
edit: true,
editField: item.editField,
targets: []

View File

@@ -0,0 +1,141 @@
import React, { useCallback, useRef } from 'react';
import MyModal from '@/components/MyModal';
import type { EditFieldModalProps } from './type.d';
import { useTranslation } from 'next-i18next';
import {
Box,
Button,
Flex,
Input,
ModalBody,
ModalFooter,
Switch,
Textarea
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { defaultEditFormData } from './constants';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useRequest } from '@/web/common/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { onChangeNode } from '../../../FlowProvider';
import { FlowNodeInputItemType } from '@fastgpt/global/core/module/node/type';
const EditFieldModal = ({
defaultValue = defaultEditFormData,
moduleId,
onClose
}: EditFieldModalProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const { register, getValues, setValue, handleSubmit, watch } = useForm<FlowNodeInputItemType>({
defaultValues: defaultValue
});
const selectTypeList = useRef([
{
label: '字符串',
value: 'string'
}
]);
const { mutate: onclickSubmit } = useRequest({
mutationFn: async (e: FlowNodeInputItemType) => {
const inputConfig = {
...e,
label: e.key
};
if (defaultValue.key) {
// edit
onChangeNode({
moduleId,
type: 'replaceInput',
key: defaultValue.key,
value: inputConfig
});
} else {
// create
onChangeNode({
moduleId,
type: 'addInput',
key: e.key,
value: {
...e,
label: e.key
}
});
}
onClose();
}
});
const onclickSubmitError = useCallback(
(e: Object) => {
for (const item of Object.values(e)) {
if (item.message) {
toast({
status: 'warning',
title: item.message
});
break;
}
}
},
[toast]
);
return (
<MyModal isOpen iconSrc="modal/edit" title={'工具字段参数配置'} onClose={onClose}>
<ModalBody>
<Flex alignItems={'center'} mb={5}>
<Box flex={'0 0 80px'}>{t('common.Require Input')}</Box>
<Switch {...register('required')} />
</Flex>
<Flex alignItems={'center'} mb={5}>
<Box flex={'0 0 80px'}>{t('core.module.Field key')}</Box>
<Box flex={'1 0 0'}>
<MySelect
list={selectTypeList.current}
value={getValues('valueType')}
onchange={(e: any) => {
setValue('valueType', e);
}}
/>
</Box>
</Flex>
<Flex alignItems={'center'} mb={5}>
<Box flex={'0 0 80px'}>{'字段key'}</Box>
<Input
bg={'myGray.50'}
{...register('key', {
required: true,
pattern: {
value: /^[a-zA-Z]+[0-9]*$/,
message: '字段key必须是纯英文字母或数字并且不能以数字开头。'
}
})}
/>
</Flex>
<Box mb={5}>
<Box flex={'0 0 80px'}>{t('core.module.Field Description')}</Box>
<Textarea
bg={'myGray.50'}
rows={5}
{...register('toolDescription', {
required: true
})}
/>
</Box>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={2} onClick={onClose}>
{t('common.Close')}
</Button>
<Button onClick={handleSubmit((data) => onclickSubmit(data), onclickSubmitError)}>
{t('common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(EditFieldModal);

View File

@@ -0,0 +1,11 @@
import { FlowNodeInputItemType } from '@fastgpt/global/core/module/node/type';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/module/node/constant';
export const defaultEditFormData: FlowNodeInputItemType = {
valueType: 'string',
type: FlowNodeInputTypeEnum.hidden,
key: '',
label: '',
toolDescription: '',
required: true
};

View File

@@ -0,0 +1,120 @@
import React, { useMemo } from 'react';
import type {
FlowNodeInputItemType,
FlowNodeOutputItemType
} from '@fastgpt/global/core/module/node/type';
import {
Box,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Flex
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import dynamic from 'next/dynamic';
import { defaultEditFormData } from './constants';
import { onChangeNode } from '../../../FlowProvider';
const EditFieldModal = dynamic(() => import('./EditFieldModal'));
const RenderToolInput = ({
moduleId,
inputs,
canEdit = false
}: {
moduleId: string;
inputs: FlowNodeInputItemType[];
canEdit?: boolean;
}) => {
const { t } = useTranslation();
const [editField, setEditField] = React.useState<FlowNodeInputItemType>();
return (
<>
{canEdit && (
<Flex mb={2} alignItems={'center'}>
<Box flex={'1 0 0'}>{t('common.Field')}</Box>
<Button
variant={'unstyled'}
leftIcon={<MyIcon name={'common/addLight'} w={'14px'} />}
size={'sm'}
px={3}
_hover={{
bg: 'myGray.150'
}}
onClick={() => setEditField(defaultEditFormData)}
>
{t('core.module.extract.Add field')}
</Button>
</Flex>
)}
<Box borderRadius={'md'} overflow={'hidden'} borderWidth={'1px'} borderBottom="none">
<TableContainer>
<Table bg={'white'}>
<Thead>
<Tr>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
<Th bg={'myGray.50'}></Th>
{canEdit && <Th bg={'myGray.50'}></Th>}
</Tr>
</Thead>
<Tbody>
{inputs.map((item, index) => (
<Tr
key={index}
position={'relative'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
>
<Td>{item.key}</Td>
<Td>{item.toolDescription}</Td>
<Td>{item.required ? '✔' : ''}</Td>
{canEdit && (
<Td whiteSpace={'nowrap'}>
<MyIcon
mr={3}
name={'common/settingLight'}
w={'16px'}
cursor={'pointer'}
onClick={() => setEditField(item)}
/>
<MyIcon
name={'delete'}
w={'16px'}
cursor={'pointer'}
onClick={() => {
onChangeNode({
moduleId,
type: 'delInput',
key: item.key,
value: ''
});
}}
/>
</Td>
)}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
{!!editField && (
<EditFieldModal
defaultValue={editField}
moduleId={moduleId}
onClose={() => setEditField(undefined)}
/>
)}
</>
);
};
export default React.memo(RenderToolInput);

View File

@@ -0,0 +1,5 @@
export type EditFieldModalProps = {
defaultValue?: EditFieldFormProps;
moduleId: string;
onClose: () => void;
};

View File

@@ -28,7 +28,7 @@ const SourceHandle = ({ handleKey, valueType, ...props }: Props) => {
<Box
position={'absolute'}
top={'50%'}
right={'-18px'}
right={'-20px'}
transform={'translate(50%,-50%)'}
{...props}
>
@@ -42,6 +42,8 @@ const SourceHandle = ({ handleKey, valueType, ...props }: Props) => {
style={{
width: '14px',
height: '14px',
borderWidth: '3.5px',
backgroundColor: 'white',
...valueStyle
}}
type="source"

View File

@@ -27,7 +27,7 @@ const TargetHandle = ({ handleKey, valueType, ...props }: Props) => {
<Box
position={'absolute'}
top={'50%'}
left={'-18px'}
left={'-20px'}
transform={'translate(50%,-50%)'}
{...props}
>
@@ -41,6 +41,8 @@ const TargetHandle = ({ handleKey, valueType, ...props }: Props) => {
style={{
width: '14px',
height: '14px',
borderWidth: '3.5px',
backgroundColor: 'white',
...valueStyle
}}
type="target"

View File

@@ -0,0 +1,103 @@
import MyTooltip from '@/components/MyTooltip';
import { FlowValueTypeMap } from '@/web/core/modules/constants/dataType';
import { Box, BoxProps } from '@chakra-ui/react';
import {
ModuleIOValueTypeEnum,
ModuleInputKeyEnum,
ModuleOutputKeyEnum
} from '@fastgpt/global/core/module/constants';
import { useTranslation } from 'next-i18next';
import { Connection, Handle, Position } from 'reactflow';
import { useFlowProviderStore } from '../../FlowProvider';
import { useCallback } from 'react';
type ToolHandleProps = BoxProps & {
moduleId: string;
};
export const ToolTargetHandle = ({ moduleId, ...props }: ToolHandleProps) => {
const { t } = useTranslation();
const valueTypeMap = FlowValueTypeMap[ModuleIOValueTypeEnum.tools];
return (
<Box position={'absolute'} left={'50%'} transform={'translate(-17px,-10px)'} {...props}>
<MyTooltip
label={t('app.module.type', {
type: t(valueTypeMap?.label),
description: valueTypeMap?.description
})}
>
<Handle
style={{
width: '14px',
height: '14px',
border: '4px solid #5E8FFF',
borderRadius: '0',
backgroundColor: 'transparent',
transformOrigin: 'center',
transform: 'rotate(45deg)'
}}
type="target"
id={ModuleOutputKeyEnum.selectedTools}
position={Position.Top}
/>
</MyTooltip>
</Box>
);
};
export const ToolSourceHandle = ({ moduleId, ...props }: ToolHandleProps) => {
const { t } = useTranslation();
const { setEdges, nodes } = useFlowProviderStore();
const valueTypeMap = FlowValueTypeMap[ModuleIOValueTypeEnum.tools];
/* onConnect edge, delete tool input and switch */
const onConnect = useCallback(
(e: Connection) => {
const node = nodes.find((node) => node.id === e.target);
if (!node) return;
const inputs = node.data.inputs;
setEdges((edges) =>
edges.filter((edge) => {
const input = inputs.find((input) => input.key === edge.targetHandle);
if (
edge.target === node.id &&
(!!input?.toolDescription || input?.key === ModuleInputKeyEnum.switch)
) {
return false;
}
return true;
})
);
},
[nodes, setEdges]
);
return (
<Box position={'absolute'} left={'50%'} transform={'translate(-16px,-14px)'} {...props}>
<MyTooltip
label={t('app.module.type', {
type: t(valueTypeMap?.label),
description: valueTypeMap?.description
})}
>
<Handle
style={{
width: '14px',
height: '14px',
border: '4px solid #5E8FFF',
borderRadius: '0',
backgroundColor: 'transparent',
transformOrigin: 'center',
transform: 'rotate(45deg)'
}}
type="source"
id={ModuleOutputKeyEnum.selectedTools}
position={Position.Bottom}
onConnect={onConnect}
/>
</MyTooltip>
</Box>
);
};

View File

@@ -33,7 +33,8 @@ const nodeTypes: Record<`${FlowNodeTypeEnum}`, any> = {
[FlowNodeTypeEnum.pluginInput]: dynamic(() => import('./components/nodes/NodePluginInput')),
[FlowNodeTypeEnum.pluginOutput]: dynamic(() => import('./components/nodes/NodePluginOutput')),
[FlowNodeTypeEnum.pluginModule]: NodeSimple,
[FlowNodeTypeEnum.queryExtension]: NodeSimple
[FlowNodeTypeEnum.queryExtension]: NodeSimple,
[FlowNodeTypeEnum.tools]: dynamic(() => import('./components/nodes/NodeTools'))
};
const edgeTypes = {
[EDGE_TYPE]: ButtonEdge

View File

@@ -14,6 +14,7 @@ export const flowNode2Modules = ({
const modules: ModuleItemType[] = nodes.map((item) => ({
moduleId: item.data.moduleId,
name: item.data.name,
intro: item.data.intro,
avatar: item.data.avatar,
flowType: item.data.flowType,
showStatus: item.data.showStatus,
@@ -38,10 +39,15 @@ export const flowNode2Modules = ({
module.outputs.forEach((output) => {
output.targets = edges
.filter(
(edge) =>
edge.source === module.moduleId && edge.sourceHandle === output.key && edge.targetHandle
)
.filter((edge) => {
if (
edge.source === module.moduleId &&
edge.sourceHandle === output.key &&
edge.targetHandle
) {
return true;
}
})
.map((edge) => ({
moduleId: edge.target,
key: edge.targetHandle || ''