Commit 7d4943e9 by Xianquan Committed by GitHub

fix(chat): restore share context and mobile citation details (#7422)

* fix(chat): restore share context and mobile citation details

* fix(chat): restore share context and mobile citation details

* submodule

* fix(chat): handle hydration failures and mobile quote actions

* chore(chat): keep quote review scope focused

---------

Co-authored-by: Archer <545436317@qq.com>
parent 07e49cde
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { getQuoteData } from '@/web/core/dataset/api/data'; import { getQuoteData } from '@/web/core/dataset/api/data';
...@@ -38,6 +39,7 @@ export type AProps = { ...@@ -38,6 +39,7 @@ export type AProps = {
sourceName?: string; sourceName?: string;
datasetId?: string; datasetId?: string;
quoteId?: string; quoteId?: string;
singleQuote?: boolean;
}) => void; }) => void;
}; };
...@@ -77,6 +79,7 @@ const CiteLink = React.memo(function CiteLink({ ...@@ -77,6 +79,7 @@ const CiteLink = React.memo(function CiteLink({
showAnimation showAnimation
}: { id: string; showAnimation?: boolean } & AProps) { }: { id: string; showAnimation?: boolean } & AProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isPc } = useSystem();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const { const {
...@@ -95,10 +98,53 @@ const CiteLink = React.memo(function CiteLink({ ...@@ -95,10 +98,53 @@ const CiteLink = React.memo(function CiteLink({
[sourceData] [sourceData]
); );
const handleOpenMobileQuote = () => {
onOpenCiteModal?.({
quoteId: id,
singleQuote: true
});
};
if (!isObjectId(id)) { if (!isObjectId(id)) {
return <></>; return <></>;
} }
const citeButton = (
<Button
variant={'unstyled'}
display={'inline-flex'}
minH={0}
minW={0}
ml={'4px'}
boxSize={'20px'}
p={'4px'}
borderRadius={'full'}
bg={'myGray.150'}
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
aria-label={t('common:chat.quote_detail_title')}
onClick={!isPc ? handleOpenMobileQuote : undefined}
_hover={{
'.cite-link-icon': {
color: 'primary.600'
}
}}
>
<MyIcon
className="cite-link-icon"
name={'common/link'}
w={'12px'}
h={'12px'}
color={'myGray.400'}
/>
</Button>
);
if (!isPc) {
return onOpenCiteModal ? citeButton : null;
}
return ( return (
<Popover <Popover
isLazy isLazy
...@@ -115,35 +161,7 @@ const CiteLink = React.memo(function CiteLink({ ...@@ -115,35 +161,7 @@ const CiteLink = React.memo(function CiteLink({
trigger={'hover'} trigger={'hover'}
gutter={4} gutter={4}
> >
<PopoverTrigger> <PopoverTrigger>{citeButton}</PopoverTrigger>
<Button
variant={'unstyled'}
display={['none', 'inline-flex']}
minH={0}
minW={0}
ml={'4px'}
boxSize={'20px'}
p={'4px'}
borderRadius={'full'}
bg={'myGray.150'}
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
_hover={{
'.cite-link-icon': {
color: 'primary.600'
}
}}
>
<MyIcon
className="cite-link-icon"
name={'common/link'}
w={'12px'}
h={'12px'}
color={'myGray.400'}
/>
</Button>
</PopoverTrigger>
<PopoverContent boxShadow={'lg'} w={'500px'} maxW={'90vw'} py={4}> <PopoverContent boxShadow={'lg'} w={'500px'} maxW={'90vw'} py={4}>
<MyBox isLoading={loading || showAnimation}> <MyBox isLoading={loading || showAnimation}>
<PopoverArrow /> <PopoverArrow />
......
...@@ -10,7 +10,10 @@ import { useTranslation } from 'next-i18next'; ...@@ -10,7 +10,10 @@ import { useTranslation } from 'next-i18next';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import type { SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type'; import type { SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; import {
ChatItemContext,
type OnOpenCiteModalProps
} from '@/web/core/chat/context/chatItemContext';
import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils'; import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
...@@ -202,44 +205,47 @@ const ChatItem = (props: Props) => { ...@@ -202,44 +205,47 @@ const ChatItem = (props: Props) => {
}, [chat.obj, chat.value]); }, [chat.obj, chat.value]);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData); const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const onOpenCiteModal = useMemoizedFn( const onOpenCiteModal = useMemoizedFn((item?: OnOpenCiteModalProps) => {
(item?: { const selectedQuote = item?.quoteId
collectionId?: string; ? quoteList.find((quote) => quote.id === item.quoteId)
sourceId?: string; : undefined;
sourceName?: string; const isSingleQuote = item?.singleQuote === true && !!selectedQuote;
datasetId?: string;
quoteId?: string; // 引用已经不在当前消息的 quoteList 时,不能打开空的单条阅读器。
}) => { if (item?.singleQuote && !isSingleQuote) return;
const collectionIdList = item?.collectionId
? [item.collectionId] const collectionId = item?.collectionId ?? selectedQuote?.collectionId;
: [...new Set(quoteList.map((item) => item.collectionId))]; const rawSearch = isSingleQuote && selectedQuote ? [selectedQuote] : quoteList;
const collectionIdList = collectionId
setCiteModalData({ ? [collectionId]
rawSearch: quoteList, : [...new Set(quoteList.map((quote) => quote.collectionId))];
metadata:
item?.collectionId && isShowFullText setCiteModalData({
? { rawSearch,
...chatAuthTarget, singleQuote: isSingleQuote,
chatId: chatId, metadata:
chatItemDataId: chat.dataId, collectionId && isShowFullText
collectionId: item.collectionId, ? {
collectionIdList, ...chatAuthTarget,
sourceId: item.sourceId || '', chatId,
sourceName: item.sourceName || '', chatItemDataId: chat.dataId,
datasetId: item.datasetId || '', collectionId,
quoteId: item.quoteId collectionIdList,
} sourceId: item?.sourceId ?? selectedQuote?.sourceId ?? '',
: { sourceName: item?.sourceName ?? selectedQuote?.sourceName ?? '',
...chatAuthTarget, datasetId: item?.datasetId ?? selectedQuote?.datasetId ?? '',
chatId: chatId, quoteId: item?.quoteId
chatItemDataId: chat.dataId, }
collectionIdList, : {
sourceId: item?.sourceId, ...chatAuthTarget,
sourceName: item?.sourceName chatId,
} chatItemDataId: chat.dataId,
}); collectionIdList,
} sourceId: item?.sourceId ?? selectedQuote?.sourceId,
); sourceName: item?.sourceName ?? selectedQuote?.sourceName
}
});
});
return ( return (
<Flex data-chat-id={chat.dataId} direction={'column'} gap={4}> <Flex data-chat-id={chat.dataId} direction={'column'} gap={4}>
......
...@@ -329,6 +329,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -329,6 +329,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
<ChatQuoteList <ChatQuoteList
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</Box> </Box>
......
...@@ -112,6 +112,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => { ...@@ -112,6 +112,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
<ChatQuoteList <ChatQuoteList
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</Box> </Box>
......
...@@ -234,6 +234,7 @@ const DetailLogsModal = ({ ...@@ -234,6 +234,7 @@ const DetailLogsModal = ({
<ChatQuoteList <ChatQuoteList
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</Box> </Box>
......
...@@ -166,6 +166,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) => ...@@ -166,6 +166,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
<ChatQuoteList <ChatQuoteList
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</Box> </Box>
......
...@@ -13,6 +13,7 @@ const CollectionQuoteItem = ({ ...@@ -13,6 +13,7 @@ const CollectionQuoteItem = ({
setQuoteIndex, setQuoteIndex,
refreshList, refreshList,
canEdit, canEdit,
alwaysShowCopy = false,
updated, updated,
isCurrentSelected, isCurrentSelected,
...@@ -26,6 +27,7 @@ const CollectionQuoteItem = ({ ...@@ -26,6 +27,7 @@ const CollectionQuoteItem = ({
setQuoteIndex: (quoteIndex: number) => void; setQuoteIndex: (quoteIndex: number) => void;
refreshList: () => void; refreshList: () => void;
canEdit: boolean; canEdit: boolean;
alwaysShowCopy?: boolean;
updated?: boolean; updated?: boolean;
isCurrentSelected: boolean; isCurrentSelected: boolean;
...@@ -46,6 +48,7 @@ const CollectionQuoteItem = ({ ...@@ -46,6 +48,7 @@ const CollectionQuoteItem = ({
quoteRefs.current.set(dataId, el); quoteRefs.current.set(dataId, el);
}} }}
p={'12px'} p={'12px'}
pb={alwaysShowCopy ? '40px' : '12px'}
cursor={hasBeenSearched ? 'pointer' : 'default'} cursor={hasBeenSearched ? 'pointer' : 'default'}
bg={isCurrentSelected ? 'blue.50' : ''} bg={isCurrentSelected ? 'blue.50' : ''}
position={'relative'} position={'relative'}
...@@ -101,7 +104,7 @@ const CollectionQuoteItem = ({ ...@@ -101,7 +104,7 @@ const CollectionQuoteItem = ({
bottom={'12px'} bottom={'12px'}
right={'12px'} right={'12px'}
gap={1.5} gap={1.5}
visibility={'hidden'} visibility={alwaysShowCopy ? 'visible' : 'hidden'}
> >
<MyTooltip label={t('common:Copy')}> <MyTooltip label={t('common:Copy')}>
<Flex <Flex
...@@ -117,7 +120,10 @@ const CollectionQuoteItem = ({ ...@@ -117,7 +120,10 @@ const CollectionQuoteItem = ({
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)' '0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
} }
cursor={'pointer'} cursor={'pointer'}
onClick={() => copyData(`${q}${a ? '\n' + a : ''}`)} onClick={(e) => {
e.stopPropagation();
copyData([q, a].filter(Boolean).join('\n'));
}}
> >
<MyIcon name="copy" w={'14px'} color={'myGray.500'} /> <MyIcon name="copy" w={'14px'} color={'myGray.500'} />
</Flex> </Flex>
......
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import { type SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type'; import { type SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
...@@ -24,11 +23,13 @@ import { getChatAuthTargetInput } from '@/web/core/chat/utils'; ...@@ -24,11 +23,13 @@ import { getChatAuthTargetInput } from '@/web/core/chat/utils';
const CollectionReader = ({ const CollectionReader = ({
rawSearch, rawSearch,
metadata, metadata,
singleQuote = false,
onClose, onClose,
onBack onBack
}: { }: {
rawSearch: SearchDataResponseQuoteListItemType[]; rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetCollectionQuoteDataProps; metadata: GetCollectionQuoteDataProps;
singleQuote?: boolean;
onClose: () => void; onClose: () => void;
onBack?: () => void; onBack?: () => void;
}) => { }) => {
...@@ -118,20 +119,23 @@ const CollectionReader = ({ ...@@ -118,20 +119,23 @@ const CollectionReader = ({
[datasetDataList, currentQuoteItem?.id, isLoading] [datasetDataList, currentQuoteItem?.id, isLoading]
); );
const formatedDataList = useMemo( const formatedDataList = useMemo(() => {
() => // 分块接口会同时返回锚点前后的数据,单条模式只保留当前引用对应的分块。
datasetDataList.map((item) => { const visibleDataList = singleQuote
const isCurrentSelected = currentQuoteItem?.id === item._id; ? datasetDataList.filter((item) => item._id === currentQuoteItem?.id)
const quoteIndex = filterResults.findIndex((res) => res.id === item._id); : datasetDataList;
return { return visibleDataList.map((item) => {
...item, const isCurrentSelected = currentQuoteItem?.id === item._id;
isCurrentSelected, const quoteIndex = filterResults.findIndex((res) => res.id === item._id);
quoteIndex
}; return {
}), ...item,
[currentQuoteItem?.id, datasetDataList, filterResults] isCurrentSelected,
); quoteIndex
};
});
}, [currentQuoteItem?.id, datasetDataList, filterResults, singleQuote]);
const canShowSourceActions = useMemo( const canShowSourceActions = useMemo(
() => () =>
...@@ -241,7 +245,7 @@ const CollectionReader = ({ ...@@ -241,7 +245,7 @@ const CollectionReader = ({
</Box> </Box>
{/* header control */} {/* header control */}
{datasetDataList.length > 0 && ( {!singleQuote && datasetDataList.length > 0 && (
<Flex <Flex
w={'full'} w={'full'}
h={'56px'} h={'56px'}
...@@ -299,7 +303,7 @@ const CollectionReader = ({ ...@@ -299,7 +303,7 @@ const CollectionReader = ({
)} )}
{/* quote list */} {/* quote list */}
{isLoading || datasetDataList.length > 0 ? ( {isLoading || formatedDataList.length > 0 ? (
<ScrollData flex={'1 0 0'} p={'12px'}> <ScrollData flex={'1 0 0'} p={'12px'}>
<Flex flexDir={'column'} gap={'12px'}> <Flex flexDir={'column'} gap={'12px'}>
{formatedDataList.map((item) => ( {formatedDataList.map((item) => (
...@@ -316,6 +320,7 @@ const CollectionReader = ({ ...@@ -316,6 +320,7 @@ const CollectionReader = ({
dataId={item._id} dataId={item._id}
collectionId={collectionId} collectionId={collectionId}
canEdit={!!userInfo && !!datasetData?.permission?.hasWritePer} canEdit={!!userInfo && !!datasetData?.permission?.hasWritePer}
alwaysShowCopy={singleQuote}
/> />
))} ))}
</Flex> </Flex>
...@@ -337,11 +342,13 @@ const CollectionReader = ({ ...@@ -337,11 +342,13 @@ const CollectionReader = ({
</Flex> </Flex>
)} )}
<Box px={5} py={3}> {!singleQuote && (
<Flex fontSize={'mini'} justifyContent={'center'} color={'myGray.500'}> <Box px={5} py={3}>
{t('chat:quote_result_notice')} <Flex fontSize={'mini'} justifyContent={'center'} color={'myGray.500'}>
</Flex> {t('chat:quote_result_notice')}
</Box> </Flex>
</Box>
)}
</MyBox> </MyBox>
); );
}; };
......
...@@ -9,12 +9,14 @@ const QuoteItem = ({ ...@@ -9,12 +9,14 @@ const QuoteItem = ({
icon, icon,
sourceName, sourceName,
onClick, onClick,
alwaysShowCopy = false,
q, q,
a a
}: { }: {
icon: string; icon: string;
sourceName: string; sourceName: string;
onClick?: () => void; onClick?: () => void;
alwaysShowCopy?: boolean;
q: string; q: string;
a?: string; a?: string;
}) => { }) => {
...@@ -25,6 +27,7 @@ const QuoteItem = ({ ...@@ -25,6 +27,7 @@ const QuoteItem = ({
return ( return (
<Box <Box
p={'12px'} p={'12px'}
pb={alwaysShowCopy ? '40px' : '12px'}
position={'relative'} position={'relative'}
overflow={'hidden'} overflow={'hidden'}
borderRadius={'6px'} borderRadius={'6px'}
...@@ -92,7 +95,7 @@ const QuoteItem = ({ ...@@ -92,7 +95,7 @@ const QuoteItem = ({
bottom={2} bottom={2}
right={5} right={5}
gap={1.5} gap={1.5}
visibility={'hidden'} visibility={alwaysShowCopy ? 'visible' : 'hidden'}
> >
<MyTooltip label={t('common:Copy')}> <MyTooltip label={t('common:Copy')}>
<Flex <Flex
...@@ -110,7 +113,7 @@ const QuoteItem = ({ ...@@ -110,7 +113,7 @@ const QuoteItem = ({
cursor={'pointer'} cursor={'pointer'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
copyData(q + '\n' + a); copyData([q, a].filter(Boolean).join('\n'));
}} }}
> >
<MyIcon name="copy" w={'14px'} color={'myGray.500'} /> <MyIcon name="copy" w={'14px'} color={'myGray.500'} />
......
...@@ -23,11 +23,13 @@ type MobileQuoteTab = 'detail' | 'source'; ...@@ -23,11 +23,13 @@ type MobileQuoteTab = 'detail' | 'source';
const QuoteReader = ({ const QuoteReader = ({
rawSearch, rawSearch,
metadata, metadata,
singleQuote = false,
onClose, onClose,
onOpenCollectionQuote onOpenCollectionQuote
}: { }: {
rawSearch: SearchDataResponseQuoteListItemType[]; rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetAllQuoteDataProps; metadata: GetAllQuoteDataProps;
singleQuote?: boolean;
onClose: () => void; onClose: () => void;
onOpenCollectionQuote: (metadata: GetCollectionQuoteDataProps) => void; onOpenCollectionQuote: (metadata: GetCollectionQuoteDataProps) => void;
}) => { }) => {
...@@ -132,7 +134,13 @@ const QuoteReader = ({ ...@@ -132,7 +134,13 @@ const QuoteReader = ({
}; };
const quoteDetailList = ( const quoteDetailList = (
<MyBox flex={'1 0 0'} p={'12px'} overflow={'auto'} isLoading={loading}> <MyBox
flex={'1 0 0'}
p={'12px'}
pt={singleQuote ? '48px' : '12px'}
overflow={'auto'}
isLoading={loading}
>
{!loading && ( {!loading && (
<Flex flexDir={'column'} gap={'12px'}> <Flex flexDir={'column'} gap={'12px'}>
{formatedDataList?.map((item) => ( {formatedDataList?.map((item) => (
...@@ -142,7 +150,8 @@ const QuoteReader = ({ ...@@ -142,7 +150,8 @@ const QuoteReader = ({
sourceName={item.sourceName} sourceName={item.sourceName}
q={item.q} q={item.q}
a={item.a} a={item.a}
onClick={item.sourceId ? () => openCollectionQuote(item) : undefined} alwaysShowCopy={singleQuote}
onClick={singleQuote || !item.sourceId ? undefined : () => openCollectionQuote(item)}
/> />
))} ))}
</Flex> </Flex>
...@@ -189,25 +198,73 @@ const QuoteReader = ({ ...@@ -189,25 +198,73 @@ const QuoteReader = ({
); );
return ( return (
<Flex flexDirection={'column'} minH={'full'} h={'full'}> <Flex flexDirection={'column'} minH={'full'} h={'full'} position={'relative'}>
{/* title */} {!singleQuote && (
<Flex <>
w={'full'} {/* title */}
alignItems={'center'} <Flex
justifyContent={'center'} w={'full'}
px={6} alignItems={'center'}
py={'16px'} justifyContent={'center'}
borderBottom={'1px solid'} px={6}
borderColor={'myGray.150'} py={'16px'}
position={'relative'} borderBottom={'1px solid'}
> borderColor={'myGray.150'}
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'16px'}> position={'relative'}
{isPc ? t('common:chat.quote_detail_title') : t('common:core.chat.Quote')} >
</Box> <Box color={'myGray.900'} fontWeight={'medium'} fontSize={'16px'}>
{isPc ? t('common:chat.quote_detail_title') : t('common:core.chat.Quote')}
</Box>
<Flex
position={'absolute'}
right={4}
justifyContent={'center'}
alignItems={'center'}
cursor={'pointer'}
borderRadius={'sm'}
_hover={{
bg: 'myGray.100'
}}
p={2}
onClick={onClose}
>
<MyIcon name="common/closeLight" color={'myGray.900'} w={4} />
</Flex>
</Flex>
{!isPc && (
<Box px={'12px'} py={'10px'} borderBottom={'1px solid'} borderColor={'myGray.150'}>
<FillRowTabs<MobileQuoteTab>
w={'full'}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
labelSize="16px"
list={[
{
label: t('common:chat.quote_detail_title'),
value: 'detail'
},
{
label: t('chat:quote_source_title'),
value: 'source'
}
]}
value={mobileTab}
onChange={setMobileTab}
/>
</Box>
)}
</>
)}
{singleQuote && (
<Flex <Flex
position={'absolute'} position={'absolute'}
top={2}
right={4} right={4}
zIndex={1}
justifyContent={'center'} justifyContent={'center'}
alignItems={'center'} alignItems={'center'}
cursor={'pointer'} cursor={'pointer'}
...@@ -220,40 +277,18 @@ const QuoteReader = ({ ...@@ -220,40 +277,18 @@ const QuoteReader = ({
> >
<MyIcon name="common/closeLight" color={'myGray.900'} w={4} /> <MyIcon name="common/closeLight" color={'myGray.900'} w={4} />
</Flex> </Flex>
</Flex>
{!isPc && (
<Box px={'12px'} py={'10px'} borderBottom={'1px solid'} borderColor={'myGray.150'}>
<FillRowTabs<MobileQuoteTab>
w={'full'}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
labelSize="16px"
list={[
{
label: t('common:chat.quote_detail_title'),
value: 'detail'
},
{
label: t('chat:quote_source_title'),
value: 'source'
}
]}
value={mobileTab}
onChange={setMobileTab}
/>
</Box>
)} )}
{/* quote list */} {/* quote list */}
{isPc || mobileTab === 'detail' ? quoteDetailList : quoteSourceList} {singleQuote || isPc || mobileTab === 'detail' ? quoteDetailList : quoteSourceList}
<Box px={5} py={3}> {!singleQuote && (
<Flex fontSize={'mini'} color={'myGray.500'} justifyContent={'center'}> <Box px={5} py={3}>
{t('chat:quote_result_notice')} <Flex fontSize={'mini'} color={'myGray.500'} justifyContent={'center'}>
</Flex> {t('chat:quote_result_notice')}
</Box> </Flex>
</Box>
)}
</Flex> </Flex>
); );
}; };
......
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { type SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type'; import { type SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type';
import { import {
type GetCollectionQuoteDataProps, type GetCollectionQuoteDataProps,
...@@ -7,28 +7,31 @@ import { ...@@ -7,28 +7,31 @@ import {
import CollectionQuoteReader from './CollectionQuoteReader'; import CollectionQuoteReader from './CollectionQuoteReader';
import QuoteReader from './QuoteReader'; import QuoteReader from './QuoteReader';
const ChatQuoteList = ({ type Props = {
rawSearch = [],
metadata,
onClose
}: {
rawSearch: SearchDataResponseQuoteListItemType[]; rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetQuoteProps; metadata: GetQuoteProps;
singleQuote?: boolean;
onClose: () => void; onClose: () => void;
}) => { };
/**
* 管理引用总览与分块详情之间的切换;外部引用变化由父级 key 触发同步重置。
*/
const ChatQuoteListContent = ({
rawSearch = [],
metadata,
singleQuote = false,
onClose
}: Props) => {
const [activeMetadata, setActiveMetadata] = useState<GetQuoteProps>(metadata); const [activeMetadata, setActiveMetadata] = useState<GetQuoteProps>(metadata);
const [canBackToQuoteList, setCanBackToQuoteList] = useState(false); const [canBackToQuoteList, setCanBackToQuoteList] = useState(false);
useEffect(() => {
setActiveMetadata(metadata);
setCanBackToQuoteList(false);
}, [metadata]);
if ('collectionId' in activeMetadata) { if ('collectionId' in activeMetadata) {
return ( return (
<CollectionQuoteReader <CollectionQuoteReader
rawSearch={rawSearch} rawSearch={rawSearch}
metadata={activeMetadata} metadata={activeMetadata}
singleQuote={singleQuote}
onClose={onClose} onClose={onClose}
onBack={ onBack={
canBackToQuoteList canBackToQuoteList
...@@ -45,6 +48,7 @@ const ChatQuoteList = ({ ...@@ -45,6 +48,7 @@ const ChatQuoteList = ({
<QuoteReader <QuoteReader
rawSearch={rawSearch} rawSearch={rawSearch}
metadata={activeMetadata} metadata={activeMetadata}
singleQuote={singleQuote}
onClose={onClose} onClose={onClose}
onOpenCollectionQuote={(nextMetadata: GetCollectionQuoteDataProps) => { onOpenCollectionQuote={(nextMetadata: GetCollectionQuoteDataProps) => {
setActiveMetadata(nextMetadata); setActiveMetadata(nextMetadata);
...@@ -57,4 +61,13 @@ const ChatQuoteList = ({ ...@@ -57,4 +61,13 @@ const ChatQuoteList = ({
return null; return null;
}; };
/**
* 引用入口由外部对话状态驱动;metadata 变化时必须同步卸载旧阅读器,避免首帧使用旧授权或引用。
*/
const ChatQuoteList = (props: Props) => {
const contentKey = JSON.stringify({ metadata: props.metadata, singleQuote: props.singleQuote });
return <ChatQuoteListContent key={contentKey} {...props} />;
};
export default ChatQuoteList; export default ChatQuoteList;
...@@ -115,6 +115,7 @@ const Chat = () => { ...@@ -115,6 +115,7 @@ const Chat = () => {
<ChatQuoteList <ChatQuoteList
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</PageContainer> </PageContainer>
......
...@@ -470,6 +470,7 @@ const OutLink = (props: Props) => { ...@@ -470,6 +470,7 @@ const OutLink = (props: Props) => {
<ChatQuoteList <ChatQuoteList
rawSearch={datasetCiteData.rawSearch} rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata} metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)} onClose={() => setCiteModalData(undefined)}
/> />
</PageContainer> </PageContainer>
...@@ -484,7 +485,15 @@ const Render = (props: Props) => { ...@@ -484,7 +485,15 @@ const Render = (props: Props) => {
const { toast } = useToast(); const { toast } = useToast();
const { shareId, authToken, customUid, appId } = props; const { shareId, authToken, customUid, appId } = props;
const { localUId, setLocalUId, loaded } = useShareChatStore(); const { localUId, setLocalUId, loaded } = useShareChatStore();
const { source, chatId, setSource, setAppId, setOutLinkAuthData } = useChatStore(); const {
source,
chatId,
appId: chatStoreAppId,
setSource,
setAppId,
setOutLinkAuthData,
loaded: chatStoreLoaded
} = useChatStore();
const outLinkUid = authToken || customUid || localUId || ''; const outLinkUid = authToken || customUid || localUId || '';
const chatHistoryProviderParams = useMemoEnhance<GetHistoriesBodyType>(() => { const chatHistoryProviderParams = useMemoEnhance<GetHistoriesBodyType>(() => {
...@@ -509,9 +518,11 @@ const Render = (props: Props) => { ...@@ -509,9 +518,11 @@ const Render = (props: Props) => {
}; };
}, [outLinkAuthData, chatId]); }, [outLinkAuthData, chatId]);
useMount(() => { useEffect(() => {
if (!chatStoreLoaded) return;
setSource('share'); setSource('share');
}); }, [chatStoreLoaded, setSource]);
// Set default localUId // Set default localUId
useEffect(() => { useEffect(() => {
...@@ -524,18 +535,20 @@ const Render = (props: Props) => { ...@@ -524,18 +535,20 @@ const Render = (props: Props) => {
// Init outLinkAuthData // Init outLinkAuthData
useEffect(() => { useEffect(() => {
if (outLinkAuthData.outLinkUid) { if (!chatStoreLoaded || !outLinkAuthData.outLinkUid) return;
setOutLinkAuthData(outLinkAuthData);
} setOutLinkAuthData(outLinkAuthData);
return () => { return () => {
setOutLinkAuthData({}); setOutLinkAuthData({});
}; };
}, [outLinkAuthData, setOutLinkAuthData]); }, [chatStoreLoaded, outLinkAuthData, setOutLinkAuthData]);
// Watch appId // Watch appId
useEffect(() => { useEffect(() => {
if (!chatStoreLoaded) return;
setAppId(appId); setAppId(appId);
}, [appId, setAppId]); }, [appId, chatStoreLoaded, setAppId]);
useMount(() => { useMount(() => {
if (!appId) { if (!appId) {
toast({ toast({
...@@ -545,7 +558,16 @@ const Render = (props: Props) => { ...@@ -545,7 +558,16 @@ const Render = (props: Props) => {
} }
}); });
return source === ChatSourceEnum.share && outLinkAuthData.outLinkUid ? ( const isCurrentChatLinkReady =
chatStoreLoaded &&
source === ChatSourceEnum.share &&
chatStoreAppId === appId &&
outLinkAuthData.shareId === shareId &&
outLinkAuthData.outLinkUid === outLinkUid &&
!!appId &&
!!outLinkUid;
return isCurrentChatLinkReady ? (
<ChatContextProvider params={chatHistoryProviderParams}> <ChatContextProvider params={chatHistoryProviderParams}>
<ChatItemContextProvider <ChatItemContextProvider
showRouteToDatasetDetail={false} showRouteToDatasetDetail={false}
......
...@@ -73,6 +73,7 @@ export type GetQuoteProps = GetAllQuoteDataProps | GetCollectionQuoteDataProps; ...@@ -73,6 +73,7 @@ export type GetQuoteProps = GetAllQuoteDataProps | GetCollectionQuoteDataProps;
export type QuoteDataType = { export type QuoteDataType = {
rawSearch: SearchDataResponseQuoteListItemType[]; rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetQuoteProps; metadata: GetQuoteProps;
singleQuote?: boolean;
}; };
export type OnOpenCiteModalProps = { export type OnOpenCiteModalProps = {
collectionId?: string; collectionId?: string;
...@@ -80,6 +81,7 @@ export type OnOpenCiteModalProps = { ...@@ -80,6 +81,7 @@ export type OnOpenCiteModalProps = {
sourceName?: string; sourceName?: string;
datasetId?: string; datasetId?: string;
quoteId?: string; quoteId?: string;
singleQuote?: boolean;
}; };
type ChatItemContextType = { type ChatItemContextType = {
......
...@@ -10,6 +10,7 @@ export enum AgentChatTestTabEnum { ...@@ -10,6 +10,7 @@ export enum AgentChatTestTabEnum {
} }
type State = { type State = {
loaded: boolean;
source?: `${ChatSourceEnum}`; source?: `${ChatSourceEnum}`;
setSource: (e: `${ChatSourceEnum}`) => any; setSource: (e: `${ChatSourceEnum}`) => any;
...@@ -65,6 +66,21 @@ const getAppChatIdCacheKey = ({ ...@@ -65,6 +66,21 @@ const getAppChatIdCacheKey = ({
return `${source}:${appId}`; return `${source}:${appId}`;
}; };
// persist 失败时没有可供回调修改的 hydrated state,因此提前注册 setter 来结束加载状态。
let markChatStoreLoaded: (() => void) | undefined;
/**
* 在 Zustand store 创建期间保存一个可通知订阅者的 loaded setter,供 hydration 失败回调使用。
*/
const registerChatStoreLoadedSetter = (set: any) => {
markChatStoreLoaded = () => {
set((state: State) => {
state.loaded = true;
});
};
return false;
};
const createCustomStorage = () => { const createCustomStorage = () => {
// source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage // source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage
const sessionKeys = ['source', 'chatId', 'appId', 'agentChatTestTab']; const sessionKeys = ['source', 'chatId', 'appId', 'agentChatTestTab'];
...@@ -116,6 +132,7 @@ export const useChatStore = create<State>()( ...@@ -116,6 +132,7 @@ export const useChatStore = create<State>()(
devtools( devtools(
persist( persist(
immer((set) => ({ immer((set) => ({
loaded: registerChatStoreLoadedSetter(set),
source: undefined, source: undefined,
setSource(e) { setSource(e) {
set((state) => { set((state) => {
...@@ -283,6 +300,16 @@ export const useChatStore = create<State>()( ...@@ -283,6 +300,16 @@ export const useChatStore = create<State>()(
{ {
name: 'chatStore', name: 'chatStore',
storage: createJSONStorage(createCustomStorage), storage: createJSONStorage(createCustomStorage),
onRehydrateStorage: () => (state, error) => {
if (error) {
markChatStoreLoaded?.();
return;
}
if (state) {
state.loaded = true;
}
},
partialize: (state) => ({ partialize: (state) => ({
source: state.source, source: state.source,
chatId: state.chatId, chatId: state.chatId,
......
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { useChatStore, createCustomStorage } from '@/web/core/chat/context/useChatStore';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants'; import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
...@@ -19,7 +18,13 @@ const mockStorage = { ...@@ -19,7 +18,13 @@ const mockStorage = {
vi.stubGlobal('sessionStorage', mockStorage); vi.stubGlobal('sessionStorage', mockStorage);
vi.stubGlobal('localStorage', mockStorage); vi.stubGlobal('localStorage', mockStorage);
const { useChatStore, createCustomStorage } = await import('@/web/core/chat/context/useChatStore');
describe('useChatStore', () => { describe('useChatStore', () => {
beforeAll(async () => {
await useChatStore.persist.rehydrate();
});
beforeEach(() => { beforeEach(() => {
mockStorage.clear.mockClear(); mockStorage.clear.mockClear();
mockStorage.getItem.mockClear(); mockStorage.getItem.mockClear();
...@@ -28,6 +33,7 @@ describe('useChatStore', () => { ...@@ -28,6 +33,7 @@ describe('useChatStore', () => {
// Reset the store // Reset the store
useChatStore.setState({ useChatStore.setState({
loaded: false,
source: undefined, source: undefined,
appId: '', appId: '',
lastChatAppId: '', lastChatAppId: '',
...@@ -40,6 +46,30 @@ describe('useChatStore', () => { ...@@ -40,6 +46,30 @@ describe('useChatStore', () => {
}); });
}); });
it('should mark the store as loaded after persistence hydration', async () => {
await useChatStore.persist.rehydrate();
expect(useChatStore.getState().loaded).toBe(true);
});
it('should finish hydration when storage access fails', async () => {
mockStorage.getItem.mockImplementationOnce(() => {
throw new Error('storage unavailable');
});
await useChatStore.persist.rehydrate();
expect(useChatStore.getState().loaded).toBe(true);
});
it('should finish hydration when persisted data is invalid JSON', async () => {
mockStorage.getItem.mockReturnValueOnce('{invalid json');
await useChatStore.persist.rehydrate();
expect(useChatStore.getState().loaded).toBe(true);
});
it('should set and get source', () => { it('should set and get source', () => {
const store = useChatStore.getState(); const store = useChatStore.getState();
store.setSource(ChatSourceEnum.share); store.setSource(ChatSourceEnum.share);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment