chat quote reader (#3912)

* init chat quote full text reader

* linked structure

* dataset data linked

* optimize code

* fix ts build

* test finish

* delete log

* fix

* fix ts

* fix ts

* remove nextId

* initial scroll

* fix

* fix
This commit is contained in:
heheer
2025-03-11 19:44:33 +08:00
committed by archer
parent 16832caaf6
commit ac7091f8d6
64 changed files with 2676 additions and 369 deletions

View File

@@ -19,6 +19,7 @@ import ChatRecordContextProvider, {
} from '@/web/core/chat/context/chatRecordContext';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useContextSelector } from 'use-context-selector';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
const ChatBox = dynamic(() => import('@/components/core/chat/ChatContainer/ChatBox'));
@@ -37,6 +38,8 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
@@ -76,7 +79,7 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
zIndex={3}
position={['fixed', 'absolute']}
top={[0, '2%']}
right={0}
right={quoteData ? 600 : 0}
h={['100%', '96%']}
w={'100%'}
maxW={['100%', '600px']}
@@ -168,6 +171,26 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
)}
</Box>
</MyBox>
{quoteData && (
<Box
w={['full', '588px']}
zIndex={300}
position={'absolute'}
top={5}
right={0}
h={'95%'}
bg={'white'}
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
borderRadius={'md'}
>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</Box>
)}
<Box zIndex={2} position={'fixed'} top={0} left={0} bottom={0} right={0} onClick={onClose} />
</>
);
@@ -189,6 +212,7 @@ const Render = (props: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={params}>

View File

@@ -42,7 +42,6 @@ import { getDocPath } from '@/web/common/system/doc';
import dynamic from 'next/dynamic';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useI18n } from '@/web/context/I18n';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
@@ -185,6 +184,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
name: item.name,
responseDetail: item.responseDetail ?? false,
showRawSource: item.showRawSource ?? false,
// showFullText: item.showFullText ?? false,
showNodeStatus: item.showNodeStatus ?? false,
limit: item.limit
})
@@ -270,7 +270,6 @@ function EditLinkModal({
}) {
const { feConfigs } = useSystemStore();
const { t } = useTranslation();
const { publishT } = useI18n();
const {
register,
setValue,
@@ -281,6 +280,7 @@ function EditLinkModal({
});
const responseDetail = watch('responseDetail');
// const showFullText = watch('showFullText');
const showRawSource = watch('showRawSource');
const isEdit = useMemo(() => !!defaultData._id, [defaultData]);
@@ -306,7 +306,7 @@ function EditLinkModal({
<MyModal
isOpen={true}
iconSrc="/imgs/modal/shareFill.svg"
title={isEdit ? publishT('edit_link') : publishT('create_link')}
title={isEdit ? t('publish:edit_link') : t('publish:create_link')}
maxW={['90vw', '700px']}
w={'100%'}
h={['90vh', 'auto']}
@@ -325,10 +325,10 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
<Input
placeholder={publishT('link_name')}
placeholder={t('publish:link_name')}
maxLength={20}
{...register('name', {
required: t('common:common.name_is_empty') || 'name_is_empty'
required: t('common:common.name_is_empty')
})}
/>
</Flex>
@@ -353,7 +353,7 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<Flex flex={'0 0 90px'} alignItems={'center'}>
<FormLabel>QPM</FormLabel>
<QuestionTip ml={1} label={publishT('qpm_tips')}></QuestionTip>
<QuestionTip ml={1} label={t('publish:qpm_tips')}></QuestionTip>
</Flex>
<Input
max={1000}
@@ -361,7 +361,7 @@ function EditLinkModal({
min: 0,
max: 1000,
valueAsNumber: true,
required: publishT('qpm_is_empty') || ''
required: t('publish:qpm_is_empty')
})}
/>
</Flex>
@@ -385,11 +385,11 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<Flex flex={'0 0 90px'} alignItems={'center'}>
<FormLabel>{publishT('token_auth')}</FormLabel>
<QuestionTip ml={1} label={publishT('token_auth_tips') || ''}></QuestionTip>
<FormLabel>{t('publish:token_auth')}</FormLabel>
<QuestionTip ml={1} label={t('publish:token_auth_tips')}></QuestionTip>
</Flex>
<Input
placeholder={publishT('token_auth_tips') || ''}
placeholder={t('publish:token_auth_tips')}
fontSize={'sm'}
{...register('limit.hookUrl')}
/>
@@ -400,7 +400,7 @@ function EditLinkModal({
fontSize={'xs'}
color={'myGray.500'}
>
{publishT('token_auth_use_cases')}
{t('publish:token_auth_use_cases')}
</Link>
</>
)}
@@ -421,8 +421,39 @@ function EditLinkModal({
label={t('common:support.outlink.share.Response Quote tips')}
></QuestionTip>
</Flex>
<Switch {...register('responseDetail')} isChecked={responseDetail} />
<Switch
{...register('responseDetail', {
onChange(e) {
if (!e.target.checked) {
// setValue('showFullText', false);
setValue('showRawSource', false);
}
}
})}
isChecked={responseDetail}
/>
</Flex>
{/* <Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
<Flex alignItems={'center'}>
<FormLabel>{t('common:support.outlink.share.Chat_quote_reader')}</FormLabel>
<QuestionTip
ml={1}
label={t('common:support.outlink.share.Full_text tips')}
></QuestionTip>
</Flex>
<Switch
{...register('showFullText', {
onChange(e) {
if (e.target.checked) {
setValue('responseDetail', true);
} else {
setValue('showRawSource', false);
}
}
})}
isChecked={showFullText}
/>
</Flex> */}
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
<Flex alignItems={'center'}>
<FormLabel>{t('common:support.outlink.share.show_complete_quote')}</FormLabel>
@@ -436,6 +467,7 @@ function EditLinkModal({
onChange(e) {
if (e.target.checked) {
setValue('responseDetail', true);
// setValue('showFullText', true);
}
}
})}

View File

@@ -7,21 +7,26 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeState } from 'ahooks';
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/web/core/app/utils';
import { useI18n } from '@/web/context/I18n';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { useChatTest } from '../useChatTest';
import ChatItemContextProvider from '@/web/core/chat/context/chatItemContext';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
type Props = { appForm: AppSimpleEditFormType };
const ChatTest = ({ appForm }: Props) => {
type Props = {
appForm: AppSimpleEditFormType;
setRenderEdit: React.Dispatch<React.SetStateAction<boolean>>;
};
const ChatTest = ({ appForm, setRenderEdit }: Props) => {
const { t } = useTranslation();
const { appT } = useI18n();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const [workflowData, setWorkflowData] = useSafeState({
nodes: appDetail.modules || [],
@@ -32,6 +37,11 @@ const ChatTest = ({ appForm }: Props) => {
const { nodes, edges } = form2AppWorkflow(appForm, t);
setWorkflowData({ nodes, edges });
}, [appForm, setWorkflowData, t]);
useEffect(() => {
setRenderEdit(!quoteData);
}, [quoteData, setRenderEdit]);
const { ChatContainer, restartChat, loading } = useChatTest({
...workflowData,
chatConfig: appForm.chatConfig,
@@ -39,41 +49,56 @@ const ChatTest = ({ appForm }: Props) => {
});
return (
<MyBox
isLoading={loading}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'100%'}
py={4}
>
<Flex px={[2, 5]}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} flex={1} color={'myGray.900'}>
{appT('chat_debug')}
<Flex h={'full'} gap={2}>
<MyBox
isLoading={loading}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'full'}
w={quoteData ? '' : 'full'}
py={4}
{...cardStyles}
boxShadow={'3'}
>
<Flex px={[2, 5]}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} flex={1} color={'myGray.900'}>
{t('app:chat_debug')}
</Box>
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={(e) => {
e.stopPropagation();
restartChat();
}}
/>
</MyTooltip>
</Flex>
<Box flex={1}>
<ChatContainer />
</Box>
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={(e) => {
e.stopPropagation();
restartChat();
}}
</MyBox>
{quoteData && (
<Box w={['full', '588px']} {...cardStyles} boxShadow={'3'}>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</MyTooltip>
</Flex>
<Box flex={1}>
<ChatContainer />
</Box>
</MyBox>
</Box>
)}
</Flex>
);
};
const Render = ({ appForm }: Props) => {
const Render = ({ appForm, setRenderEdit }: Props) => {
const { chatId } = useChatStore();
const { appDetail } = useContextSelector(AppContext, (v) => v);
@@ -90,10 +115,11 @@ const Render = ({ appForm }: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<ChatTest appForm={appForm} />
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Box } from '@chakra-ui/react';
import ChatTest from './ChatTest';
@@ -21,6 +21,7 @@ const Edit = ({
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
}) => {
const { isPc } = useSystem();
const [renderEdit, setRenderEdit] = useState(true);
return (
<Box
@@ -32,24 +33,26 @@ const Edit = ({
borderRadius={'lg'}
overflowY={['auto', 'unset']}
>
<Box
className={styles.EditAppBox}
pr={[0, 1]}
overflowY={'auto'}
minW={['auto', '580px']}
flex={'1'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard appForm={appForm} setPast={setPast} />
</Box>
{renderEdit && (
<Box
className={styles.EditAppBox}
pr={[0, 1]}
overflowY={'auto'}
minW={['auto', '580px']}
flex={'1'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard appForm={appForm} setPast={setPast} />
</Box>
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
<EditForm appForm={appForm} setAppForm={setAppForm} />
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
<EditForm appForm={appForm} setAppForm={setAppForm} />
</Box>
</Box>
</Box>
)}
{isPc && (
<Box {...cardStyles} boxShadow={'3'} flex={'2 0 0'} w={0} mb={3}>
<ChatTest appForm={appForm} />
<Box flex={'2 0 0'} w={0} mb={3}>
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</Box>
)}
</Box>

View File

@@ -20,6 +20,7 @@ import ChatRecordContextProvider, {
} from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
type Props = {
isOpen: boolean;
@@ -41,10 +42,13 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
});
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
return (
<>
<Flex h={'full'}>
<Box
zIndex={300}
display={isOpen ? 'block' : 'none'}
@@ -53,7 +57,10 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
left={0}
bottom={0}
right={0}
onClick={onClose}
onClick={() => {
setQuoteData(undefined);
onClose();
}}
/>
<MyBox
isLoading={loading}
@@ -62,7 +69,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
flexDirection={'column'}
position={'absolute'}
top={5}
right={0}
right={quoteData ? 600 : 0}
h={isOpen ? '95%' : '0'}
w={isOpen ? ['100%', '460px'] : '0'}
bg={'white'}
@@ -141,7 +148,27 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
<ChatContainer />
</Box>
</MyBox>
</>
{quoteData && (
<Box
w={['full', '588px']}
zIndex={300}
position={'absolute'}
top={5}
right={0}
h={'95%'}
bg={'white'}
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
borderRadius={'md'}
>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</Box>
)}
</Flex>
);
};
@@ -162,6 +189,7 @@ const Render = (Props: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>

View File

@@ -46,6 +46,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.name);
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.avatar);
const showRouteToAppDetail = useContextSelector(ChatItemContext, (v) => v.showRouteToAppDetail);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const concatHistory = useMemo(() => {
const formatHistories: HistoryItemType[] = histories.map((item) => {
@@ -144,7 +145,10 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
borderRadius={'xl'}
leftIcon={<MyIcon name={'core/chat/chatLight'} w={'16px'} />}
overflow={'hidden'}
onClick={() => onChangeChatId()}
onClick={() => {
onChangeChatId();
setQuoteData(undefined);
}}
>
{t('common:core.chat.New Chat')}
</Button>
@@ -199,6 +203,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
: {
onClick: () => {
onChangeChatId(item.id);
setQuoteData(undefined);
}
})}
{...(i !== concatHistory.length - 1 && {
@@ -270,6 +275,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
onDelHistory(item.id);
if (item.id === activeChatId) {
onChangeChatId();
setQuoteData(undefined);
}
},
type: 'danger'

View File

@@ -0,0 +1,192 @@
import Markdown from '@/components/Markdown';
import { Box, Flex } from '@chakra-ui/react';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { Dispatch, MutableRefObject, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import InputDataModal from '@/pageComponents/dataset/detail/InputDataModal';
const CollectionQuoteItem = ({
index,
quoteRefs,
quoteIndex,
setQuoteIndex,
refreshList,
canEdit,
updated,
isCurrentSelected,
q,
a,
dataId,
collectionId
}: {
index: number;
quoteRefs: MutableRefObject<(HTMLDivElement | null)[]>;
quoteIndex: number;
setQuoteIndex: Dispatch<SetStateAction<number>>;
refreshList: () => void;
canEdit: boolean;
updated?: boolean;
isCurrentSelected: boolean;
q: string;
a?: string;
dataId: string;
collectionId: string;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const hasBeenSearched = quoteIndex !== undefined && quoteIndex > -1;
const [editInputData, setEditInputData] = useState<{ dataId: string; collectionId: string }>();
return (
<>
<Box
ref={(el: HTMLDivElement | null) => {
quoteRefs.current[index] = el;
}}
p={2}
py={2}
cursor={hasBeenSearched ? 'pointer' : 'default'}
bg={isCurrentSelected ? '#FFF9E7' : hasBeenSearched ? '#FFFCF2' : ''}
position={'relative'}
overflow={'hidden'}
border={'1px solid '}
borderColor={isCurrentSelected ? 'yellow.200' : 'transparent'}
wordBreak={'break-all'}
fontSize={'sm'}
_hover={
hasBeenSearched
? {
'& .hover-data': { visibility: 'visible' }
}
: {
bg: 'linear-gradient(180deg, #FBFBFC 7.61%, #F0F1F6 100%)',
borderTopColor: 'myGray.50',
'& .hover-data': { visibility: 'visible' }
}
}
onClick={(e) => {
e.stopPropagation();
if (hasBeenSearched) {
setQuoteIndex(quoteIndex);
}
}}
>
{updated && (
<Flex
position={'absolute'}
top={2}
right={5}
gap={1}
bg={'yellow.50'}
color={'yellow.500'}
px={2}
py={1}
rounded={'md'}
fontSize={'12px'}
>
<MyIcon name="common/info" w={'14px'} color={'yellow.500'} />
{t('common:core.dataset.data.Updated')}
</Flex>
)}
<Markdown source={q} />
{!!a && (
<Box>
<Markdown source={a} />
</Box>
)}
<Flex
className="hover-data"
position={'absolute'}
bottom={2}
right={5}
gap={1.5}
visibility={'hidden'}
>
<MyTooltip label={t('common:core.dataset.Quote Length')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={2}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
>
<MyIcon name="common/text/t" w={'14px'} mr={1} color={'myGray.500'} />
{q.length + (a?.length || 0)}
</Flex>
</MyTooltip>
{canEdit && (
<MyTooltip label={t('common:core.dataset.data.Edit')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() =>
setEditInputData({
dataId,
collectionId
})
}
>
<MyIcon name="common/edit" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
)}
<MyTooltip label={t('common:common.Copy')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() => {
copyData(q + '\n' + a);
}}
>
<MyIcon name="copy" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
</Flex>
</Box>
{editInputData && (
<InputDataModal
onClose={() => setEditInputData(undefined)}
onSuccess={() => {
console.log('onSuccess');
refreshList();
}}
dataId={editInputData.dataId}
collectionId={editInputData.collectionId}
/>
)}
</>
);
};
export default CollectionQuoteItem;

View File

@@ -0,0 +1,345 @@
import { Box, Button, Flex } from '@chakra-ui/react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import DownloadButton from './DownloadButton';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { downloadFetch } from '@/web/common/system/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getCollectionSource, getDatasetDataPermission } from '@/web/core/dataset/api';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import ScoreTag from './ScoreTag';
import { formatScore } from '@/components/core/dataset/QuoteItem';
import NavButton from './NavButton';
import { useLinkedScroll } from '@fastgpt/web/hooks/useLinkedScroll';
import CollectionQuoteItem from './CollectionQuoteItem';
import { DatasetDataListItemType } from '@/global/core/dataset/type';
import { metadataType } from '@/web/core/chat/context/chatItemContext';
import { useUserStore } from '@/web/support/user/useUserStore';
import { getCollectionQuote } from '@/web/core/chat/api';
const CollectionReader = ({
rawSearch,
metadata,
chatTime,
onClose
}: {
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
chatTime: Date;
onClose: () => void;
}) => {
const { t } = useTranslation();
const router = useRouter();
const { toast } = useToast();
const { chatId, appId, outLinkAuthData } = useChatStore();
const { userInfo } = useUserStore();
const { collectionId, datasetId, chatItemId, sourceId, sourceName } = metadata;
const [quoteIndex, setQuoteIndex] = useState(0);
const { data: permissionData, loading: isPermissionLoading } = useRequest2(
async () => await getDatasetDataPermission(datasetId),
{
manual: !userInfo && !datasetId,
refreshDeps: [datasetId, userInfo]
}
);
const filterResults = useMemo(() => {
const results = rawSearch.filter(
(item) => item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
);
return results.sort((a, b) => (a.chunkIndex || 0) - (b.chunkIndex || 0));
}, [metadata, rawSearch]);
const currentQuoteItem = filterResults[quoteIndex];
const {
dataList: datasetDataList,
setDataList: setDatasetDataList,
isLoading,
loadData,
ScrollData,
itemRefs,
scrollToItem
} = useLinkedScroll(getCollectionQuote, {
refreshDeps: [collectionId],
params: {
collectionId,
chatTime,
chatItemId,
chatId,
appId,
...outLinkAuthData
},
initialId: currentQuoteItem?.id,
initialIndex: currentQuoteItem?.chunkIndex,
canLoadData: !!currentQuoteItem?.id && !isPermissionLoading
});
const loading = isLoading || isPermissionLoading;
const isDeleted = !datasetDataList.find((item) => item._id === currentQuoteItem?.id);
const formatedDataList = useMemo(
() =>
datasetDataList.map((item: DatasetDataListItemType) => {
const isCurrentSelected = currentQuoteItem?.id === item._id;
const quoteIndex = filterResults.findIndex((res) => res.id === item._id);
return {
...item,
isCurrentSelected,
quoteIndex
};
}),
[currentQuoteItem?.id, datasetDataList, filterResults]
);
useEffect(() => {
setQuoteIndex(0);
setDatasetDataList([]);
}, [collectionId, setDatasetDataList]);
const { runAsync: handleDownload, loading: downloadLoading } = useRequest2(async () => {
await downloadFetch({
url: '/api/core/dataset/collection/export',
filename: 'parsed_content.md',
body: {
collectionId: collectionId,
chatTime: chatTime,
chatItemId: chatItemId
}
});
});
const { runAsync: handleRead, loading: readLoading } = useRequest2(
async () => await getCollectionSource({ ...metadata, appId, chatId }),
{
onSuccess: (res) => {
if (!res.value) {
throw new Error('No file found');
}
if (res.value.startsWith('/')) {
window.open(`${location.origin}${res.value}`, '_blank');
} else {
window.open(res.value, '_blank');
}
},
onError: (err) => {
toast({
title: t(getErrText(err, t('common:error.fileNotFound'))),
status: 'error'
});
}
}
);
const handleNavigate = useCallback(
async (targetIndex: number) => {
if (targetIndex < 0 || targetIndex >= filterResults.length) return;
const targetItemId = filterResults[targetIndex].id;
const targetItemIndex = filterResults[targetIndex].chunkIndex;
setQuoteIndex(targetIndex);
const dataIndex = datasetDataList.findIndex((item) => item._id === targetItemId);
if (dataIndex !== -1) {
setTimeout(() => {
scrollToItem(dataIndex);
}, 50);
} else {
try {
await loadData({ id: targetItemId, index: targetItemIndex });
} catch (error) {
console.error('Failed to navigate:', error);
}
}
},
[filterResults, datasetDataList, scrollToItem, loadData]
);
return (
<Flex flexDirection={'column'} h={'full'}>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
px={5}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
<Box flex={1} py={4}>
<Flex mb={1} alignItems={['flex-start', 'center']} flexDirection={['column', 'row']}>
<Flex gap={2} mr={2}>
<MyIcon
name={getSourceNameIcon({ sourceId, sourceName }) as any}
w={['1rem', '1.25rem']}
color={'primary.600'}
/>
<Box
maxW={['200px', '300px']}
className={'textEllipsis'}
wordBreak={'break-all'}
color={'myGray.900'}
fontWeight={'medium'}
>
{sourceName || t('common:common.UnKnow Source')}
</Box>
</Flex>
<Flex gap={3} mt={[2, 0]} alignItems={'center'}>
{!!userInfo && permissionData?.permission?.hasReadPer && (
<Button
variant={'primaryGhost'}
size={'xs'}
fontSize={'mini'}
border={'none'}
_hover={{
bg: 'primary.100'
}}
onClick={() => {
router.push(
`/dataset/detail?datasetId=${datasetId}&currentTab=dataCard&collectionId=${collectionId}`
);
}}
>
{t('common:core.dataset.Go Dataset')}
<MyIcon name="common/upperRight" w={4} ml={1} />
</Button>
)}
<DownloadButton
canAccessRawData={true}
onDownload={handleDownload}
onRead={handleRead}
isLoading={downloadLoading || readLoading}
/>
</Flex>
</Flex>
<Box fontSize={'mini'} color={'myGray.500'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
<Box
cursor={'pointer'}
borderRadius={'sm'}
p={1}
_hover={{
bg: 'myGray.100'
}}
onClick={onClose}
>
<MyIcon name="common/closeLight" color={'myGray.900'} w={6} />
</Box>
</Flex>
{/* header control */}
{datasetDataList.length > 0 && (
<Flex
w={'full'}
px={4}
py={2}
alignItems={'center'}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
{/* 引用序号 */}
<Flex fontSize={'mini'} mr={3} alignItems={'center'} gap={1}>
<Box as={'span'} color={'myGray.900'}>
{t('common:core.chat.Quote')} {quoteIndex + 1}
</Box>
<Box as={'span'} color={'myGray.500'}>
/
</Box>
<Box as={'span'} color={'myGray.500'}>
{filterResults.length}
</Box>
</Flex>
{/* 检索分数 */}
{!loading &&
(!isDeleted ? (
<ScoreTag {...formatScore(currentQuoteItem?.score)} />
) : (
<Flex
borderRadius={'sm'}
py={1}
px={2}
color={'red.600'}
bg={'red.50'}
alignItems={'center'}
fontSize={'11px'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'red.600'} />
{t('chat:chat.quote.deleted')}
</Flex>
))}
<Box flex={1} />
{/* 检索按钮 */}
<Flex gap={1}>
<NavButton
direction="up"
isDisabled={quoteIndex === 0}
onClick={() => handleNavigate(quoteIndex - 1)}
/>
<NavButton
direction="down"
isDisabled={quoteIndex === filterResults.length - 1}
onClick={() => handleNavigate(quoteIndex + 1)}
/>
</Flex>
</Flex>
)}
{/* quote list */}
{loading || datasetDataList.length > 0 ? (
<ScrollData flex={'1 0 0'} mt={2} px={5} py={1} isLoading={loading}>
<Flex flexDir={'column'} gap={3}>
{formatedDataList.map((item, index) => (
<CollectionQuoteItem
key={item._id}
index={index}
quoteRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>}
quoteIndex={item.quoteIndex}
setQuoteIndex={setQuoteIndex}
refreshList={() =>
currentQuoteItem?.id &&
loadData({ id: currentQuoteItem.id, index: currentQuoteItem.chunkIndex })
}
updated={item.updated}
isCurrentSelected={item.isCurrentSelected}
q={item.q}
a={item.a}
dataId={item._id}
collectionId={collectionId}
canEdit={!!userInfo && !!permissionData?.permission?.hasWritePer}
/>
))}
</Flex>
</ScrollData>
) : (
<Flex
flex={'1 0 0'}
flexDirection={'column'}
gap={1}
justifyContent={'center'}
alignItems={'center'}
>
<Box border={'1px dashed'} borderColor={'myGray.400'} p={2} borderRadius={'full'}>
<MyIcon name="common/fileNotFound" />
</Box>
<Box fontSize={'sm'} color={'myGray.500'}>
{t('chat:chat.quote.No Data')}
</Box>
</Flex>
)}
</Flex>
);
};
export default CollectionReader;

View File

@@ -0,0 +1,68 @@
import { Button } from '@chakra-ui/react';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useTranslation } from 'react-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
const DownloadButton = ({
canAccessRawData,
onDownload,
onRead,
isLoading
}: {
canAccessRawData: boolean;
onDownload: () => void;
onRead: () => void;
isLoading: boolean;
}) => {
const { t } = useTranslation();
if (canAccessRawData) {
return (
<MyMenu
size={'xs'}
Button={
<Button
variant={'whitePrimary'}
size={'xs'}
fontSize={'mini'}
leftIcon={<MyIcon name={'common/download'} w={'4'} />}
isLoading={isLoading}
>
{t('common:Download')}
</Button>
}
menuList={[
{
children: [
{
label: t('common:core.dataset.Download the parsed content'),
type: 'grayBg',
onClick: onDownload
},
{
label: t('common:core.dataset.Get the raw data'),
type: 'grayBg',
onClick: onRead
}
]
}
]}
/>
);
}
return (
<Button
variant={'whitePrimary'}
size={'xs'}
fontSize={'mini'}
leftIcon={<MyIcon name={'common/download'} w={'4'} />}
onClick={onDownload}
isLoading={isLoading}
>
{t('common:Download')}
</Button>
);
};
export default DownloadButton;

View File

@@ -0,0 +1,47 @@
import { Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
const NavButton = ({
direction,
isDisabled,
onClick
}: {
direction: 'up' | 'down';
isDisabled: boolean;
onClick: () => void;
}) => {
const isUp = direction === 'up';
const baseStyles = {
color: 'myGray.500',
border: '1px solid',
borderColor: 'myGray.150',
borderRadius: 'sm',
w: 6,
h: 6,
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s'
};
const stateStyles = isDisabled
? {
cursor: 'not-allowed',
opacity: 0.5,
_hover: {}
}
: {
cursor: 'pointer',
opacity: 1,
_hover: { bg: 'myGray.100' },
onClick
};
return (
<Flex {...baseStyles} {...stateStyles}>
<MyIcon name={isUp ? `common/solidChevronUp` : `common/solidChevronDown`} w={'18px'} />
</Flex>
);
};
export default NavButton;

View File

@@ -0,0 +1,163 @@
import { ScoreItemType } from '@/components/core/dataset/QuoteItem';
import { Box, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import ScoreTag from './ScoreTag';
import Markdown from '@/components/Markdown';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'react-i18next';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
const QuoteItem = ({
index,
icon,
sourceName,
score,
q,
a
}: {
index: number;
icon: string;
sourceName: string;
score: { primaryScore?: ScoreItemType; secondaryScore: ScoreItemType[] };
q: string;
a?: string;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const isDeleted = !q;
return (
<Box
p={2}
position={'relative'}
overflow={'hidden'}
border={'1px solid transparent'}
borderBottomColor={'myGray.150'}
wordBreak={'break-all'}
fontSize={'sm'}
_hover={{
bg: 'linear-gradient(180deg, #FBFBFC 7.61%, #F0F1F6 100%)',
borderTopColor: 'myGray.50',
'& .hover-data': { visibility: 'visible' }
}}
>
<Flex gap={2} alignItems={'center'} mb={2}>
<Box
alignItems={'center'}
fontSize={'xs'}
border={'sm'}
borderRadius={'sm'}
_hover={{
'.controller': {
display: 'flex'
}
}}
overflow={'hidden'}
display={'inline-flex'}
height={6}
>
<Flex
color={'myGray.500'}
bg={'myGray.150'}
w={4}
justifyContent={'center'}
fontSize={'10px'}
h={'full'}
alignItems={'center'}
>
{index + 1}
</Flex>
<Flex px={1.5}>
<MyIcon name={icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box
className="textEllipsis3"
wordBreak={'break-all'}
flex={'1 0 0'}
fontSize={'mini'}
color={'myGray.900'}
>
{sourceName}
</Box>
</Flex>
</Box>
{score && !isDeleted && (
<Box className="hover-data" visibility={'hidden'}>
<ScoreTag {...score} />
</Box>
)}
</Flex>
{!isDeleted ? (
<>
<Markdown source={q} />
{!!a && (
<Box>
<Markdown source={a} />
</Box>
)}
</>
) : (
<Flex
justifyContent={'center'}
alignItems={'center'}
h={'full'}
py={2}
bg={'#FAFAFA'}
color={'myGray.500'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'myGray.500'} />
{t('chat:chat.quote.deleted')}
</Flex>
)}
<Flex
className="hover-data"
position={'absolute'}
bottom={2}
right={5}
gap={1.5}
visibility={'hidden'}
>
<MyTooltip label={t('common:core.dataset.Quote Length')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={2}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
>
<MyIcon name="common/text/t" w={'14px'} mr={1} color={'myGray.500'} />
{q.length + (a?.length || 0)}
</Flex>
</MyTooltip>
<MyTooltip label={t('common:common.Copy')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() => {
copyData(q + '\n' + a);
}}
>
<MyIcon name="copy" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
</Flex>
</Box>
);
};
export default QuoteItem;

View File

@@ -0,0 +1,152 @@
import { Box, Flex } from '@chakra-ui/react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useTranslation } from 'react-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import QuoteItem from './QuoteItem';
import { useMemo } from 'react';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import { formatScore } from '@/components/core/dataset/QuoteItem';
import { metadataType } from '@/web/core/chat/context/chatItemContext';
import { getQuoteDataList } from '@/web/core/chat/api';
const QuoteReader = ({
rawSearch,
metadata,
chatTime,
onClose
}: {
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
chatTime: Date;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { chatId, appId, outLinkAuthData } = useChatStore();
const { data, loading } = useRequest2(
async () =>
await getQuoteDataList({
datasetDataIdList: rawSearch.map((item) => item.id),
chatTime,
collectionIdList: metadata.collectionIdList,
chatItemId: metadata.chatItemId,
appId,
chatId,
...outLinkAuthData
}),
{
manual: false
}
);
const filterResults = useMemo(() => {
if (!metadata.collectionId) {
return rawSearch;
}
return rawSearch.filter(
(item) => item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
);
}, [metadata, rawSearch]);
const formatedDataList = useMemo(() => {
return filterResults
.map((item) => {
const currentFilterItem = data?.quoteList.find((res) => res._id === item.id);
return {
...item,
q: currentFilterItem?.q || '',
a: currentFilterItem?.a || '',
score: formatScore(item.score),
icon: getSourceNameIcon({
sourceId: item.sourceId,
sourceName: item.sourceName
})
};
})
.sort((a, b) => {
return (b.score.primaryScore?.value || 0) - (a.score.primaryScore?.value || 0);
});
}, [data?.quoteList, filterResults]);
return (
<Flex flexDirection={'column'} h={'full'}>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
px={5}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
<Box flex={1} py={4}>
<Flex gap={2} mr={2} mb={1}>
<MyIcon
name={
metadata.sourceId && metadata.sourceName
? (getSourceNameIcon({
sourceId: metadata.sourceId,
sourceName: metadata.sourceName
}) as any)
: 'core/chat/quoteFill'
}
w={['1rem', '1.25rem']}
color={'primary.600'}
/>
<Box
maxW={['200px', '300px']}
className={'textEllipsis'}
wordBreak={'break-all'}
color={'myGray.900'}
fontWeight={'medium'}
>
{metadata.sourceName
? metadata.sourceName
: t('common:core.chat.Quote Amount', { amount: rawSearch.length })}
</Box>
</Flex>
<Box fontSize={'mini'} color={'myGray.500'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
<Box
cursor={'pointer'}
borderRadius={'sm'}
p={1}
_hover={{
bg: 'myGray.100'
}}
onClick={onClose}
>
<MyIcon name="common/closeLight" color={'myGray.900'} w={6} />
</Box>
</Flex>
{/* quote list */}
<MyBox flex={'1 0 0'} mt={2} px={5} py={1} overflow={'auto'} isLoading={loading}>
{!loading && (
<Flex flexDir={'column'} gap={3}>
{formatedDataList?.map((item, index) => (
<QuoteItem
key={item.id}
index={index}
icon={item.icon}
sourceName={item.sourceName}
score={item.score}
q={item.q}
a={item.a}
/>
))}
</Flex>
)}
</MyBox>
</Flex>
);
};
export default QuoteReader;

View File

@@ -0,0 +1,78 @@
import { ScoreItemType, scoreTheme } from '@/components/core/dataset/QuoteItem';
import { Box, Flex, Progress } from '@chakra-ui/react';
import { SearchScoreTypeMap } from '@fastgpt/global/core/dataset/constants';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'react-i18next';
const ScoreTag = (score: { primaryScore?: ScoreItemType; secondaryScore: ScoreItemType[] }) => {
const { t } = useTranslation();
return (
<Flex alignItems={'center'} flexWrap={'wrap'} gap={3}>
{score?.primaryScore && (
<MyTooltip
label={
score.secondaryScore.length ? (
<Box>
{score.secondaryScore.map((item, i) => (
<Box fontSize={'xs'} key={i}>
<Flex alignItems={'flex-start'} lineHeight={1.2} mb={1}>
<Box
px={'5px'}
borderWidth={'1px'}
borderRadius={'sm'}
mr={'2px'}
{...(scoreTheme[i] && scoreTheme[i])}
>
<Box transform={'scale(0.9)'}>#{item.index + 1}</Box>
</Box>
<Box transform={'scale(0.9)'}>
{t(SearchScoreTypeMap[item.type]?.label as any)}: {item.value.toFixed(4)}
</Box>
</Flex>
<Box h={'4px'}>
{SearchScoreTypeMap[item.type]?.showScore && (
<Progress
value={item.value * 100}
h={'4px'}
w={'100%'}
size="sm"
borderRadius={'20px'}
{...(scoreTheme[i] && {
colorScheme: scoreTheme[i].colorScheme
})}
bg="#E8EBF0"
/>
)}
</Box>
</Box>
))}
</Box>
) : (
t(SearchScoreTypeMap[score.primaryScore.type]?.desc as any)
)
}
>
<Flex
borderRadius={'sm'}
py={1}
px={2}
color={'green.600'}
bg={'green.50'}
alignItems={'center'}
fontSize={'11px'}
>
<Box>
{t(SearchScoreTypeMap[score.primaryScore.type]?.label as any)}
{SearchScoreTypeMap[score.primaryScore.type]?.showScore
? ` ${score.primaryScore.value.toFixed(4)}`
: `: ${score.primaryScore.index + 1}`}
</Box>
</Flex>
</MyTooltip>
)}
</Flex>
);
};
export default ScoreTag;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { useContextSelector } from 'use-context-selector';
import { ChatItemContext, metadataType } from '@/web/core/chat/context/chatItemContext';
import CollectionQuoteReader from './CollectionQuoteReader';
import QuoteReader from './QuoteReader';
const ChatQuoteList = ({
chatTime,
rawSearch = [],
metadata,
onClose
}: {
chatTime: Date;
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
onClose: () => void;
}) => {
const isShowReadRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
return (
<>
{metadata.collectionId && isShowReadRawSource ? (
<CollectionQuoteReader
rawSearch={rawSearch}
metadata={metadata}
chatTime={chatTime}
onClose={onClose}
/>
) : (
<QuoteReader
rawSearch={rawSearch}
metadata={metadata}
chatTime={chatTime}
onClose={onClose}
/>
)}
</>
);
};
export default ChatQuoteList;

View File

@@ -5,7 +5,6 @@ import {
delOneDatasetDataById,
getDatasetCollectionById
} from '@/web/core/dataset/api';
import { useQuery } from '@tanstack/react-query';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';

View File

@@ -178,7 +178,6 @@ const InputDataModal = ({
async (e: InputDataType) => {
if (!dataId) return Promise.reject(t('common:common.error.unKnow'));
// not exactly same
await putDatasetDataById({
dataId,
q: e.q,