Plugin runtime (#2050)

* feat: plugin run (#1950)

* feat: plugin run

* fix

* ui

* fix

* change user input type

* fix

* fix

* temp

* split out plugin chat

* perf: chatbox

* perf: chatbox

* fix: plugin runtime (#2032)

* fix: plugin runtime

* fix

* fix build

* fix build

* perf: chat send prompt

* perf: chat log ux

* perf: chatbox context and share page plugin runtime

* perf: plugin run time config

* fix: ts

* feat: doc

* perf: isPc check

* perf: variable input render

* feat: app search

* fix: response box height

* fix: phone ui

* perf: lock

* perf: plugin route

* fix: chat (#2049)

---------

Co-authored-by: heheer <71265218+newfish-cmyk@users.noreply.github.com>
This commit is contained in:
Archer
2024-07-15 22:50:48 +08:00
committed by GitHub
parent 090c880860
commit b5c98a4f63
126 changed files with 5012 additions and 4317 deletions

View File

@@ -0,0 +1,534 @@
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 } from 'react';
import { useTranslation } from 'next-i18next';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { compressImgFileAndUpload } from '@/web/common/file/controller';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { addDays } from 'date-fns';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { MongoImageTypeEnum } from '@fastgpt/global/common/file/image/constants';
import { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from '../type';
import { textareaMinH } from '../constants';
import { UseFormReturn, useFieldArray } from 'react-hook-form';
import { ChatBoxContext } from '../Provider';
import dynamic from 'next/dynamic';
import { useContextSelector } from 'use-context-selector';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const InputGuideBox = dynamic(() => import('./InputGuideBox'));
const ChatInput = ({
onSendMessage,
onStop,
TextareaDom,
showFileSelector = false,
resetInputVal,
chatForm,
appId
}: {
onSendMessage: (val: ChatBoxInputType & { autoTTSResponse?: boolean }) => void;
onStop: () => void;
showFileSelector?: boolean;
TextareaDom: React.MutableRefObject<HTMLTextAreaElement | null>;
resetInputVal: (val: ChatBoxInputType) => void;
chatForm: UseFormReturn<ChatBoxInputFormType>;
appId: string;
}) => {
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 { isChatting, whisperConfig, autoTTSResponse, chatInputGuide, outLinkAuthData } =
useContextSelector(ChatBoxContext, (v) => v);
const { whisperModel } = useSystemStore();
const { isPc } = useSystem();
const canvasRef = useRef<HTMLCanvasElement>(null);
const { t } = useTranslation();
const havInput = !!inputValue || fileList.length > 0;
const hasFileUploading = fileList.some((item) => !item.url);
const canSendMessage = havInput && !hasFileUploading;
/* file selector and upload */
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: 'image/*',
multiple: true,
maxCount: 10
});
const { mutate: uploadFile } = useRequest({
mutationFn: async ({ file, fileIndex }: { file: UserInputFileItemType; fileIndex: number }) => {
if (file.type === ChatFileTypeEnum.image && file.rawFile) {
try {
const url = await compressImgFileAndUpload({
type: MongoImageTypeEnum.chatImage,
file: file.rawFile,
maxW: 4320,
maxH: 4320,
maxSize: 1024 * 1024 * 16,
// 7 day expired.
expiredTime: addDays(new Date(), 7),
...outLinkAuthData
});
updateFile(fileIndex, {
...file,
url: `${location.origin}${url}`
});
} catch (error) {
removeFile(fileIndex);
console.log(error);
return Promise.reject(error);
}
}
},
errorToast: t('common.Upload File Failed')
});
const onSelectFile = useCallback(
async (files: File[]) => {
if (!files || files.length === 0) {
return;
}
const loadFiles = await Promise.all(
files.map(
(file) =>
new Promise<UserInputFileItemType>((resolve, reject) => {
if (file.type.includes('image')) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const item = {
id: getNanoid(6),
rawFile: file,
type: ChatFileTypeEnum.image,
name: file.name,
icon: reader.result as string
};
resolve(item);
};
reader.onerror = () => {
reject(reader.error);
};
} else {
resolve({
id: getNanoid(6),
rawFile: file,
type: ChatFileTypeEnum.file,
name: file.name,
icon: 'file/pdf'
});
}
})
)
);
appendFile(loadFiles);
loadFiles.forEach((file, i) =>
uploadFile({
file,
fileIndex: i + fileList.length
})
);
},
[appendFile, fileList.length, uploadFile]
);
/* on send */
const handleSend = async (val?: string) => {
if (!canSendMessage) return;
const textareaValue = val || TextareaDom.current?.value || '';
onSendMessage({
text: textareaValue.trim(),
files: fileList
});
replaceFile([]);
};
/* whisper init */
const {
isSpeaking,
isTransCription,
stopSpeak,
startSpeak,
speakingTimeString,
renderAudioGraph,
stream
} = useSpeech({ appId, ...outLinkAuthData });
useEffect(() => {
if (!stream) {
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 4096;
analyser.smoothingTimeConstant = 1;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const renderCurve = () => {
if (!canvasRef.current) return;
renderAudioGraph(analyser, canvasRef.current);
window.requestAnimationFrame(renderCurve);
};
renderCurve();
}, [renderAudioGraph, stream]);
const finishWhisperTranscription = useCallback(
(text: string) => {
if (!text) return;
if (whisperConfig?.autoSend) {
onSendMessage({
text,
files: fileList,
autoTTSResponse
});
replaceFile([]);
} else {
resetInputVal({ text });
}
},
[autoTTSResponse, fileList, onSendMessage, replaceFile, resetInputVal, whisperConfig?.autoSend]
);
const onWhisperRecord = useCallback(() => {
if (isSpeaking) {
return stopSpeak();
}
startSpeak(finishWhisperTranscription);
}, [finishWhisperTranscription, isSpeaking, startSpeak, stopSpeak]);
return (
<Box m={['0 auto', '10px auto']} w={'100%'} maxW={['auto', 'min(800px, 100%)']} px={[0, 5]}>
<Box
pt={fileList.length > 0 ? '10px' : ['14px', '18px']}
pb={['14px', '18px']}
position={'relative'}
boxShadow={isSpeaking ? `0 0 10px rgba(54,111,255,0.4)` : `0 0 10px rgba(0,0,0,0.2)`}
borderRadius={['none', 'md']}
bg={'white'}
overflow={'display'}
{...(isPc
? {
border: '1px solid',
borderColor: 'rgba(0,0,0,0.12)'
}
: {
borderTop: '1px solid',
borderTopColor: 'rgba(0,0,0,0.15)'
})}
>
{/* Chat input guide box */}
{chatInputGuide.open && (
<InputGuideBox
appId={appId}
text={inputValue}
onSelect={(e) => {
setValue('input', e);
}}
onSend={(e) => {
handleSend(e);
}}
/>
)}
{/* translate loading */}
<Flex
position={'absolute'}
top={0}
bottom={0}
left={0}
right={0}
zIndex={10}
pl={5}
alignItems={'center'}
bg={'white'}
color={'primary.500'}
visibility={isSpeaking && isTransCription ? 'visible' : 'hidden'}
>
<Spinner size={'sm'} mr={4} />
{t('core.chat.Converting to text')}
</Flex>
{/* file preview */}
<Flex wrap={'wrap'} px={[2, 4]} userSelect={'none'}>
{fileList.map((item, index) => (
<Box
key={item.id}
border={'1px solid rgba(0,0,0,0.12)'}
mr={2}
mb={2}
rounded={'md'}
position={'relative'}
_hover={{
'.close-icon': { display: item.url ? 'block' : 'none' }
}}
>
{/* uploading */}
{!item.url && (
<Flex
position={'absolute'}
alignItems={'center'}
justifyContent={'center'}
rounded={'md'}
color={'primary.500'}
top={0}
left={0}
bottom={0}
right={0}
bg={'rgba(255,255,255,0.8)'}
>
<Spinner />
</Flex>
)}
<MyIcon
name={'closeSolid'}
w={'16px'}
h={'16px'}
color={'myGray.700'}
cursor={'pointer'}
_hover={{ color: 'primary.500' }}
position={'absolute'}
bg={'white'}
right={'-8px'}
top={'-8px'}
onClick={() => {
removeFile(index);
}}
className="close-icon"
display={['', 'none']}
/>
{item.type === ChatFileTypeEnum.image && (
<Image
alt={'img'}
src={item.icon}
w={['50px', '70px']}
h={['50px', '70px']}
borderRadius={'md'}
objectFit={'contain'}
/>
)}
</Box>
))}
</Flex>
<Flex alignItems={'flex-end'} mt={fileList.length > 0 ? 1 : 0} pl={[2, 4]}>
{/* file selector */}
{showFileSelector && (
<Flex
h={'22px'}
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
transform={'translateY(1px)'}
onClick={() => {
if (isSpeaking) return;
onOpenSelectFile();
}}
>
<MyTooltip label={t('core.chat.Select Image')}>
<MyIcon name={'core/chat/fileSelect'} w={'18px'} color={'myGray.600'} />
</MyTooltip>
<File onSelect={onSelectFile} />
</Flex>
)}
{/* input area */}
<Textarea
ref={TextareaDom}
py={0}
pl={2}
pr={['30px', '48px']}
border={'none'}
_focusVisible={{
border: 'none'
}}
placeholder={isSpeaking ? t('core.chat.Speaking') : t('core.chat.Type a message')}
resize={'none'}
rows={1}
height={'22px'}
lineHeight={'22px'}
maxHeight={'50vh'}
maxLength={-1}
overflowY={'auto'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
boxShadow={'none !important'}
color={'myGray.900'}
isDisabled={isSpeaking}
value={inputValue}
fontSize={['md', 'sm']}
onChange={(e) => {
const textarea = e.target;
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
setValue('input', textarea.value);
}}
onKeyDown={(e) => {
// enter send.(pc or iframe && enter and unPress shift)
const isEnter = e.keyCode === 13;
if (isEnter && TextareaDom.current && (e.ctrlKey || e.altKey)) {
// Add a new line
const index = TextareaDom.current.selectionStart;
const val = TextareaDom.current.value;
TextareaDom.current.value = `${val.slice(0, index)}\n${val.slice(index)}`;
TextareaDom.current.selectionStart = index + 1;
TextareaDom.current.selectionEnd = index + 1;
TextareaDom.current.style.height = textareaMinH;
TextareaDom.current.style.height = `${TextareaDom.current.scrollHeight}px`;
return;
}
// 全选内容
// @ts-ignore
e.key === 'a' && e.ctrlKey && e.target?.select();
if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) {
handleSend();
e.preventDefault();
}
}}
onPaste={(e) => {
const clipboardData = e.clipboardData;
if (clipboardData && showFileSelector) {
const items = clipboardData.items;
const files = Array.from(items)
.map((item) => (item.kind === 'file' ? item.getAsFile() : undefined))
.filter(Boolean) as File[];
onSelectFile(files);
}
}}
/>
<Flex
alignItems={'center'}
position={'absolute'}
right={[2, 4]}
bottom={['10px', '12px']}
>
{/* voice-input */}
{whisperConfig.open && !havInput && !isChatting && !!whisperModel && (
<>
<canvas
ref={canvasRef}
style={{
height: '30px',
width: isSpeaking && !isTransCription ? '100px' : 0,
background: 'white',
zIndex: 0
}}
/>
{isSpeaking && (
<MyTooltip label={t('core.chat.Cancel Speak')}>
<Flex
mr={2}
alignItems={'center'}
justifyContent={'center'}
flexShrink={0}
h={['26px', '32px']}
w={['26px', '32px']}
borderRadius={'md'}
cursor={'pointer'}
_hover={{ bg: '#F5F5F8' }}
onClick={() => stopSpeak(true)}
>
<MyIcon
name={'core/chat/cancelSpeak'}
width={['20px', '22px']}
height={['20px', '22px']}
/>
</Flex>
</MyTooltip>
)}
<MyTooltip label={isSpeaking ? t('core.chat.Finish Speak') : t('core.chat.Record')}>
<Flex
mr={2}
alignItems={'center'}
justifyContent={'center'}
flexShrink={0}
h={['26px', '32px']}
w={['26px', '32px']}
borderRadius={'md'}
cursor={'pointer'}
_hover={{ bg: '#F5F5F8' }}
onClick={onWhisperRecord}
>
<MyIcon
name={isSpeaking ? 'core/chat/finishSpeak' : 'core/chat/recordFill'}
width={['20px', '22px']}
height={['20px', '22px']}
color={isSpeaking ? 'primary.500' : 'myGray.600'}
/>
</Flex>
</MyTooltip>
</>
)}
{/* send and stop icon */}
{isSpeaking ? (
<Box color={'#5A646E'} w={'36px'} textAlign={'right'} whiteSpace={'nowrap'}>
{speakingTimeString}
</Box>
) : (
<Flex
alignItems={'center'}
justifyContent={'center'}
flexShrink={0}
h={['28px', '32px']}
w={['28px', '32px']}
borderRadius={'md'}
bg={
isSpeaking || isChatting
? ''
: !havInput || hasFileUploading
? '#E5E5E5'
: 'primary.500'
}
cursor={havInput ? 'pointer' : 'not-allowed'}
lineHeight={1}
onClick={() => {
if (isChatting) {
return onStop();
}
return handleSend();
}}
>
{isChatting ? (
<MyIcon
animation={'zoomStopIcon 0.4s infinite alternate'}
width={['22px', '25px']}
height={['22px', '25px']}
cursor={'pointer'}
name={'stop'}
color={'gray.500'}
/>
) : (
<MyTooltip label={t('core.chat.Send Message')}>
<MyIcon
name={'core/chat/sendFill'}
width={['18px', '20px']}
height={['18px', '20px']}
color={'white'}
/>
</MyTooltip>
)}
</Flex>
)}
</Flex>
</Flex>
</Box>
</Box>
);
};
export default React.memo(ChatInput);

View File

@@ -0,0 +1,117 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useI18n } from '@/web/context/I18n';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { queryChatInputGuideList } from '@/web/core/chat/inputGuide/api';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import HighlightText from '@fastgpt/web/components/common/String/HighlightText';
import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector';
export default function InputGuideBox({
appId,
text,
onSelect,
onSend
}: {
appId: string;
text: string;
onSelect: (text: string) => void;
onSend: (text: string) => void;
}) {
const { t } = useTranslation();
const { chatT } = useI18n();
const chatInputGuide = useContextSelector(ChatBoxContext, (v) => v.chatInputGuide);
const outLinkAuthData = useContextSelector(ChatBoxContext, (v) => v.outLinkAuthData);
const { data = [] } = useRequest2(
async () => {
if (!text) return [];
// More than 20 characters, it's basically meaningless
if (text.length > 20) return [];
return await queryChatInputGuideList(
{
appId,
searchKey: text,
...outLinkAuthData
},
chatInputGuide.customUrl ? chatInputGuide.customUrl : undefined
);
},
{
manual: false,
refreshDeps: [text],
throttleWait: 300
}
);
const filterData = data.filter((item) => item !== text).slice(0, 5);
return filterData.length ? (
<Box
bg={'white'}
boxShadow={'lg'}
borderWidth={'1px'}
borderColor={'borderColor.base'}
p={2}
borderRadius={'md'}
position={'absolute'}
top={-3}
w={'100%'}
zIndex={150}
transform={'translateY(-100%)'}
>
<Flex alignItems={'center'} fontSize={'sm'} color={'myGray.600'} gap={2} mb={2} px={2}>
<MyIcon name={'union'} />
<Box>{chatT('Input guide')}</Box>
</Flex>
{data.map((item, index) => (
<Flex
alignItems={'center'}
as={'li'}
key={item}
px={4}
py={3}
borderRadius={'sm'}
cursor={'pointer'}
overflow={'auto'}
_notLast={{
mb: 1
}}
bg={'myGray.50'}
color={'myGray.600'}
_hover={{
bg: 'primary.50',
color: 'primary.600',
'.send-icon': {
display: 'block'
}
}}
onClick={() => onSelect(item)}
>
<Box fontSize={'sm'} flex={'1 0 0'}>
<HighlightText rawText={item} matchText={text} />
</Box>
<MyTooltip label={t('core.chat.markdown.Send Question')}>
<MyIcon
className="send-icon"
display={'none'}
name={'chatSend'}
boxSize={4}
color={'myGray.500'}
_hover={{
color: 'primary.600'
}}
onClick={(e) => {
e.stopPropagation();
onSend(item);
}}
/>
</MyTooltip>
</Flex>
))}
</Box>
) : null;
}

