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:
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 `
|
||||

|
||||
`;
|
||||
} 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
|
||||
};
|
||||
};
|
||||
@@ -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
34
projects/app/src/components/core/chat/ChatContainer/ChatBox/type.d.ts
vendored
Normal file
34
projects/app/src/components/core/chat/ChatContainer/ChatBox/type.d.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum PluginRunBoxTabEnum {
|
||||
input = 'input',
|
||||
output = 'output',
|
||||
detail = 'detail'
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
22
projects/app/src/components/core/chat/ChatContainer/PluginRunBox/type.d.ts
vendored
Normal file
22
projects/app/src/components/core/chat/ChatContainer/PluginRunBox/type.d.ts
vendored
Normal 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>>;
|
||||
};
|
||||
26
projects/app/src/components/core/chat/ChatContainer/type.d.ts
vendored
Normal file
26
projects/app/src/components/core/chat/ChatContainer/type.d.ts
vendored
Normal 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;
|
||||
}
|
||||
>;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user