FastGPT/projects/app/src/components/ChatBox/index.tsx
2024-01-10 23:35:04 +08:00

1376 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {
useCallback,
useRef,
useState,
useMemo,
forwardRef,
useImperativeHandle,
ForwardedRef,
useEffect
} from 'react';
import Script from 'next/script';
import { throttle } from 'lodash';
import type { ExportChatType } from '@/types/chat.d';
import type { ChatItemType, ChatSiteItemType } from '@fastgpt/global/core/chat/type.d';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d';
import { useToast } from '@/web/common/hooks/useToast';
import { useAudioPlay } from '@/web/common/utils/voice';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import {
Box,
Card,
Flex,
Input,
Button,
useTheme,
BoxProps,
FlexProps,
Image,
Textarea,
Checkbox
} from '@chakra-ui/react';
import { feConfigs } from '@/web/common/system/staticData';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import { adaptChat2GptMessages } from '@fastgpt/global/core/chat/adapt';
import { useMarkdown } from '@/web/common/hooks/useMarkdown';
import { ModuleItemType } from '@fastgpt/global/core/module/type.d';
import { VariableInputEnum } from '@fastgpt/global/core/module/constants';
import { useForm } from 'react-hook-form';
import type { ChatMessageItemType } from '@fastgpt/global/core/ai/type.d';
import { fileDownload } from '@/web/common/file/utils';
import { htmlTemplate } from '@/constants/common';
import { useRouter } from 'next/router';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next';
import { customAlphabet } from 'nanoid';
import {
closeCustomFeedback,
updateChatAdminFeedback,
updateChatUserFeedback
} from '@/web/core/chat/api';
import type { AdminMarkType } from './SelectMarkCollection';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@/components/Avatar';
import Markdown, { CodeClassName } from '@/components/Markdown';
import MySelect from '@/components/Select';
import MyTooltip from '../MyTooltip';
import dynamic from 'next/dynamic';
const ResponseTags = dynamic(() => import('./ResponseTags'));
const FeedbackModal = dynamic(() => import('./FeedbackModal'));
const ReadFeedbackModal = dynamic(() => import('./ReadFeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./SelectMarkCollection'));
import styles from './index.module.scss';
import { postQuestionGuide } from '@/web/core/ai/api';
import { splitGuideModule } from '@fastgpt/global/core/module/utils';
import type { AppTTSConfigType } from '@fastgpt/global/core/module/type.d';
import MessageInput from './MessageInput';
import { ModuleOutputKeyEnum } from '@fastgpt/global/core/module/constants';
import ChatBoxDivider from '../core/chat/Divider';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 24);
const textareaMinH = '22px';
type generatingMessageProps = { text?: string; name?: string; status?: 'running' | 'finish' };
export type StartChatFnProps = {
chatList: ChatSiteItemType[];
messages: ChatMessageItemType[];
controller: AbortController;
variables: Record<string, any>;
generatingMessage: (e: generatingMessageProps) => void;
};
export type ComponentRef = {
getChatHistories: () => ChatSiteItemType[];
resetVariables: (data?: Record<string, any>) => void;
resetHistory: (history: ChatSiteItemType[]) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto') => void;
sendPrompt: (question: string) => void;
};
enum FeedbackTypeEnum {
user = 'user',
admin = 'admin',
hidden = 'hidden'
}
type Props = {
feedbackType?: `${FeedbackTypeEnum}`;
showMarkIcon?: boolean; // admin mark dataset
showVoiceIcon?: boolean;
showEmptyIntro?: boolean;
appAvatar?: string;
userAvatar?: string;
userGuideModule?: ModuleItemType;
showFileSelector?: boolean;
active?: boolean; // can use
// not chat test params
appId?: string;
chatId?: string;
shareId?: string;
outLinkUid?: string;
onUpdateVariable?: (e: Record<string, any>) => void;
onStartChat?: (e: StartChatFnProps) => Promise<{
responseText: string;
[ModuleOutputKeyEnum.responseData]: ChatHistoryItemResType[];
isNewChat?: boolean;
}>;
onDelMessage?: (e: { contentId?: string; index: number }) => void;
};
const ChatBox = (
{
feedbackType = FeedbackTypeEnum.hidden,
showMarkIcon = false,
showVoiceIcon = true,
showEmptyIntro = false,
appAvatar,
userAvatar,
userGuideModule,
showFileSelector,
active = true,
appId,
chatId,
shareId,
outLinkUid,
onUpdateVariable,
onStartChat,
onDelMessage
}: Props,
ref: ForwardedRef<ComponentRef>
) => {
const ChatBoxRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const router = useRouter();
const { t } = useTranslation();
const { toast } = useToast();
const { isPc, setLoading } = useSystemStore();
const TextareaDom = useRef<HTMLTextAreaElement>(null);
const chatController = useRef(new AbortController());
const questionGuideController = useRef(new AbortController());
const isNewChatReplace = useRef(false);
const [refresh, setRefresh] = useState(false);
const [variables, setVariables] = useState<Record<string, any>>({}); // settings variable
const [chatHistory, setChatHistory] = useState<ChatSiteItemType[]>([]);
const [feedbackId, setFeedbackId] = useState<string>();
const [readFeedbackData, setReadFeedbackData] = useState<{
chatItemId: string;
content: string;
}>();
const [adminMarkData, setAdminMarkData] = useState<AdminMarkType & { chatItemId: string }>();
const [questionGuides, setQuestionGuide] = useState<string[]>([]);
const isChatting = useMemo(
() =>
chatHistory[chatHistory.length - 1] &&
chatHistory[chatHistory.length - 1]?.status !== 'finish',
[chatHistory]
);
const { welcomeText, variableModules, questionGuide, ttsConfig } = useMemo(
() => splitGuideModule(userGuideModule),
[userGuideModule]
);
// compute variable input is finish.
const [variableInputFinish, setVariableInputFinish] = useState(false);
const variableIsFinish = useMemo(() => {
if (!variableModules || variableModules.length === 0 || chatHistory.length > 0) return true;
for (let i = 0; i < variableModules.length; i++) {
const item = variableModules[i];
if (item.required && !variables[item.key]) {
return false;
}
}
return variableInputFinish;
}, [chatHistory.length, variableInputFinish, variableModules, variables]);
const { register, reset, getValues, setValue, handleSubmit } = useForm<Record<string, any>>({
defaultValues: variables
});
// 滚动到底部
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'auto' = 'smooth') => {
if (!ChatBoxRef.current) return;
ChatBoxRef.current.scrollTo({
top: ChatBoxRef.current.scrollHeight,
behavior
});
},
[ChatBoxRef]
);
// 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部
const generatingScroll = useCallback(
throttle(() => {
if (!ChatBoxRef.current) return;
const isBottom =
ChatBoxRef.current.scrollTop + ChatBoxRef.current.clientHeight + 150 >=
ChatBoxRef.current.scrollHeight;
isBottom && scrollToBottom('auto');
}, 100),
[]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const generatingMessage = ({ text = '', status, name }: generatingMessageProps) => {
setChatHistory((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
...(text
? {
value: item.value + text
}
: {}),
...(status && name
? {
status,
moduleName: name
}
: {})
};
})
);
generatingScroll();
};
// 重置输入内容
const resetInputVal = useCallback((val: string) => {
if (!TextareaDom.current) return;
setTimeout(() => {
/* 回到最小高度 */
if (TextareaDom.current) {
TextareaDom.current.value = val;
TextareaDom.current.style.height =
val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`;
}
setRefresh((state) => !state);
}, 100);
}, []);
// create question guide
const createQuestionGuide = useCallback(
async ({ history }: { history: ChatSiteItemType[] }) => {
if (!questionGuide || chatController.current?.signal?.aborted) return;
try {
const abortSignal = new AbortController();
questionGuideController.current = abortSignal;
const result = await postQuestionGuide(
{
messages: adaptChat2GptMessages({ messages: history, reserveId: false }).slice(-6),
shareId
},
abortSignal
);
if (Array.isArray(result)) {
setQuestionGuide(result);
setTimeout(() => {
scrollToBottom();
}, 100);
}
} catch (error) {}
},
[questionGuide, scrollToBottom, shareId]
);
/**
* user confirm send prompt
*/
const sendPrompt = useCallback(
async (variables: Record<string, any> = {}, inputVal = '', history = chatHistory) => {
if (!onStartChat) return;
if (isChatting) {
toast({
title: '正在聊天中...请等待结束',
status: 'warning'
});
return;
}
questionGuideController.current?.abort('stop');
// get input value
const val = inputVal.trim();
if (!val) {
toast({
title: '内容为空',
status: 'warning'
});
return;
}
const newChatList: ChatSiteItemType[] = [
...history,
{
dataId: nanoid(),
obj: 'Human',
value: val,
status: 'finish'
},
{
dataId: nanoid(),
obj: 'AI',
value: '',
status: 'loading'
}
];
// 插入内容
setChatHistory(newChatList);
// 清空输入内容
resetInputVal('');
setQuestionGuide([]);
setTimeout(() => {
scrollToBottom();
}, 100);
try {
// create abort obj
const abortSignal = new AbortController();
chatController.current = abortSignal;
const messages = adaptChat2GptMessages({ messages: newChatList, reserveId: true });
const {
responseData,
responseText,
isNewChat = false
} = await onStartChat({
chatList: newChatList.map((item) => ({
dataId: item.dataId,
obj: item.obj,
value: item.value,
status: item.status,
moduleName: item.moduleName
})),
messages,
controller: abortSignal,
generatingMessage,
variables
});
isNewChatReplace.current = isNewChat;
// set finish status
setChatHistory((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: 'finish',
responseData
};
})
);
setTimeout(() => {
createQuestionGuide({
history: newChatList.map((item, i) =>
i === newChatList.length - 1
? {
...item,
value: responseText
}
: item
)
});
generatingScroll();
isPc && TextareaDom.current?.focus();
}, 100);
} catch (err: any) {
toast({
title: t(getErrText(err, 'core.chat.error.Chat error')),
status: 'error',
duration: 5000,
isClosable: true
});
if (!err?.responseText) {
resetInputVal(inputVal);
setChatHistory(newChatList.slice(0, newChatList.length - 2));
}
// set finish status
setChatHistory((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: 'finish'
};
})
);
}
},
[
chatHistory,
onStartChat,
isChatting,
resetInputVal,
toast,
scrollToBottom,
generatingMessage,
createQuestionGuide,
generatingScroll,
isPc,
t
]
);
// retry input
const retryInput = useCallback(
async (index: number) => {
if (!onDelMessage) return;
const delHistory = chatHistory.slice(index);
setLoading(true);
try {
await Promise.all(
delHistory.map((item, i) => onDelMessage({ contentId: item.dataId, index: index + i }))
);
setChatHistory((state) => (index === 0 ? [] : state.slice(0, index)));
sendPrompt(variables, delHistory[0].value, chatHistory.slice(0, index));
} catch (error) {}
setLoading(false);
},
[chatHistory, onDelMessage, sendPrompt, setLoading, variables]
);
// delete one message
const delOneMessage = useCallback(
({ dataId, index }: { dataId?: string; index: number }) => {
setChatHistory((state) => state.filter((chat) => chat.dataId !== dataId));
onDelMessage?.({
contentId: dataId,
index
});
},
[onDelMessage]
);
// output data
useImperativeHandle(ref, () => ({
getChatHistories: () => chatHistory,
resetVariables(e) {
const defaultVal: Record<string, any> = {};
variableModules?.forEach((item) => {
defaultVal[item.key] = '';
});
reset(e || defaultVal);
setVariables(e || defaultVal);
},
resetHistory(e) {
setVariableInputFinish(!!e.length);
setChatHistory(e);
},
scrollToBottom,
sendPrompt: (question: string) => handleSubmit((item) => sendPrompt(item, question))()
}));
/* style start */
const MessageCardStyle: BoxProps = {
px: 4,
py: 3,
borderRadius: '0 8px 8px 8px',
boxShadow: '0 0 8px rgba(0,0,0,0.15)',
display: 'inline-block',
maxW: ['calc(100% - 25px)', 'calc(100% - 40px)']
};
const showEmpty = useMemo(
() =>
feConfigs?.show_emptyChat &&
showEmptyIntro &&
chatHistory.length === 0 &&
!variableModules?.length &&
!welcomeText,
[chatHistory.length, showEmptyIntro, variableModules, welcomeText]
);
const statusBoxData = useMemo(() => {
const colorMap = {
loading: 'myGray.700',
running: '#67c13b',
finish: 'primary.500'
};
if (!isChatting) return;
const chatContent = chatHistory[chatHistory.length - 1];
if (!chatContent) return;
return {
bg: colorMap[chatContent.status] || colorMap.loading,
name: t(chatContent.moduleName || '') || t('common.Loading')
};
}, [chatHistory, isChatting, t]);
/* style end */
// page change and abort request
useEffect(() => {
isNewChatReplace.current = false;
setQuestionGuide([]);
return () => {
chatController.current?.abort('leave');
if (!isNewChatReplace.current) {
questionGuideController.current?.abort('leave');
}
};
}, [router.query]);
// add listener
useEffect(() => {
const windowMessage = ({ data }: MessageEvent<{ type: 'sendPrompt'; text: string }>) => {
if (data?.type === 'sendPrompt' && data?.text) {
handleSubmit((item) => sendPrompt(item, data.text))();
}
};
window.addEventListener('message', windowMessage);
eventBus.on(EventNameEnum.sendQuestion, ({ text }: { text: string }) => {
if (!text) return;
handleSubmit((data) => sendPrompt(data, text))();
});
eventBus.on(EventNameEnum.editQuestion, ({ text }: { text: string }) => {
if (!text) return;
resetInputVal(text);
});
return () => {
window.removeEventListener('message', windowMessage);
eventBus.off(EventNameEnum.sendQuestion);
eventBus.off(EventNameEnum.editQuestion);
};
}, [handleSubmit, resetInputVal, sendPrompt]);
return (
<Flex flexDirection={'column'} h={'100%'}>
<Script src="/js/html2pdf.bundle.min.js" strategy="lazyOnload"></Script>
{/* chat box container */}
<Box ref={ChatBoxRef} flex={'1 0 0'} h={0} w={'100%'} overflow={'overlay'} px={[4, 0]} pb={3}>
<Box id="chat-container" maxW={['100%', '92%']} h={'100%'} mx={'auto'}>
{showEmpty && <Empty />}
{!!welcomeText && (
<Box py={3}>
{/* avatar */}
<ChatAvatar src={appAvatar} type={'AI'} />
{/* message */}
<Box textAlign={'left'}>
<Card order={2} mt={2} {...MessageCardStyle} bg={'white'}>
<Markdown source={`~~~guide \n${welcomeText}`} isChatting={false} />
</Card>
</Box>
</Box>
)}
{/* variable input */}
{!!variableModules?.length && (
<Box py={3}>
{/* avatar */}
<ChatAvatar src={appAvatar} type={'AI'} />
{/* message */}
<Box textAlign={'left'}>
<Card order={2} mt={2} bg={'white'} w={'400px'} {...MessageCardStyle}>
{variableModules.map((item) => (
<Box key={item.id} mb={4}>
<VariableLabel required={item.required}>{item.label}</VariableLabel>
{item.type === VariableInputEnum.input && (
<Input
isDisabled={variableIsFinish}
bg={'myWhite.400'}
{...register(item.key, {
required: item.required
})}
/>
)}
{item.type === VariableInputEnum.textarea && (
<Textarea
isDisabled={variableIsFinish}
bg={'myWhite.400'}
{...register(item.key, {
required: item.required
})}
rows={5}
maxLength={4000}
/>
)}
{item.type === VariableInputEnum.select && (
<MySelect
width={'100%'}
isDisabled={variableIsFinish}
list={(item.enums || []).map((item) => ({
label: item.value,
value: item.value
}))}
{...register(item.key, {
required: item.required
})}
value={getValues(item.key)}
onchange={(e) => {
setValue(item.key, e);
setRefresh(!refresh);
}}
/>
)}
</Box>
))}
{!variableIsFinish && (
<Button
leftIcon={<MyIcon name={'core/chat/chatFill'} w={'16px'} />}
size={'sm'}
maxW={'100px'}
onClick={handleSubmit((data) => {
onUpdateVariable?.(data);
setVariables(data);
setVariableInputFinish(true);
})}
>
{t('core.chat.Start Chat')}
</Button>
)}
</Card>
</Box>
</Box>
)}
{/* chat history */}
<Box id={'history'}>
{chatHistory.map((item, index) => (
<Box key={item.dataId} py={5}>
{item.obj === 'Human' && (
<>
{/* control icon */}
<Flex w={'100%'} alignItems={'center'} justifyContent={'flex-end'}>
<ChatController
chat={item}
onDelete={
onDelMessage
? () => {
delOneMessage({ dataId: item.dataId, index });
}
: undefined
}
onRetry={() => retryInput(index)}
/>
<ChatAvatar src={userAvatar} type={'Human'} />
</Flex>
{/* content */}
<Box mt={['6px', 2]} textAlign={'right'}>
<Card
className="markdown"
{...MessageCardStyle}
bg={'primary.200'}
borderRadius={'8px 0 8px 8px'}
textAlign={'left'}
>
<Markdown source={item.value} isChatting={false} />
</Card>
</Box>
</>
)}
{item.obj === 'AI' && (
<>
{/* control icon */}
<Flex w={'100%'} alignItems={'center'}>
<ChatAvatar src={appAvatar} type={'AI'} />
<ChatController
ml={2}
chat={item}
setChatHistory={setChatHistory}
display={index === chatHistory.length - 1 && isChatting ? 'none' : 'flex'}
showVoiceIcon={showVoiceIcon}
ttsConfig={ttsConfig}
onDelete={
onDelMessage
? () => {
delOneMessage({ dataId: item.dataId, index });
}
: undefined
}
onMark={
showMarkIcon
? () => {
if (!item.dataId) return;
if (item.adminFeedback) {
setAdminMarkData({
chatItemId: item.dataId,
datasetId: item.adminFeedback.datasetId,
collectionId: item.adminFeedback.collectionId,
dataId: item.adminFeedback.dataId,
q: item.adminFeedback.q || chatHistory[index - 1]?.value || '',
a: item.adminFeedback.a
});
} else {
setAdminMarkData({
chatItemId: item.dataId,
q: chatHistory[index - 1]?.value || '',
a: item.value
});
}
}
: undefined
}
onAddUserLike={(() => {
if (feedbackType !== FeedbackTypeEnum.user || item.userBadFeedback) {
return;
}
return () => {
if (!item.dataId || !chatId || !appId) return;
const isGoodFeedback = !!item.userGoodFeedback;
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === item.dataId
? {
...chatItem,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
}
: chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
chatItemId: item.dataId,
shareId,
outLinkUid,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
});
} catch (error) {}
};
})()}
onCloseUserLike={
feedbackType === FeedbackTypeEnum.admin
? () => {
if (!item.dataId || !chatId || !appId) return;
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === item.dataId
? { ...chatItem, userGoodFeedback: undefined }
: chatItem
)
);
updateChatUserFeedback({
appId,
chatId,
chatItemId: item.dataId,
userGoodFeedback: undefined
});
}
: undefined
}
onAddUserDislike={(() => {
if (feedbackType !== FeedbackTypeEnum.user || item.userGoodFeedback) {
return;
}
if (item.userBadFeedback) {
return () => {
if (!item.dataId || !chatId || !appId) return;
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === item.dataId
? { ...chatItem, userBadFeedback: undefined }
: chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
chatItemId: item.dataId,
shareId,
outLinkUid
});
} catch (error) {}
};
} else {
return () => setFeedbackId(item.dataId);
}
})()}
onReadUserDislike={
feedbackType === FeedbackTypeEnum.admin
? () => {
if (!item.dataId) return;
setReadFeedbackData({
chatItemId: item.dataId || '',
content: item.userBadFeedback || ''
});
}
: undefined
}
/>
{/* chatting status */}
{statusBoxData && index === chatHistory.length - 1 && (
<Flex
ml={3}
alignItems={'center'}
px={3}
py={'1px'}
borderRadius="md"
border={theme.borders.base}
>
<Box
className={styles.statusAnimation}
bg={statusBoxData.bg}
w="8px"
h="8px"
borderRadius={'50%'}
mt={'1px'}
></Box>
<Box ml={2} color={'myGray.600'}>
{statusBoxData.name}
</Box>
</Flex>
)}
</Flex>
{/* content */}
<Box textAlign={'left'} mt={['6px', 2]}>
<Card bg={'white'} {...MessageCardStyle}>
<Markdown
source={(() => {
const text = item.value as string;
// replace quote tag: [source1] 标识第一个来源需要提取数字1从而去数组里查找来源
const quoteReg = /\[source:(.+)\]/g;
const replaceText = text.replace(quoteReg, `[QUOTE SIGN]($1)`);
// question guide
if (
index === chatHistory.length - 1 &&
!isChatting &&
questionGuides.length > 0
) {
return `${replaceText}\n\`\`\`${
CodeClassName.questionGuide
}\n${JSON.stringify(questionGuides)}`;
}
return replaceText;
})()}
isChatting={index === chatHistory.length - 1 && isChatting}
/>
<ResponseTags responseData={item.responseData} isShare={!!shareId} />
{/* custom feedback */}
{item.customFeedbacks && item.customFeedbacks.length > 0 && (
<Box>
<ChatBoxDivider
icon={'core/app/customFeedback'}
text={t('core.app.feedback.Custom feedback')}
/>
{item.customFeedbacks.map((text, i) => (
<Box key={`${text}${i}`}>
<MyTooltip label={t('core.app.feedback.close custom feedback')}>
<Checkbox
onChange={(e) => {
if (e.target.checked && appId && chatId && item.dataId) {
closeCustomFeedback({
appId,
chatId,
chatItemId: item.dataId,
index: i
});
// update dom
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === item.dataId
? {
...chatItem,
customFeedbacks: chatItem.customFeedbacks?.filter(
(item, index) => index !== i
)
}
: chatItem
)
);
}
console.log(e);
}}
>
{text}
</Checkbox>
</MyTooltip>
</Box>
))}
</Box>
)}
{/* admin mark content */}
{showMarkIcon && item.adminFeedback && (
<Box fontSize={'sm'}>
<ChatBoxDivider
icon="core/app/markLight"
text={t('core.chat.Admin Mark Content')}
/>
<Box whiteSpace={'pre'}>
<Box color={'black'}>{item.adminFeedback.q}</Box>
<Box color={'myGray.600'}>{item.adminFeedback.a}</Box>
</Box>
</Box>
)}
</Card>
</Box>
</>
)}
</Box>
))}
</Box>
</Box>
</Box>
{/* message input */}
{onStartChat && variableIsFinish && active ? (
<MessageInput
onChange={(e) => {
setRefresh(!refresh);
}}
onSendMessage={(e) => {
handleSubmit((data) => sendPrompt(data, e))();
}}
onStop={() => chatController.current?.abort('stop')}
isChatting={isChatting}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
showFileSelector={showFileSelector}
/>
) : null}
{/* user feedback modal */}
{!!feedbackId && chatId && appId && (
<FeedbackModal
appId={appId}
chatId={chatId}
chatItemId={feedbackId}
shareId={shareId}
outLinkUid={outLinkUid}
onClose={() => setFeedbackId(undefined)}
onSuccess={(content: string) => {
setChatHistory((state) =>
state.map((item) =>
item.dataId === feedbackId ? { ...item, userBadFeedback: content } : item
)
);
setFeedbackId(undefined);
}}
/>
)}
{/* admin read feedback modal */}
{!!readFeedbackData && (
<ReadFeedbackModal
content={readFeedbackData.content}
onClose={() => setReadFeedbackData(undefined)}
onCloseFeedback={() => {
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === readFeedbackData.chatItemId
? { ...chatItem, userBadFeedback: undefined }
: chatItem
)
);
try {
if (!chatId || !appId) return;
updateChatUserFeedback({
appId,
chatId,
chatItemId: readFeedbackData.chatItemId
});
} catch (error) {}
setReadFeedbackData(undefined);
}}
/>
)}
{/* admin mark data */}
{!!adminMarkData && (
<SelectMarkCollection
adminMarkData={adminMarkData}
setAdminMarkData={(e) => setAdminMarkData({ ...e, chatItemId: adminMarkData.chatItemId })}
onClose={() => setAdminMarkData(undefined)}
onSuccess={(adminFeedback) => {
console.log(adminMarkData);
if (!appId || !chatId || !adminMarkData.chatItemId) return;
updateChatAdminFeedback({
appId,
chatId,
chatItemId: adminMarkData.chatItemId,
...adminFeedback
});
// update dom
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === adminMarkData.chatItemId
? {
...chatItem,
adminFeedback
}
: chatItem
)
);
if (readFeedbackData && chatId && appId) {
updateChatUserFeedback({
appId,
chatId,
chatItemId: readFeedbackData.chatItemId,
userBadFeedback: undefined
});
setChatHistory((state) =>
state.map((chatItem) =>
chatItem.dataId === readFeedbackData.chatItemId
? { ...chatItem, userBadFeedback: undefined }
: chatItem
)
);
setReadFeedbackData(undefined);
}
}}
/>
)}
</Flex>
);
};
export default React.memo(forwardRef(ChatBox));
export const useChatBox = () => {
const onExportChat = useCallback(
({ type, history }: { type: ExportChatType; history: ChatItemType[] }) => {
const getHistoryHtml = () => {
const historyDom = document.getElementById('history');
if (!historyDom) return;
const dom = Array.from(historyDom.children).map((child, i) => {
const avatar = `<img src="${child.querySelector<HTMLImageElement>('.avatar')
?.src}" alt="" />`;
const chatContent = child.querySelector<HTMLDivElement>('.markdown');
if (!chatContent) {
return '';
}
const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement;
const codeHeader = chatContentClone.querySelectorAll('.code-header');
codeHeader.forEach((childElement: any) => {
childElement.remove();
});
return `<div class="chat-item">
${avatar}
${chatContentClone.outerHTML}
</div>`;
});
const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n'));
return html;
};
const map: Record<ExportChatType, () => void> = {
md: () => {
fileDownload({
text: history.map((item) => item.value).join('\n\n'),
type: 'text/markdown',
filename: 'chat.md'
});
},
html: () => {
const html = getHistoryHtml();
html &&
fileDownload({
text: html,
type: 'text/html',
filename: '聊天记录.html'
});
},
pdf: () => {
const html = getHistoryHtml();
html &&
// @ts-ignore
html2pdf(html, {
margin: 0,
filename: `聊天记录.pdf`
});
}
};
map[type]();
},
[]
);
return {
onExportChat
};
};
function VariableLabel({
required = false,
children
}: {
required?: boolean;
children: React.ReactNode | string;
}) {
return (
<Box as={'label'} display={'inline-block'} position={'relative'} mb={1}>
{children}
{required && (
<Box
position={'absolute'}
top={'-2px'}
right={'-10px'}
color={'red.500'}
fontWeight={'bold'}
>
*
</Box>
)}
</Box>
);
}
function ChatAvatar({ src, type }: { src?: string; type: 'Human' | 'AI' }) {
const theme = useTheme();
return (
<Box
w={['28px', '34px']}
h={['28px', '34px']}
p={'2px'}
borderRadius={'sm'}
border={theme.borders.base}
boxShadow={'0 0 5px rgba(0,0,0,0.1)'}
bg={type === 'Human' ? 'white' : 'primary.50'}
>
<Avatar src={src} w={'100%'} h={'100%'} />
</Box>
);
}
function 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>
);
}
function ChatController({
chat,
setChatHistory,
display,
showVoiceIcon,
ttsConfig,
onReadUserDislike,
onCloseUserLike,
onMark,
onRetry,
onDelete,
onAddUserDislike,
onAddUserLike,
ml,
mr
}: {
chat: ChatSiteItemType;
setChatHistory?: React.Dispatch<React.SetStateAction<ChatSiteItemType[]>>;
showVoiceIcon?: boolean;
ttsConfig?: AppTTSConfigType;
onRetry?: () => void;
onDelete?: () => void;
onMark?: () => void;
onReadUserDislike?: () => void;
onCloseUserLike?: () => void;
onAddUserLike?: () => void;
onAddUserDislike?: () => void;
} & FlexProps) {
const theme = useTheme();
const { t } = useTranslation();
const { copyData } = useCopyData();
const { audioLoading, audioPlaying, hasAudio, playAudio, cancelAudio } = useAudioPlay({
ttsConfig
});
const controlIconStyle = {
w: '14px',
cursor: 'pointer',
p: 1,
bg: 'white',
borderRadius: 'md',
boxShadow: '0 0 5px rgba(0,0,0,0.1)',
border: theme.borders.base,
mr: 3
};
const controlContainerStyle = {
className: 'control',
color: 'myGray.400',
display: 'flex',
pl: 1
};
return (
<Flex {...controlContainerStyle} ml={ml} mr={mr} display={display}>
<MyTooltip label={'复制'}>
<MyIcon
{...controlIconStyle}
name={'copy'}
_hover={{ color: 'primary.600' }}
onClick={() => copyData(chat.value)}
/>
</MyTooltip>
{!!onDelete && (
<>
{onRetry && (
<MyTooltip label={t('core.chat.retry')}>
<MyIcon
{...controlIconStyle}
name={'common/retryLight'}
_hover={{ color: 'green.500' }}
onClick={onRetry}
/>
</MyTooltip>
)}
<MyTooltip label={'删除'}>
<MyIcon
{...controlIconStyle}
name={'delete'}
_hover={{ color: 'red.600' }}
onClick={onDelete}
/>
</MyTooltip>
</>
)}
{showVoiceIcon &&
hasAudio &&
(audioLoading ? (
<MyTooltip label={'加载中...'}>
<MyIcon {...controlIconStyle} name={'common/loading'} />
</MyTooltip>
) : audioPlaying ? (
<Flex alignItems={'center'} mr={2}>
<MyTooltip label={t('core.chat.tts.Stop Speech')}>
<MyIcon
{...controlIconStyle}
mr={1}
name={'core/chat/stopSpeech'}
color={'#E74694'}
onClick={() => cancelAudio()}
/>
</MyTooltip>
<Image src="/icon/speaking.gif" w={'23px'} alt={''} />
</Flex>
) : (
<MyTooltip label={t('core.app.TTS')}>
<MyIcon
{...controlIconStyle}
name={'common/voiceLight'}
_hover={{ color: '#E74694' }}
onClick={async () => {
const response = await playAudio({
buffer: chat.ttsBuffer,
chatItemId: chat.dataId,
text: chat.value
});
if (!setChatHistory || !response.buffer) return;
setChatHistory((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>
)}
{!!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>
);
}