View File

@@ -0,0 +1,217 @@
import React, { useState, useMemo } from 'react';
import { useAudioPlay } from '@/web/common/utils/voice';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import {
AppChatConfigType,
AppTTSConfigType,
AppWhisperConfigType,
ChatInputGuideConfigType,
VariableItemType
} from '@fastgpt/global/core/app/type';
import { ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import {
defaultChatInputGuideConfig,
defaultTTSConfig,
defaultWhisperConfig
} from '@fastgpt/global/core/app/constants';
import { createContext } from 'use-context-selector';
import { FieldValues, UseFormReturn } from 'react-hook-form';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
export type ChatProviderProps = OutLinkChatAuthProps & {
appAvatar?: string;
chatConfig?: AppChatConfigType;
chatHistories: ChatSiteItemType[];
setChatHistories: React.Dispatch<React.SetStateAction<ChatSiteItemType[]>>;
variablesForm: UseFormReturn<FieldValues, any>;
// not chat test params
chatId?: string;
};
type useChatStoreType = OutLinkChatAuthProps &
ChatProviderProps & {
welcomeText: string;
variableList: VariableItemType[];
questionGuide: boolean;
ttsConfig: AppTTSConfigType;
whisperConfig: AppWhisperConfigType;
autoTTSResponse: boolean;
startSegmentedAudio: () => Promise<any>;
splitText2Audio: (text: string, done?: boolean | undefined) => void;
finishSegmentedAudio: () => void;
audioLoading: boolean;
audioPlaying: boolean;
hasAudio: boolean;
playAudioByText: ({
text,
buffer
}: {
text: string;
buffer?: Uint8Array | undefined;
}) => Promise<{
buffer?: Uint8Array | undefined;
}>;
cancelAudio: () => void;
audioPlayingChatId: string | undefined;
setAudioPlayingChatId: React.Dispatch<React.SetStateAction<string | undefined>>;
isChatting: boolean;
chatInputGuide: ChatInputGuideConfigType;
outLinkAuthData: OutLinkChatAuthProps;
};
export const ChatBoxContext = createContext<useChatStoreType>({
welcomeText: '',
variableList: [],
questionGuide: false,
ttsConfig: {
type: 'none',
model: undefined,
voice: undefined,
speed: undefined
},
whisperConfig: {
open: false,
autoSend: false,
autoTTSResponse: false
},
autoTTSResponse: false,
startSegmentedAudio: function (): Promise<any> {
throw new Error('Function not implemented.');
},
splitText2Audio: function (text: string, done?: boolean | undefined): void {
throw new Error('Function not implemented.');
},
chatHistories: [],
setChatHistories: function (value: React.SetStateAction<ChatSiteItemType[]>): void {
throw new Error('Function not implemented.');
},
isChatting: false,
audioLoading: false,
audioPlaying: false,
hasAudio: false,
playAudioByText: function ({
text,
buffer
}: {
text: string;
buffer?: Uint8Array | undefined;
}): Promise<{ buffer?: Uint8Array | undefined }> {
throw new Error('Function not implemented.');
},
cancelAudio: function (): void {
throw new Error('Function not implemented.');
},
audioPlayingChatId: undefined,
setAudioPlayingChatId: function (value: React.SetStateAction<string | undefined>): void {
throw new Error('Function not implemented.');
},
finishSegmentedAudio: function (): void {
throw new Error('Function not implemented.');
},
chatInputGuide: {
open: false,
customUrl: ''
},
outLinkAuthData: {},
// @ts-ignore
variablesForm: undefined
});
const Provider = ({
shareId,
outLinkUid,
teamId,
teamToken,
chatHistories,
setChatHistories,
variablesForm,
chatConfig = {},
children,
...props
}: ChatProviderProps & {
children: React.ReactNode;
}) => {
const {
welcomeText = '',
variables = [],
questionGuide = false,
ttsConfig = defaultTTSConfig,
whisperConfig = defaultWhisperConfig,
chatInputGuide = defaultChatInputGuideConfig
} = useMemo(() => chatConfig, [chatConfig]);
const outLinkAuthData = useMemo(
() => ({
shareId,
outLinkUid,
teamId,
teamToken
}),
[shareId, outLinkUid, teamId, teamToken]
);
// segment audio
const [audioPlayingChatId, setAudioPlayingChatId] = useState<string>();
const {
audioLoading,
audioPlaying,
hasAudio,
playAudioByText,
cancelAudio,
startSegmentedAudio,
finishSegmentedAudio,
splitText2Audio
} = useAudioPlay({
ttsConfig,
...outLinkAuthData
});
const autoTTSResponse =
whisperConfig?.open && whisperConfig?.autoSend && whisperConfig?.autoTTSResponse && hasAudio;
const isChatting = useMemo(
() =>
chatHistories[chatHistories.length - 1] &&
chatHistories[chatHistories.length - 1]?.status !== 'finish',
[chatHistories]
);
const value: useChatStoreType = {
...props,
shareId,
outLinkUid,
teamId,
teamToken,
welcomeText,
variableList: variables.filter((item) => item.type !== VariableInputEnum.custom),
questionGuide,
ttsConfig,
whisperConfig,
autoTTSResponse,
startSegmentedAudio,
finishSegmentedAudio,
splitText2Audio,
audioLoading,
audioPlaying,
hasAudio,
playAudioByText,
cancelAudio,
audioPlayingChatId,
setAudioPlayingChatId,
chatHistories,
setChatHistories,
isChatting,
chatInputGuide,
outLinkAuthData,
variablesForm
};
return <ChatBoxContext.Provider value={value}>{children}</ChatBoxContext.Provider>;
};
export default React.memo(Provider);

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%'} borderRadius={'sm'} />
</Box>
);
};
export default React.memo(ChatAvatar);

