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 4655c2754e
commit e061e80235
64 changed files with 2676 additions and 369 deletions

View File

@@ -1,22 +1,41 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import type { BoxProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
interface Props extends BoxProps {}
interface Props extends BoxProps {
externalTrigger?: Boolean;
}
const SideBar = (e?: Props) => {
const {
w = ['100%', '0 0 250px', '0 0 270px', '0 0 290px', '0 0 310px'],
children,
externalTrigger,
...props
} = e || {};
const [foldSideBar, setFoldSideBar] = useState(false);
const [isFolded, setIsFolded] = useState(false);
const prevExternalTriggerRef = useRef<Boolean | undefined>(undefined);
useEffect(() => {
if (externalTrigger && !prevExternalTriggerRef.current && !isFolded) {
setIsFolded(true);
}
prevExternalTriggerRef.current = externalTrigger;
}, [externalTrigger, isFolded]);
const handleToggle = () => {
const newFolded = !isFolded;
setIsFolded(newFolded);
};
return (
<Box
position={'relative'}
flex={foldSideBar ? '0 0 0' : w}
flex={isFolded ? '0 0 0' : w}
w={['100%', 0]}
h={'100%'}
zIndex={1}
@@ -40,7 +59,7 @@ const SideBar = (e?: Props) => {
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSideBar
{...(isFolded
? {
opacity: 0.6
}
@@ -48,16 +67,16 @@ const SideBar = (e?: Props) => {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSideBar(!foldSideBar)}
onClick={handleToggle}
>
<MyIcon
name={'common/backLight'}
transform={foldSideBar ? 'rotate(180deg)' : ''}
transform={isFolded ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box position={'relative'} h={'100%'} overflow={foldSideBar ? 'hidden' : 'visible'}>
<Box position={'relative'} h={'100%'} overflow={isFolded ? 'hidden' : 'visible'}>
{children}
</Box>
</Box>

View File

@@ -0,0 +1,96 @@
import React, { useMemo } from 'react';
import { Box, useTheme } from '@chakra-ui/react';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import QuoteItem, { formatScore } from '@/components/core/dataset/QuoteItem';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { getQuoteDataList } from '@/web/core/chat/api';
const QuoteList = React.memo(function QuoteList({
chatItemId,
rawSearch = [],
chatTime
}: {
chatItemId?: string;
rawSearch: SearchDataResponseItemType[];
chatTime: Date;
}) {
const theme = useTheme();
const { chatId, appId, outLinkAuthData } = useChatStore();
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
chatItemId,
appId: v.appId,
chatId: v.chatId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
const { data } = useRequest2(
async () =>
await getQuoteDataList({
datasetDataIdList: rawSearch.map((item) => item.id),
chatTime,
collectionIdList: [...new Set(rawSearch.map((item) => item.collectionId))],
chatItemId: chatItemId || '',
appId,
chatId,
...outLinkAuthData
}),
{
manual: false
}
);
const formatedDataList = useMemo(() => {
return rawSearch
.map((item) => {
const currentFilterItem = data?.quoteList.find((res) => res._id === item.id);
return {
...item,
q: currentFilterItem?.q || '',
a: currentFilterItem?.a || ''
};
})
.sort((a, b) => {
const aScore = formatScore(a.score);
const bScore = formatScore(b.score);
return (bScore.primaryScore?.value || 0) - (aScore.primaryScore?.value || 0);
});
}, [data?.quoteList, rawSearch]);
return (
<>
{formatedDataList.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={showRawSource}
canEditDataset={showRouteToDatasetDetail}
{...RawSourceBoxProps}
/>
</Box>
))}
</>
);
});
export default QuoteList;

View File

@@ -1,129 +0,0 @@
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';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
const QuoteModal = ({
rawSearch = [],
onClose,
chatItemId,
metadata
}: {
rawSearch: SearchDataResponseItemType[];
onClose: () => void;
chatItemId: string;
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]
);
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
appId: v.appId,
chatId: v.chatId,
chatItemId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
return (
<>
<MyModal
isOpen={true}
onClose={onClose}
h={['90vh', '80vh']}
isCentered
minW={['90vw', '600px']}
iconSrc={!!metadata ? undefined : getWebReqUrl('/imgs/modal/quote.svg')}
title={
<Box>
{metadata ? (
<RawSourceBox {...metadata} {...RawSourceBoxProps} canView={showRawSource} />
) : (
<>{t('common:core.chat.Quote Amount', { amount: rawSearch.length })}</>
)}
<Box fontSize={'xs'} color={'myGray.500'} fontWeight={'normal'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
}
>
<ModalBody>
<QuoteList rawSearch={filterResults} chatItemId={chatItemId} />
</ModalBody>
</MyModal>
</>
);
};
export default QuoteModal;
export const QuoteList = React.memo(function QuoteList({
chatItemId,
rawSearch = []
}: {
chatItemId?: string;
rawSearch: SearchDataResponseItemType[];
}) {
const theme = useTheme();
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
chatItemId,
appId: v.appId,
chatId: v.chatId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
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={showRawSource}
canEditDataset={showRouteToDatasetDetail}
{...RawSourceBoxProps}
/>
</Box>
))}
</>
);
});

View File

@@ -14,8 +14,8 @@ import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useSize } from 'ahooks';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
const QuoteModal = dynamic(() => import('./QuoteModal'));
const ContextModal = dynamic(() => import('./ContextModal'));
const WholeResponseModal = dynamic(() => import('../../../components/WholeResponseModal'));
@@ -30,6 +30,7 @@ const ResponseTags = ({
const { t } = useTranslation();
const quoteListRef = React.useRef<HTMLDivElement>(null);
const dataId = historyItem.dataId;
const chatTime = historyItem.time || new Date();
const {
totalQuoteList: quoteList = [],
@@ -38,17 +39,12 @@ const ResponseTags = ({
historyPreviewLength = 0
} = useMemo(() => addStatisticalDataToHistoryItem(historyItem), [historyItem]);
const [quoteModalData, setQuoteModalData] = useState<{
rawSearch: SearchDataResponseItemType[];
metadata?: {
collectionId: string;
sourceId?: string;
sourceName: string;
};
}>();
const [quoteFolded, setQuoteFolded] = useState<boolean>(true);
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const notSharePage = useMemo(() => chatType !== 'share', [chatType]);
const {
@@ -81,7 +77,8 @@ const ResponseTags = ({
sourceName: item.sourceName,
sourceId: item.sourceId,
icon: getSourceNameIcon({ sourceId: item.sourceId, sourceName: item.sourceName }),
collectionId: item.collectionId
collectionId: item.collectionId,
datasetId: item.datasetId
}));
}, [quoteList]);
@@ -99,7 +96,11 @@ const ResponseTags = ({
<>
<Flex justifyContent={'space-between'} alignItems={'center'}>
<Box width={'100%'}>
<ChatBoxDivider icon="core/chat/quoteFill" text={t('common:core.chat.Quote')} />
<ChatBoxDivider
icon="core/chat/quoteFill"
text={t('common:core.chat.Quote')}
iconColor="#E82F72"
/>
</Box>
{quoteFolded && quoteIsOverflow && (
<MyIcon
@@ -135,15 +136,13 @@ const ResponseTags = ({
: {}
}
>
{sourceList.map((item) => {
{sourceList.map((item, index) => {
return (
<MyTooltip key={item.collectionId} label={t('common:core.chat.quote.Read Quote')}>
<Flex
alignItems={'center'}
fontSize={'xs'}
border={'sm'}
py={1.5}
px={2}
borderRadius={'sm'}
_hover={{
'.controller': {
@@ -155,20 +154,46 @@ const ResponseTags = ({
cursor={'pointer'}
onClick={(e) => {
e.stopPropagation();
setQuoteModalData({
setQuoteData({
chatTime,
rawSearch: quoteList,
metadata: {
collectionId: item.collectionId,
sourceId: item.sourceId,
sourceName: item.sourceName
collectionIdList: [
...new Set(quoteList.map((item) => item.collectionId))
],
sourceId: item.sourceId || '',
sourceName: item.sourceName,
datasetId: item.datasetId,
chatItemId: historyItem.dataId
}
});
}}
height={6}
>
<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
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={item.icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box
className="textEllipsis3"
wordBreak={'break-all'}
flex={'1 0 0'}
fontSize={'mini'}
>
{item.sourceName}
</Box>
</Flex>
</Flex>
</MyTooltip>
);
@@ -196,7 +221,22 @@ const ResponseTags = ({
colorSchema="blue"
type="borderSolid"
cursor={'pointer'}
onClick={() => setQuoteModalData({ rawSearch: quoteList })}
onClick={(e) => {
e.stopPropagation();
setQuoteData({
chatTime,
rawSearch: quoteList,
metadata: {
collectionId: '',
collectionIdList: [...new Set(quoteList.map((item) => item.collectionId))],
chatItemId: historyItem.dataId,
sourceId: '',
sourceName: '',
datasetId: ''
}
});
}}
>
{t('chat:citations', { num: quoteList.length })}
</MyTag>
@@ -246,15 +286,10 @@ const ResponseTags = ({
</Flex>
)}
{!!quoteModalData && (
<QuoteModal
{...quoteModalData}
chatItemId={historyItem.dataId}
onClose={() => setQuoteModalData(undefined)}
/>
)}
{isOpenContextModal && <ContextModal dataId={dataId} onClose={onCloseContextModal} />}
{isOpenWholeModal && <WholeResponseModal dataId={dataId} onClose={onCloseWholeModal} />}
{isOpenWholeModal && (
<WholeResponseModal dataId={dataId} chatTime={chatTime} onClose={onCloseWholeModal} />
)}
</>
);
};

View File

@@ -12,12 +12,18 @@ const RenderResponseDetail = () => {
const isChatting = useContextSelector(PluginRunContext, (v) => v.isChatting);
const responseData = chatRecords?.[1]?.responseData || [];
const chatTime = new Date();
return isChatting ? (
<>{t('chat:in_progress')}</>
) : (
<Box flex={'1 0 0'} h={'100%'} overflow={'auto'}>
<ResponseBox useMobile={true} response={responseData} dataId={chatRecords?.[1]?.dataId} />
<ResponseBox
useMobile={true}
response={responseData}
dataId={chatRecords?.[1]?.dataId}
chatTime={chatTime}
/>
</Box>
);
};

View File

@@ -3,11 +3,19 @@ import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type.d';
const ChatBoxDivider = ({ icon, text }: { icon: IconNameType; text: string }) => {
const ChatBoxDivider = ({
icon,
text,
iconColor
}: {
icon: IconNameType;
text: string;
iconColor?: string;
}) => {
return (
<Box>
<Flex alignItems={'center'} py={2} gap={2}>
<MyIcon name={icon} w={'14px'} color={'myGray.900'} />
<MyIcon name={icon} w={'14px'} color={iconColor || 'myGray.900'} />
<Box color={'myGray.500'} fontSize={'sm'}>
{text}
</Box>

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import MyModal from '@fastgpt/web/components/common/MyModal';
import Markdown from '@/components/Markdown';
import { QuoteList } from '../ChatContainer/ChatBox/components/QuoteModal';
import QuoteList from '../ChatContainer/ChatBox/components/QuoteList';
import { DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
@@ -32,11 +32,13 @@ type sideTabItemType = {
export const WholeResponseContent = ({
activeModule,
hideTabs,
dataId
dataId,
chatTime
}: {
activeModule: ChatHistoryItemResType;
hideTabs?: boolean;
dataId?: string;
chatTime?: Date;
}) => {
const { t } = useTranslation();
@@ -263,7 +265,13 @@ export const WholeResponseContent = ({
{activeModule.quoteList && activeModule.quoteList.length > 0 && (
<Row
label={t('common:core.chat.response.module quoteList')}
rawDom={<QuoteList chatItemId={dataId} rawSearch={activeModule.quoteList} />}
rawDom={
<QuoteList
chatItemId={dataId}
chatTime={chatTime || new Date()}
rawSearch={activeModule.quoteList}
/>
}
/>
)}
</>
@@ -562,11 +570,13 @@ const SideTabItem = ({
export const ResponseBox = React.memo(function ResponseBox({
response,
dataId,
chatTime,
hideTabs = false,
useMobile = false
}: {
response: ChatHistoryItemResType[];
dataId?: string;
chatTime: Date;
hideTabs?: boolean;
useMobile?: boolean;
}) {
@@ -689,7 +699,12 @@ export const ResponseBox = React.memo(function ResponseBox({
</Box>
</Box>
<Box flex={'5 0 0'} w={0} height={'100%'}>
<WholeResponseContent dataId={dataId} activeModule={activeModule} hideTabs={hideTabs} />
<WholeResponseContent
dataId={dataId}
activeModule={activeModule}
hideTabs={hideTabs}
chatTime={chatTime}
/>
</Box>
</Flex>
) : (
@@ -753,6 +768,7 @@ export const ResponseBox = React.memo(function ResponseBox({
dataId={dataId}
activeModule={activeModule}
hideTabs={hideTabs}
chatTime={chatTime}
/>
</Box>
</Flex>
@@ -763,7 +779,15 @@ export const ResponseBox = React.memo(function ResponseBox({
);
});
const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId: string }) => {
const WholeResponseModal = ({
onClose,
dataId,
chatTime
}: {
onClose: () => void;
dataId: string;
chatTime: Date;
}) => {
const { t } = useTranslation();
const { getHistoryResponseData } = useContextSelector(ChatBoxContext, (v) => v);
@@ -792,7 +816,7 @@ const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId:
}
>
{!!response?.length ? (
<ResponseBox response={response} dataId={dataId} />
<ResponseBox response={response} dataId={dataId} chatTime={chatTime} />
) : (
<EmptyTip text={t('chat:no_workflow_response')} />
)}

View File

@@ -14,8 +14,8 @@ import Markdown from '@/components/Markdown';
const InputDataModal = dynamic(() => import('@/pageComponents/dataset/detail/InputDataModal'));
type ScoreItemType = SearchDataResponseItemType['score'][0];
const scoreTheme: Record<
export type ScoreItemType = SearchDataResponseItemType['score'][0];
export const scoreTheme: Record<
string,
{
color: string;
@@ -44,6 +44,47 @@ const scoreTheme: Record<
}
};
export const formatScore = (score: ScoreItemType[]) => {
if (!Array.isArray(score)) {
return {
primaryScore: undefined,
secondaryScore: []
};
}
// rrf -> rerank -> embedding -> fullText 优先级
let rrfScore: ScoreItemType | undefined = undefined;
let reRankScore: ScoreItemType | undefined = undefined;
let embeddingScore: ScoreItemType | undefined = undefined;
let fullTextScore: ScoreItemType | undefined = undefined;
score.forEach((item) => {
if (item.type === SearchScoreTypeEnum.rrf) {
rrfScore = item;
} else if (item.type === SearchScoreTypeEnum.reRank) {
reRankScore = item;
} else if (item.type === SearchScoreTypeEnum.embedding) {
embeddingScore = item;
} else if (item.type === SearchScoreTypeEnum.fullText) {
fullTextScore = item;
}
});
const primaryScore = (rrfScore ||
reRankScore ||
embeddingScore ||
fullTextScore) as unknown as ScoreItemType;
const secondaryScore = [rrfScore, reRankScore, embeddingScore, fullTextScore].filter(
// @ts-ignore
(item) => item && primaryScore && item.type !== primaryScore.type
) as unknown as ScoreItemType[];
return {
primaryScore,
secondaryScore
};
};
const QuoteItem = ({
quoteItem,
canViewSource,
@@ -58,44 +99,7 @@ const QuoteItem = ({
const [editInputData, setEditInputData] = useState<{ dataId: string; collectionId: string }>();
const score = useMemo(() => {
if (!Array.isArray(quoteItem.score)) {
return {
primaryScore: undefined,
secondaryScore: []
};
}
// rrf -> rerank -> embedding -> fullText 优先级
let rrfScore: ScoreItemType | undefined = undefined;
let reRankScore: ScoreItemType | undefined = undefined;
let embeddingScore: ScoreItemType | undefined = undefined;
let fullTextScore: ScoreItemType | undefined = undefined;
quoteItem.score.forEach((item) => {
if (item.type === SearchScoreTypeEnum.rrf) {
rrfScore = item;
} else if (item.type === SearchScoreTypeEnum.reRank) {
reRankScore = item;
} else if (item.type === SearchScoreTypeEnum.embedding) {
embeddingScore = item;
} else if (item.type === SearchScoreTypeEnum.fullText) {
fullTextScore = item;
}
});
const primaryScore = (rrfScore ||
reRankScore ||
embeddingScore ||
fullTextScore) as unknown as ScoreItemType;
const secondaryScore = [rrfScore, reRankScore, embeddingScore, fullTextScore].filter(
// @ts-ignore
(item) => item && primaryScore && item.type !== primaryScore.type
) as unknown as ScoreItemType[];
return {
primaryScore,
secondaryScore
};
return formatScore(quoteItem.score);
}, [quoteItem.score]);
return (