View File

@@ -0,0 +1,248 @@
import { useCopyData } from '@/web/common/hooks/useCopyData';
import { Flex, FlexProps, Image, css, useTheme } from '@chakra-ui/react';
import { ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import React, { useMemo } 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';
import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector';
export type ChatControllerProps = {
isLastChild: boolean;
chat: ChatSiteItemType;
showVoiceIcon?: boolean;
onRetry?: () => void;
onDelete?: () => void;
onMark?: () => void;
onReadUserDislike?: () => void;
onCloseUserLike?: () => void;
onAddUserLike?: () => void;
onAddUserDislike?: () => void;
};
const ChatController = ({
chat,
isLastChild,
showVoiceIcon,
onReadUserDislike,
onCloseUserLike,
onMark,
onRetry,
onDelete,
onAddUserDislike,
onAddUserLike
}: ChatControllerProps & FlexProps) => {
const theme = useTheme();
const {
isChatting,
setChatHistories,
audioLoading,
audioPlaying,
hasAudio,
playAudioByText,
cancelAudio,
audioPlayingChatId,
setAudioPlayingChatId
} = useContextSelector(ChatBoxContext, (v) => v);
const controlIconStyle = {
w: '14px',
cursor: 'pointer',
p: '5px',
bg: 'white',
borderRight: theme.borders.base
};
const controlContainerStyle = {
className: 'control',
color: 'myGray.400',
display: 'flex'
};
const { t } = useTranslation();
const { copyData } = useCopyData();
const chatText = useMemo(() => formatChatValue2InputType(chat.value).text || '', [chat.value]);
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(chatText)}
/>
</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 &&
(() => {
const isPlayingChat = chat.dataId === audioPlayingChatId;
if (isPlayingChat && audioPlaying) {
return (
<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>
);
}
if (isPlayingChat && audioLoading) {
return (
<MyTooltip label={t('common.Loading')}>
<MyIcon {...controlIconStyle} name={'common/loading'} />
</MyTooltip>
);
}
return (
<MyTooltip label={t('core.app.TTS start')}>
<MyIcon
{...controlIconStyle}
name={'common/voiceLight'}
_hover={{ color: '#E74694' }}
onClick={async () => {
setAudioPlayingChatId(chat.dataId);
const response = await playAudioByText({
buffer: chat.ttsBuffer,
text: chatText
});
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,158 @@
import { Box, BoxProps, Card, Flex } 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 from '@/components/Markdown';
import styles from '../index.module.scss';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import FilesBlock from './FilesBox';
import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector';
import AIResponseBox from '../../../components/AIResponseBox';
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;
};
questionGuides?: string[];
children?: React.ReactNode;
} & ChatControllerProps) => {
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 isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting);
const { chat } = chatControllerProps;
const ContentCard = useMemo(() => {
if (type === 'Human') {
const { text, files = [] } = formatChatValue2InputType(chat.value);
return (
<>
{files.length > 0 && <FilesBlock files={files} />}
<Markdown source={text} />
</>
);
}
/* AI */
return (
<Flex flexDirection={'column'} key={chat.dataId} gap={2}>
{chat.value.map((value, i) => {
const key = `${chat.dataId}-ai-${i}`;
return (
<AIResponseBox
key={key}
value={value}
index={i}
chat={chat}
isLastChild={isLastChild}
isChatting={isChatting}
questionGuides={questionGuides}
/>
);
})}
</Flex>
);
}, [chat, 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} isLastChild={isLastChild} />
</Box>
)}
<ChatAvatar src={avatar} type={type} />
{!!chatStatusMap && statusBoxData && isLastChild && (
<Flex
alignItems={'center'}
px={3}
py={'1.5px'}
borderRadius="md"
bg={chatStatusMap.bg}
fontSize={'sm'}
>
<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 React.memo(ChatItem);

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { ModalBody, Box, useTheme } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { DispatchNodeResponseType } from '@fastgpt/global/core/workflow/runtime/type.d';
const ContextModal = ({
context = [],
onClose
}: {
context: DispatchNodeResponseType['historyPreview'];
onClose: () => void;
}) => {
const theme = useTheme();
return (
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="/imgs/modal/chatHistory.svg"
title={`上下文预览(${context.length}条)`}
h={['90vh', '80vh']}
minW={['90vw', '600px']}
isCentered
>
<ModalBody
whiteSpace={'pre-wrap'}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'sm'}
>
{context.map((item, i) => (
<Box
key={i}
p={2}
borderRadius={'md'}
border={theme.borders.base}
_notLast={{ mb: 2 }}
position={'relative'}
>
<Box fontWeight={'bold'}>{item.obj}</Box>
<Box>{item.value}</Box>
</Box>
))}
</ModalBody>
</MyModal>
);
};
export default ContextModal;

View File

@@ -0,0 +1,25 @@
import { useMarkdown } from '@/web/common/hooks/useMarkdown';
import { Box, Card } from '@chakra-ui/react';
import React from 'react';
import dynamic from 'next/dynamic';
const Markdown = dynamic(() => import('@/components/Markdown'), { ssr: false });
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,75 @@
import React, { useRef } from 'react';
import { ModalBody, Textarea, ModalFooter, Button } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useTranslation } from 'next-i18next';
import { updateChatUserFeedback } from '@/web/core/chat/api';
const FeedbackModal = ({
appId,
chatId,
chatItemId,
teamId,
teamToken,
shareId,
outLinkUid,
onSuccess,
onClose
}: {
appId: string;
chatId: string;
chatItemId: string;
shareId?: string;
teamId?: string;
teamToken?: string;
outLinkUid?: string;
onSuccess: (e: string) => void;
onClose: () => void;
}) => {
const ref = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const { mutate, isLoading } = useRequest({
mutationFn: async () => {
const val = ref.current?.value || t('core.chat.feedback.No Content');
return updateChatUserFeedback({
appId,
chatId,
chatItemId,
shareId,
teamId,
teamToken,
outLinkUid,
userBadFeedback: val
});
},
onSuccess() {
onSuccess(ref.current?.value || t('core.chat.feedback.No Content'));
},
successToast: t('core.chat.Feedback Success'),
errorToast: t('core.chat.Feedback Failed')
});
return (
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="/imgs/modal/badAnswer.svg"
title={t('core.chat.Feedback Modal')}
>
<ModalBody>
<Textarea ref={ref} rows={10} placeholder={t('core.chat.Feedback Modal Tip')} />
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={2} onClick={onClose}>
{t('common.Close')}
</Button>
<Button isLoading={isLoading} onClick={mutate}>
{t('core.chat.Feedback Submit')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default FeedbackModal;

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/core/chat/ChatContainer/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,96 @@
import React, { useMemo } from 'react';
import { ModalBody, Box, useTheme } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import QuoteItem from '@/components/core/dataset/QuoteItem';
import RawSourceBox from '@/components/core/dataset/RawSourceBox';
const QuoteModal = ({
rawSearch = [],
onClose,
showDetail,
metadata
}: {
rawSearch: SearchDataResponseItemType[];
onClose: () => void;
showDetail: boolean;
metadata?: {
collectionId: string;
sourceId?: string;
sourceName: string;
};
}) => {
const { t } = useTranslation();
const filterResults = useMemo(
() =>
metadata
? rawSearch.filter(
(item) =>
item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
)
: rawSearch,
[metadata, rawSearch]
);
return (
<>
<MyModal
isOpen={true}
onClose={onClose}
h={['90vh', '80vh']}
isCentered
minW={['90vw', '600px']}
iconSrc={!!metadata ? undefined : '/imgs/modal/quote.svg'}
title={
<Box>
{metadata ? (
<RawSourceBox {...metadata} canView={showDetail} />
) : (
<>{t('core.chat.Quote Amount', { amount: rawSearch.length })}</>
)}
<Box fontSize={'xs'} color={'myGray.500'} fontWeight={'normal'}>
{t('core.chat.quote.Quote Tip')}
</Box>
</Box>
}
>
<ModalBody>
<QuoteList rawSearch={filterResults} showDetail={showDetail} />
</ModalBody>
</MyModal>
</>
);
};
export default QuoteModal;
export const QuoteList = React.memo(function QuoteList({
rawSearch = [],
showDetail
}: {
rawSearch: SearchDataResponseItemType[];
showDetail: boolean;
}) {
const theme = useTheme();
return (
<>
{rawSearch.map((item, i) => (
<Box
key={i}
flex={'1 0 0'}
p={2}
borderRadius={'sm'}
border={theme.borders.base}
_notLast={{ mb: 2 }}
_hover={{ '& .hover-data': { display: 'flex' } }}
bg={i % 2 === 0 ? 'white' : 'myWhite.500'}
>
<QuoteItem quoteItem={item} canViewSource={showDetail} linkToDataset={showDetail} />
</Box>
))}
</>
);
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { ModalBody, ModalFooter, Button } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
const ReadFeedbackModal = ({
content,
onCloseFeedback,
onClose
}: {
content: string;
onCloseFeedback: () => void;
onClose: () => void;
}) => {
const { t } = useTranslation();
return (
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="/imgs/modal/readFeedback.svg"
title={t('core.chat.Feedback Modal')}
>
<ModalBody>{content}</ModalBody>
<ModalFooter>
<Button mr={2} onClick={onCloseFeedback}>
{t('core.chat.feedback.Feedback Close')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(ReadFeedbackModal);

View File

@@ -0,0 +1,222 @@
import React, { useMemo, useState } from 'react';
import { type ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { DispatchNodeResponseType } from '@fastgpt/global/core/workflow/runtime/type.d';
import { Flex, useDisclosure, useTheme, Box } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import dynamic from 'next/dynamic';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import ChatBoxDivider from '@/components/core/chat/Divider';
import { strIsLink } from '@fastgpt/global/common/string/tools';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const QuoteModal = dynamic(() => import('./QuoteModal'));
const ContextModal = dynamic(() => import('./ContextModal'));
const WholeResponseModal = dynamic(() => import('../../../components/WholeResponseModal'));
const isLLMNode = (item: ChatHistoryItemResType) =>
item.moduleType === FlowNodeTypeEnum.chatNode || item.moduleType === FlowNodeTypeEnum.tools;
const ResponseTags = ({
flowResponses = [],
showDetail
}: {
flowResponses?: ChatHistoryItemResType[];
showDetail: boolean;
}) => {
const theme = useTheme();
const { isPc } = useSystem();
const { t } = useTranslation();
const [quoteModalData, setQuoteModalData] = useState<{
rawSearch: SearchDataResponseItemType[];
metadata?: {
collectionId: string;
sourceId?: string;
sourceName: string;
};
}>();
const [contextModalData, setContextModalData] =
useState<DispatchNodeResponseType['historyPreview']>();
const {
isOpen: isOpenWholeModal,
onOpen: onOpenWholeModal,
onClose: onCloseWholeModal
} = useDisclosure();
const {
llmModuleAccount,
quoteList = [],
sourceList = [],
historyPreview = [],
runningTime = 0
} = useMemo(() => {
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]) {
acc[cur.collectionId] = [cur];
}
return acc;
},
{}
);
return {
llmModuleAccount: flatResponse.filter(isLLMNode).length,
quoteList,
sourceList: Object.values(sourceList)
.flat()
.map((item) => ({
sourceName: item.sourceName,
sourceId: item.sourceId,
icon: getSourceNameIcon({ sourceId: item.sourceId, sourceName: item.sourceName }),
canReadQuote: showDetail || strIsLink(item.sourceId),
collectionId: item.collectionId
})),
historyPreview: chatData?.historyPreview,
runningTime: +flowResponses.reduce((sum, item) => sum + (item.runningTime || 0), 0).toFixed(2)
};
}, [showDetail, flowResponses]);
return flowResponses.length === 0 ? null : (
<>
{sourceList.length > 0 && (
<>
<ChatBoxDivider icon="core/chat/quoteFill" text={t('core.chat.Quote')} />
<Flex alignItems={'center'} flexWrap={'wrap'} gap={2}>
{sourceList.map((item) => (
<MyTooltip key={item.collectionId} label={t('core.chat.quote.Read Quote')}>
<Flex
alignItems={'center'}
fontSize={'xs'}
border={theme.borders.sm}
py={1.5}
px={2}
borderRadius={'sm'}
_hover={{
'.controller': {
display: 'flex'
}
}}
overflow={'hidden'}
position={'relative'}
cursor={'pointer'}
onClick={(e) => {
e.stopPropagation();
setQuoteModalData({
rawSearch: quoteList,
metadata: {
collectionId: item.collectionId,
sourceId: item.sourceId,
sourceName: item.sourceName
}
});
}}
>
<MyIcon name={item.icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box className="textEllipsis3" wordBreak={'break-all'} flex={'1 0 0'}>
{item.sourceName}
</Box>
</Flex>
</MyTooltip>
))}
</Flex>
</>
)}
{showDetail && (
<Flex alignItems={'center'} mt={3} flexWrap={'wrap'} gap={2}>
{quoteList.length > 0 && (
<MyTooltip label="查看引用">
<MyTag
colorSchema="blue"
type="borderSolid"
cursor={'pointer'}
onClick={() => setQuoteModalData({ rawSearch: quoteList })}
>
{quoteList.length}
</MyTag>
</MyTooltip>
)}
{llmModuleAccount === 1 && (
<>
{historyPreview.length > 0 && (
<MyTooltip label={'点击查看上下文预览'}>
<MyTag
colorSchema="green"
cursor={'pointer'}
type="borderSolid"
onClick={() => setContextModalData(historyPreview)}
>
{historyPreview.length}
</MyTag>
</MyTooltip>
)}
</>
)}
{llmModuleAccount > 1 && (
<MyTag type="borderSolid" colorSchema="blue">
AI
</MyTag>
)}
{isPc && runningTime > 0 && (
<MyTooltip label={'模块运行时间和'}>
<MyTag colorSchema="purple" type="borderSolid" cursor={'default'}>
{runningTime}s
</MyTag>
</MyTooltip>
)}
<MyTooltip label={t('core.chat.response.Read complete response tips')}>
<MyTag
colorSchema="gray"
type="borderSolid"
cursor={'pointer'}
onClick={onOpenWholeModal}
>
{t('core.chat.response.Read complete response')}
</MyTag>
</MyTooltip>
</Flex>
)}
{!!quoteModalData && (
<QuoteModal
{...quoteModalData}
showDetail={showDetail}
onClose={() => setQuoteModalData(undefined)}
/>
)}
{!!contextModalData && (
<ContextModal context={contextModalData} onClose={() => setContextModalData(undefined)} />
)}
{isOpenWholeModal && (
<WholeResponseModal
response={flowResponses}
showDetail={showDetail}
onClose={onCloseWholeModal}
/>
)}
</>
);
};
export default React.memo(ResponseTags);

View File

@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { ModalBody, useTheme, ModalFooter, Button, Box, Card, Flex, Grid } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import Avatar from '@/components/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants';
import DatasetSelectModal, { useDatasetSelect } from '@/components/core/dataset/SelectModal';
import dynamic from 'next/dynamic';
import { AdminFbkType } from '@fastgpt/global/core/chat/type.d';
import SelectCollections from '@/web/core/dataset/components/SelectCollections';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
const InputDataModal = dynamic(() => import('@/pages/dataset/detail/components/InputDataModal'));
export type AdminMarkType = {
dataId?: string;
datasetId?: string;
collectionId?: string;
q: string;
a?: string;
};
const SelectMarkCollection = ({
adminMarkData,
setAdminMarkData,
onSuccess,
onClose
}: {
adminMarkData: AdminMarkType;
setAdminMarkData: (e: AdminMarkType) => void;
onClose: () => void;
onSuccess: (adminFeedback: AdminFbkType) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const { paths, setParentId, datasets, isFetching } = useDatasetSelect();
return (
<>
{/* select dataset */}
{!adminMarkData.datasetId && (
<DatasetSelectModal
isOpen
paths={paths}
onClose={onClose}
setParentId={setParentId}
isLoading={isFetching}
tips={t('core.chat.Select dataset Desc')}
>
<ModalBody flex={'1 0 0'} overflowY={'auto'}>
<Grid
display={'grid'}
gridTemplateColumns={['repeat(1,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']}
gridGap={3}
userSelect={'none'}
>
{datasets.map((item) =>
(() => {
return (
<Card
key={item._id}
p={3}
border={theme.borders.base}
boxShadow={'sm'}
h={'80px'}
cursor={'pointer'}
_hover={{
boxShadow: 'md'
}}
onClick={() => {
if (item.type === DatasetTypeEnum.folder) {
setParentId(item._id);
} else {
setAdminMarkData({ ...adminMarkData, datasetId: item._id });
}
}}
>
<Flex alignItems={'center'} h={'38px'}>
<Avatar src={item.avatar} w={['24px', '28px', '32px']}></Avatar>
<Box ml={3} fontWeight={'bold'} fontSize={['md', 'lg']}>
{item.name}
</Box>
</Flex>
<Flex justifyContent={'flex-end'} alignItems={'center'} fontSize={'sm'}>
<MyIcon mr={1} name="kbTest" w={'12px'} />
<Box color={'myGray.500'}>{item.vectorModel.name}</Box>
</Flex>
</Card>
);
})()
)}
</Grid>
{datasets.length === 0 && <EmptyTip text={'这个目录已经没东西可选了~'}></EmptyTip>}
</ModalBody>
</DatasetSelectModal>
)}
{/* select collection */}
{adminMarkData.datasetId && (
<SelectCollections
datasetId={adminMarkData.datasetId}
type={'collection'}
title={t('dataset.collections.Select One Collection To Store')}
onClose={onClose}
onChange={({ collectionIds }) => {
setAdminMarkData({
...adminMarkData,
collectionId: collectionIds[0]
});
}}
CustomFooter={
<ModalFooter>
<Button
variant={'whiteBase'}
mr={2}
onClick={() => {
setAdminMarkData({
...adminMarkData,
datasetId: undefined
});
}}
>
{t('common.Last Step')}
</Button>
</ModalFooter>
}
/>
)}
{/* input data */}
{adminMarkData.datasetId && adminMarkData.collectionId && (
<InputDataModal
onClose={() => {
setAdminMarkData({
...adminMarkData,
collectionId: undefined
});
}}
collectionId={adminMarkData.collectionId}
dataId={adminMarkData.dataId}
defaultValue={{
q: adminMarkData.q,
a: adminMarkData.a
}}
onSuccess={(data) => {
if (
!data.q ||
!adminMarkData.datasetId ||
!adminMarkData.collectionId ||
!data.dataId
) {
return onClose();
}
onSuccess({
dataId: data.dataId,
datasetId: adminMarkData.datasetId,
collectionId: adminMarkData.collectionId,
q: data.q,
a: data.a
});
onClose();
}}
/>
)}
</>
);
};
export default React.memo(SelectMarkCollection);

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Controller, UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { Box, Button, Card, FormControl, Input, Textarea } from '@chakra-ui/react';
import ChatAvatar from './ChatAvatar';
import { MessageCardStyle } from '../constants';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import MySelect from '@fastgpt/web/components/common/MySelect';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { ChatBoxInputFormType } from '../type.d';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
const VariableInput = ({
chatForm,
chatStarted
}: {
chatStarted: boolean;
chatForm: UseFormReturn<ChatBoxInputFormType>;
}) => {
const { t } = useTranslation();
const { appAvatar, variableList, variablesForm } = useContextSelector(ChatBoxContext, (v) => v);
const { register, getValues, setValue, handleSubmit: handleSubmitChat, control } = variablesForm;
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)'}
>
{variableList.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
bg={'myWhite.400'}
{...register(item.key, {
required: item.required
})}
/>
)}
{item.type === VariableInputEnum.textarea && (
<Textarea
bg={'myWhite.400'}
{...register(item.key, {
required: item.required
})}
rows={5}
maxLength={4000}
/>
)}
{item.type === VariableInputEnum.select && (
<Controller
key={item.key}
control={control}
name={item.key}
rules={{ required: item.required }}
render={({ field: { ref, value } }) => {
return (
<MySelect
ref={ref}
width={'100%'}
list={(item.enums || []).map((item) => ({
label: item.value,
value: item.value
}))}
value={value}
onchange={(e) => setValue(item.key, e)}
/>
);
}}
/>
)}
</Box>
))}
{!chatStarted && (
<Button
leftIcon={<MyIcon name={'core/chat/chatFill'} w={'16px'} />}
size={'sm'}
maxW={'100px'}
onClick={handleSubmitChat(() => {
chatForm.setValue('chatStarted', true);
})}
>
{t('core.chat.Start Chat')}
</Button>
)}
</Card>
</Box>
</Box>
);
};
export default VariableInput;

View File

@@ -0,0 +1,32 @@
import { Box, Card } from '@chakra-ui/react';
import React from 'react';
import { MessageCardStyle } from '../constants';
import Markdown from '@/components/Markdown';
import ChatAvatar from './ChatAvatar';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
const WelcomeBox = ({ welcomeText }: { welcomeText: string }) => {
const appAvatar = useContextSelector(ChatBoxContext, (v) => v.appAvatar);
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}`} />
</Card>
</Box>
</Box>
);
};
export default WelcomeBox;

View File

@@ -0,0 +1,19 @@
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'
};
export enum FeedbackTypeEnum {
user = 'user',
admin = 'admin',
hidden = 'hidden'
}

View File

@@ -0,0 +1,100 @@
import { ExportChatType } from '@/types/chat';
import { ChatItemType } from '@fastgpt/global/core/chat/type';
import { useCallback } from 'react';
import { htmlTemplate } from '@/web/core/chat/constants';
import { fileDownload } from '@/web/common/file/utils';
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
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) => {
let result = `Role: ${item.obj}\n`;
const content = item.value.map((item) => {
if (item.type === ChatItemValueTypeEnum.text) {
return item.text?.content;
} else if (item.type === ChatItemValueTypeEnum.file) {
return `
![${item.file?.name}](${item.file?.url})
`;
} else if (item.type === ChatItemValueTypeEnum.tool) {
return `
\`\`\`Toll
${JSON.stringify(item.tools, null, 2)}
\`\`\`
`;
}
});
return result + content;
})
.join('\n\n-------\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
};
};

View File

@@ -0,0 +1,12 @@
.statusAnimation {
animation: statusBox 0.8s linear infinite alternate;
}
@keyframes statusBox {
0% {
opacity: 1;
}
100% {
opacity: 0.11;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
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/workflow/runtime/constants';
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[];
chatStarted: boolean;
};
export type ChatBoxInputType = {
text?: string;
files?: UserInputFileItemType[];
};
export type ComponentRef = {
restartChat: () => void;
scrollToBottom: (behavior?: 'smooth' | 'auto') => void;
sendPrompt: (question: string) => void;
};

View File

@@ -0,0 +1,37 @@
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 (!value) {
return { text: '', files: [] };
}
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

@@ -0,0 +1,69 @@
import React from 'react';
import { Controller } from 'react-hook-form';
import RenderPluginInput from './renderPluginInput';
import { Button, Flex } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useContextSelector } from 'use-context-selector';
import { PluginRunContext } from '../context';
const RenderInput = () => {
const { pluginInputs, variablesForm, histories, onStartChat, onNewChat, onSubmit, isChatting } =
useContextSelector(PluginRunContext, (v) => v);
const { t } = useTranslation();
const {
control,
handleSubmit,
formState: { errors }
} = variablesForm;
const isDisabledInput = histories.length > 0;
return (
<>
{pluginInputs.map((input) => {
return (
<Controller
key={input.key}
control={control}
name={input.key}
rules={{ required: input.required }}
render={({ field: { onChange, value } }) => {
return (
<RenderPluginInput
value={value}
onChange={onChange}
label={input.label}
description={input.description}
isDisabled={isDisabledInput}
valueType={input.valueType}
placeholder={input.placeholder}
required={input.required}
min={input.min}
max={input.max}
isInvalid={errors && Object.keys(errors).includes(input.key)}
/>
);
}}
/>
);
})}
{onStartChat && onNewChat && (
<Flex justifyContent={'end'} mt={8}>
<Button
isLoading={isChatting}
onClick={() => {
if (histories.length > 0) {
return onNewChat();
}
handleSubmit(onSubmit)();
}}
>
{histories.length > 0 ? t('common.Restart') : t('common.Run')}
</Button>
</Flex>
)}
</>
);
};
export default RenderInput;

View File

@@ -0,0 +1,59 @@
import { Box } from '@chakra-ui/react';
import React, { useMemo } from 'react';
import { useContextSelector } from 'use-context-selector';
import { PluginRunContext } from '../context';
import Markdown from '@/components/Markdown';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import AIResponseBox from '../../../components/AIResponseBox';
const RenderOutput = () => {
const { histories, isChatting } = useContextSelector(PluginRunContext, (v) => v);
const pluginOutputs = useMemo(() => {
const pluginOutputs = histories?.[1]?.responseData?.find(
(item) => item.moduleType === FlowNodeTypeEnum.pluginOutput
)?.pluginOutput;
return JSON.stringify(pluginOutputs, null, 2);
}, [histories]);
return (
<>
<Box border={'base'} rounded={'md'} bg={'myGray.25'}>
<Box p={4} color={'myGray.900'}>
<Box color={'myGray.900'} fontWeight={'bold'}>
</Box>
{histories.length > 0 && histories[1]?.value.length > 0 ? (
<Box mt={2}>
{histories[1].value.map((value, i) => {
const key = `${histories[1].dataId}-ai-${i}`;
return (
<AIResponseBox
key={key}
value={value}
index={i}
chat={histories[1]}
isLastChild={true}
isChatting={isChatting}
questionGuides={[]}
/>
);
})}
</Box>
) : null}
</Box>
</Box>
<Box border={'base'} mt={4} rounded={'md'} bg={'myGray.25'}>
<Box p={4} color={'myGray.900'} fontWeight={'bold'}>
<Box></Box>
{histories.length > 0 && histories[1].responseData ? (
<Markdown source={`~~~json\n${pluginOutputs}`} />
) : null}
</Box>
</Box>
</>
);
};
export default RenderOutput;

View File

@@ -0,0 +1,21 @@
import { ResponseBox } from '../../../components/WholeResponseModal';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { PluginRunContext } from '../context';
import { Box } from '@chakra-ui/react';
const RenderResponseDetail = () => {
const { histories, isChatting } = useContextSelector(PluginRunContext, (v) => v);
const responseData = histories?.[1]?.responseData || [];
return isChatting ? (
<>{'进行中'}</>
) : (
<Box flex={'1 0 0'} h={'100%'} overflow={'auto'}>
<ResponseBox response={responseData} showDetail={true} />
</Box>
);
};
export default RenderResponseDetail;

View File

@@ -0,0 +1,118 @@
import {
Box,
Flex,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Switch,
Textarea
} from '@chakra-ui/react';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
const JsonEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/JsonEditor'));
const RenderPluginInput = ({
value,
onChange,
label,
description,
isDisabled,
valueType,
placeholder,
required,
min,
max,
isInvalid
}: {
value: any;
onChange: () => void;
label: string;
description?: string;
isDisabled?: boolean;
valueType: WorkflowIOValueTypeEnum | undefined;
placeholder?: string;
required?: boolean;
min?: number;
max?: number;
isInvalid: boolean;
}) => {
const { t } = useTranslation();
const render = (() => {
if (valueType === WorkflowIOValueTypeEnum.string) {
return (
<Textarea
value={value}
onChange={onChange}
isDisabled={isDisabled}
placeholder={t(placeholder)}
bg={'myGray.50'}
isInvalid={isInvalid}
/>
);
}
if (valueType === WorkflowIOValueTypeEnum.number) {
return (
<NumberInput
step={1}
min={min}
max={max}
bg={'myGray.50'}
isDisabled={isDisabled}
isInvalid={isInvalid}
>
<NumberInputField value={value} onChange={onChange} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
);
}
if (valueType === WorkflowIOValueTypeEnum.boolean) {
return (
<Switch
isChecked={value}
onChange={onChange}
isDisabled={isDisabled}
isInvalid={isInvalid}
/>
);
}
return (
<JsonEditor
bg={'myGray.50'}
placeholder={t(placeholder || '')}
resize
value={value}
onChange={onChange}
isInvalid={isInvalid}
/>
);
})();
return !!render ? (
<Box _notLast={{ mb: 4 }} px={1}>
<Flex alignItems={'center'} mb={1}>
<Box position={'relative'}>
{required && (
<Box position={'absolute'} left={-2} top={'-1px'} color={'red.600'}>
*
</Box>
)}
{label}
</Box>
{description && <QuestionTip ml={2} label={description} />}
</Flex>
{render}
</Box>
) : null;
};
export default RenderPluginInput;

View File

@@ -0,0 +1,5 @@
export enum PluginRunBoxTabEnum {
input = 'input',
output = 'output',
detail = 'detail'
}

View File

@@ -0,0 +1,234 @@
import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { createContext } from 'use-context-selector';
import { PluginRunBoxProps } from './type';
import { AIChatItemValueItemType, ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import { FieldValues } from 'react-hook-form';
import { PluginRunBoxTabEnum } from './constants';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { generatingMessageProps } from '../type';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { getPluginRunContent } from '@fastgpt/global/core/app/plugin/utils';
type PluginRunContextType = PluginRunBoxProps & {
isChatting: boolean;
onSubmit: (e: FieldValues) => Promise<any>;
};
export const PluginRunContext = createContext<PluginRunContextType>({
pluginInputs: [],
//@ts-ignore
variablesForm: undefined,
histories: [],
setHistories: function (value: React.SetStateAction<ChatSiteItemType[]>): void {
throw new Error('Function not implemented.');
},
appId: '',
tab: PluginRunBoxTabEnum.input,
setTab: function (value: React.SetStateAction<PluginRunBoxTabEnum>): void {
throw new Error('Function not implemented.');
},
isChatting: false,
onSubmit: function (e: FieldValues): Promise<any> {
throw new Error('Function not implemented.');
}
});
const PluginRunContextProvider = ({
children,
...props
}: PluginRunBoxProps & { children: ReactNode }) => {
const { pluginInputs, onStartChat, setHistories, histories, setTab } = props;
const { toast } = useToast();
const chatController = useRef(new AbortController());
/* Abort chat completions, questionGuide */
const abortRequest = useCallback(() => {
chatController.current?.abort('stop');
}, []);
const generatingMessage = useCallback(
({ event, text = '', status, name, tool, variables }: generatingMessageProps) => {
setHistories((state) =>
state.map((item, index) => {
if (index !== state.length - 1 || item.obj !== ChatRoleEnum.AI) return item;
const lastValue: AIChatItemValueItemType = JSON.parse(
JSON.stringify(item.value[item.value.length - 1])
);
if (event === SseResponseEventEnum.flowNodeStatus && status) {
return {
...item,
status,
moduleName: name
};
} else if (
(event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) &&
text
) {
if (!lastValue || !lastValue.text) {
const newValue: AIChatItemValueItemType = {
type: ChatItemValueTypeEnum.text,
text: {
content: text
}
};
return {
...item,
value: item.value.concat(newValue)
};
} else {
lastValue.text.content += text;
return {
...item,
value: item.value.slice(0, -1).concat(lastValue)
};
}
} else if (event === SseResponseEventEnum.toolCall && tool) {
const val: AIChatItemValueItemType = {
type: ChatItemValueTypeEnum.tool,
tools: [tool]
};
return {
...item,
value: item.value.concat(val)
};
} else if (
event === SseResponseEventEnum.toolParams &&
tool &&
lastValue.type === ChatItemValueTypeEnum.tool &&
lastValue?.tools
) {
lastValue.tools = lastValue.tools.map((item) => {
if (item.id === tool.id) {
item.params += tool.params;
}
return item;
});
return {
...item,
value: item.value.slice(0, -1).concat(lastValue)
};
} else if (event === SseResponseEventEnum.toolResponse && tool) {
// replace tool response
return {
...item,
value: item.value.map((val) => {
if (val.type === ChatItemValueTypeEnum.tool && val.tools) {
const tools = val.tools.map((item) =>
item.id === tool.id ? { ...item, response: tool.response } : item
);
return {
...val,
tools
};
}
return val;
})
};
}
return item;
})
);
},
[setHistories]
);
const isChatting = useMemo(
() => histories[histories.length - 1] && histories[histories.length - 1]?.status !== 'finish',
[histories]
);
const { runAsync: onSubmit } = useRequest2(async (e: FieldValues) => {
if (!onStartChat) return;
if (isChatting) {
toast({
title: '正在聊天中...请等待结束',
status: 'warning'
});
return;
}
setTab(PluginRunBoxTabEnum.output);
// reset controller
abortRequest();
const abortSignal = new AbortController();
chatController.current = abortSignal;
setHistories([
{
dataId: getNanoid(24),
obj: ChatRoleEnum.Human,
status: 'finish',
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: getPluginRunContent({
pluginInputs
})
}
}
]
},
{
dataId: getNanoid(24),
obj: ChatRoleEnum.AI,
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: ''
}
}
],
status: 'loading'
}
]);
try {
const { responseData } = await onStartChat({
messages: [],
controller: chatController.current,
generatingMessage,
variables: e
});
setHistories((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: 'finish',
responseData
};
})
);
} catch (err: any) {
toast({ title: err.message, status: 'error' });
setHistories((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: 'finish'
};
})
);
}
});
const contextValue: PluginRunContextType = {
...props,
isChatting,
onSubmit
};
return <PluginRunContext.Provider value={contextValue}>{children}</PluginRunContext.Provider>;
};
export default PluginRunContextProvider;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { PluginRunBoxTabEnum } from './constants';
import { PluginRunBoxProps } from './type';
import RenderInput from './components/RenderInput';
import PluginRunContextProvider, { PluginRunContext } from './context';
import { useContextSelector } from 'use-context-selector';
import RenderOutput from './components/RenderOutput';
import RenderResponseDetail from './components/RenderResponseDetail';
const PluginRunBox = () => {
const { tab } = useContextSelector(PluginRunContext, (v) => v);
return (
<>
{tab === PluginRunBoxTabEnum.input && <RenderInput />}
{tab === PluginRunBoxTabEnum.output && <RenderOutput />}
{tab === PluginRunBoxTabEnum.detail && <RenderResponseDetail />}
</>
);
};
const Render = (props: PluginRunBoxProps) => {
return (
<PluginRunContextProvider {...props}>
<PluginRunBox />
</PluginRunContextProvider>
);
};
export default Render;

View File

@@ -0,0 +1,22 @@
import { ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { FieldValues, UseFormReturn } from 'react-hook-form';
import { PluginRunBoxTabEnum } from './constants';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import React from 'react';
import { onStartChatType } from '../type';
export type PluginRunBoxProps = OutLinkChatAuthProps & {
pluginInputs: FlowNodeInputItemType[];
variablesForm: UseFormReturn<FieldValues, any>;
histories: ChatSiteItemType[]; // chatHistories[1] is the response
setHistories: React.Dispatch<React.SetStateAction<ChatSiteItemType[]>>;
onStartChat?: onStartChatType;
onNewChat?: () => void;
appId: string;
chatId?: string;
tab: PluginRunBoxTabEnum;
setTab: React.Dispatch<React.SetStateAction<PluginRunBoxTabEnum>>;
};

View File

@@ -0,0 +1,26 @@
import { StreamResponseType } from '@/web/common/api/fetch';
import { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import { ChatSiteItemType, ToolModuleResponseItemType } from '@fastgpt/global/core/chat/type';
export type generatingMessageProps = {
event: SseResponseEventEnum;
text?: string;
name?: string;
status?: 'running' | 'finish';
tool?: ToolModuleResponseItemType;
variables?: Record<string, any>;
};
export type StartChatFnProps = {
messages: ChatCompletionMessageParam[];
responseChatItemId?: string;
controller: AbortController;
variables: Record<string, any>;
generatingMessage: (e: generatingMessageProps) => void;
};
export type onStartChatType = (e: StartChatFnProps) => Promise<
StreamResponseType & {
isNewChat?: boolean;
}
>;

View File

@@ -0,0 +1,63 @@
import { ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import { useCallback, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { PluginRunBoxTabEnum } from './PluginRunBox/constants';
import { ComponentRef as ChatComponentRef } from './ChatBox/type';
export const useChat = () => {
const ChatBoxRef = useRef<ChatComponentRef>(null);
const [chatRecords, setChatRecords] = useState<ChatSiteItemType[]>([]);
const variablesForm = useForm();
// plugin
const [pluginRunTab, setPluginRunTab] = useState<PluginRunBoxTabEnum>(PluginRunBoxTabEnum.input);
const resetChatRecords = useCallback(
(props?: { records?: ChatSiteItemType[]; variables?: Record<string, any> }) => {
const { records = [], variables = {} } = props || {};
setChatRecords(records);
// Reset to empty input
const data = variablesForm.getValues();
for (const key in data) {
data[key] = '';
}
variablesForm.reset({
...data,
...variables
});
setTimeout(
() => {
ChatBoxRef.current?.restartChat?.();
},
ChatBoxRef.current?.restartChat ? 0 : 500
);
},
[variablesForm, setChatRecords]
);
const clearChatRecords = useCallback(() => {
setChatRecords([]);
const data = variablesForm.getValues();
for (const key in data) {
variablesForm.setValue(key, '');
}
console.log(ChatBoxRef.current);
ChatBoxRef.current?.restartChat?.();
}, [variablesForm]);
return {
ChatBoxRef,
chatRecords,
setChatRecords,
variablesForm,
pluginRunTab,
setPluginRunTab,
clearChatRecords,
resetChatRecords
};
};

View File

@@ -0,0 +1,127 @@
import Markdown, { CodeClassName } from '@/components/Markdown';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box
} from '@chakra-ui/react';
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
import {
AIChatItemValueItemType,
ChatSiteItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@/components/Avatar';
type props = {
value: UserChatItemValueItemType | AIChatItemValueItemType;
index: number;
chat: ChatSiteItemType;
isLastChild: boolean;
isChatting: boolean;
questionGuides: string[];
};
const AIResponseBox = ({ value, index, chat, isLastChild, isChatting, questionGuides }: props) => {
if (value.text) {
let source = (value.text?.content || '').trim();
// First empty line
if (!source && chat.value.length > 1) return null;
// computed question guide
if (
isLastChild &&
!isChatting &&
questionGuides.length > 0 &&
index === chat.value.length - 1
) {
source = `${source}
\`\`\`${CodeClassName.questionGuide}
${JSON.stringify(questionGuides)}`;
}
return (
<Markdown
source={source}
showAnimation={isLastChild && isChatting && index === chat.value.length - 1}
/>
);
}
if (value.type === ChatItemValueTypeEnum.tool && value.tools) {
return (
<Box>
{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 (
<Accordion key={tool.id} allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton
w={'auto'}
bg={'white'}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'1'}
_hover={{
bg: 'auto'
}}
>
<Avatar src={tool.toolAvatar} w={'1rem'} h={'1rem'} mr={2} />
<Box mr={1} fontSize={'sm'}>
{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 && toolParams !== '{}' && (
<Markdown
source={`~~~json#Input
${toolParams}`}
/>
)}
{toolResponse && (
<Markdown
source={`~~~json#Response
${toolResponse}`}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
);
})}
</Box>
);
}
return null;
};
export default React.memo(AIResponseBox);

View File

@@ -0,0 +1,347 @@
import React, { useMemo, useState } from 'react';
import { Box, useTheme, Flex, Image, BoxProps } from '@chakra-ui/react';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { useTranslation } from 'next-i18next';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import MyModal from '@fastgpt/web/components/common/MyModal';
import Markdown from '@/components/Markdown';
import { QuoteList } from '../ChatContainer/ChatBox/components/QuoteModal';
import { DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import { useI18n } from '@/web/context/I18n';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
function RowRender({
children,
mb,
label,
...props
}: { children: React.ReactNode; label: string } & BoxProps) {
return (
<Box mb={3}>
<Box fontSize={'sm'} mb={mb} flex={'0 0 90px'}>
{label}:
</Box>
<Box borderRadius={'sm'} fontSize={['xs', 'sm']} bg={'myGray.50'} {...props}>
{children}
</Box>
</Box>
);
}
function Row({
label,
value,
rawDom
}: {
label: string;
value?: string | number | boolean | object;
rawDom?: React.ReactNode;
}) {
const theme = useTheme();
const val = value || rawDom;
const isObject = typeof value === 'object';
const formatValue = useMemo(() => {
if (isObject) {
return `~~~json\n${JSON.stringify(value, null, 2)}`;
}
return `${value}`;
}, [isObject, value]);
if (rawDom) {
return (
<RowRender label={label} mb={1}>
{rawDom}
</RowRender>
);
}
if (val === undefined || val === '' || val === 'undefined') return null;
return (
<RowRender
label={label}
mb={isObject ? 0 : 1}
{...(isObject
? { transform: 'translateY(-3px)' }
: value
? { px: 3, py: 2, border: theme.borders.base }
: {})}
>
<Markdown source={formatValue} />
</RowRender>
);
}
const WholeResponseModal = ({
response,
showDetail,
onClose
}: {
response: ChatHistoryItemResType[];
showDetail: boolean;
onClose: () => void;
}) => {
const { t } = useTranslation();
return (
<MyModal
isCentered
isOpen={true}
onClose={onClose}
h={['90vh', '80vh']}
minW={['90vw', '600px']}
iconSrc="/imgs/modal/wholeRecord.svg"
title={
<Flex alignItems={'center'}>
{t('core.chat.response.Complete Response')}
<QuestionTip ml={2} label={'从左往右,为各个模块的响应顺序'}></QuestionTip>
</Flex>
}
>
<ResponseBox response={response} showDetail={showDetail} />
</MyModal>
);
};
export default WholeResponseModal;
export const ResponseBox = React.memo(function ResponseBox({
response,
showDetail,
hideTabs = false
}: {
response: ChatHistoryItemResType[];
showDetail: boolean;
hideTabs?: boolean;
}) {
const theme = useTheme();
const { t } = useTranslation();
const { workflowT } = useI18n();
const list = useMemo(
() =>
response.map((item, i) => ({
label: (
<Flex alignItems={'center'} justifyContent={'center'} px={2}>
<Image
mr={2}
src={
item.moduleLogo ||
moduleTemplatesFlat.find((template) => item.moduleType === template.flowNodeType)
?.avatar
}
alt={''}
w={['14px', '16px']}
/>
{t(item.moduleName)}
</Flex>
),
value: `${i}`
})),
[response, t]
);
const [currentTab, setCurrentTab] = useState(`0`);
const activeModule = useMemo(() => response[Number(currentTab)], [currentTab, response]);
return (
<Box
{...(hideTabs ? { overflow: 'auto' } : { display: 'flex', flexDirection: 'column' })}
h={'100%'}
>
{!hideTabs && (
<Box>
<LightRowTabs
list={list}
value={currentTab}
inlineStyles={{ pt: 0 }}
onChange={setCurrentTab}
/>
</Box>
)}
{activeModule && (
<Box
py={2}
px={4}
{...(hideTabs
? {}
: {
flex: '1 0 0',
overflow: 'auto'
})}
>
<>
<Row label={t('core.chat.response.module name')} value={t(activeModule.moduleName)} />
{activeModule?.totalPoints !== undefined && (
<Row
label={t('support.wallet.usage.Total points')}
value={formatNumber(activeModule.totalPoints)}
/>
)}
<Row
label={t('core.chat.response.module time')}
value={`${activeModule?.runningTime || 0}s`}
/>
<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')}
value={activeModule?.contextTotalLen}
/>
<Row label={workflowT('response.Error')} value={activeModule?.error} />
</>
{/* ai chat */}
<>
<Row
label={t('core.chat.response.module temperature')}
value={activeModule?.temperature}
/>
<Row label={t('core.chat.response.module maxToken')} value={activeModule?.maxToken} />
<Row
label={t('core.chat.response.module historyPreview')}
rawDom={
activeModule.historyPreview ? (
<Box px={3} py={2} border={theme.borders.base} borderRadius={'md'}>
{activeModule.historyPreview?.map((item, i) => (
<Box
key={i}
_notLast={{
borderBottom: '1px solid',
borderBottomColor: 'myWhite.700',
mb: 2
}}
pb={2}
>
<Box fontWeight={'bold'}>{item.obj}</Box>
<Box whiteSpace={'pre-wrap'}>{item.value}</Box>
</Box>
))}
</Box>
) : (
''
)
}
/>
</>
{/* dataset search */}
<>
{activeModule?.searchMode && (
<Row
label={t('core.dataset.search.search mode')}
// @ts-ignore
value={t(DatasetSearchModeMap[activeModule.searchMode]?.title)}
/>
)}
<Row
label={t('core.chat.response.module similarity')}
value={activeModule?.similarity}
/>
<Row label={t('core.chat.response.module limit')} value={activeModule?.limit} />
<Row
label={t('core.chat.response.search using reRank')}
value={`${activeModule?.searchUsingReRank}`}
/>
<Row
label={t('core.chat.response.Extension model')}
value={activeModule?.extensionModel}
/>
<Row
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 */}
<>
<Row label={t('core.chat.response.module cq result')} value={activeModule?.cqResult} />
<Row
label={t('core.chat.response.module cq')}
value={(() => {
if (!activeModule?.cqList) return '';
return activeModule.cqList.map((item) => `* ${item.value}`).join('\n');
})()}
/>
</>
{/* if-else */}
<>
<Row
label={t('core.chat.response.module if else Result')}
value={activeModule?.ifElseResult}
/>
</>
{/* extract */}
<>
<Row
label={t('core.chat.response.module extract description')}
value={activeModule?.extractDescription}
/>
<Row
label={t('core.chat.response.module extract result')}
value={activeModule?.extractResult}
/>
</>
{/* http */}
<>
<Row label={'Headers'} value={activeModule?.headers} />
<Row label={'Params'} value={activeModule?.params} />
<Row label={'Body'} value={activeModule?.body} />
<Row
label={t('core.chat.response.module http result')}
value={activeModule?.httpResult}
/>
</>
{/* plugin */}
<>
<Row label={t('core.chat.response.plugin output')} value={activeModule?.pluginOutput} />
{activeModule?.pluginDetail && activeModule?.pluginDetail.length > 0 && (
<Row
label={t('core.chat.response.Plugin response detail')}
rawDom={
<ResponseBox response={activeModule.pluginDetail} showDetail={showDetail} />
}
/>
)}
</>
{/* 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} />}
/>
)}
{/* code */}
<Row label={workflowT('response.Custom outputs')} value={activeModule?.customOutputs} />
<Row label={workflowT('response.Custom inputs')} value={activeModule?.customInputs} />
<Row label={workflowT('response.Code log')} value={activeModule?.codeLog} />
</Box>
)}
</Box>
);
});