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 {
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useTranslation } from 'next-i18next';
import React, { useMemo } from 'react';
import { getQuoteData } from '@/web/core/dataset/api/data';
......@@ -38,6 +39,7 @@ export type AProps = {
sourceName?: string;
datasetId?: string;
quoteId?: string;
singleQuote?: boolean;
}) => void;
};
......@@ -77,6 +79,7 @@ const CiteLink = React.memo(function CiteLink({
showAnimation
}: { id: string; showAnimation?: boolean } & AProps) {
const { t } = useTranslation();
const { isPc } = useSystem();
const { isOpen, onOpen, onClose } = useDisclosure();
const {
......@@ -95,10 +98,53 @@ const CiteLink = React.memo(function CiteLink({
[sourceData]
);
const handleOpenMobileQuote = () => {
onOpenCiteModal?.({
quoteId: id,
singleQuote: true
});
};
if (!isObjectId(id)) {
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 (
<Popover
isLazy
......@@ -115,35 +161,7 @@ const CiteLink = React.memo(function CiteLink({
trigger={'hover'}
gutter={4}
>
<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>
<PopoverTrigger>{citeButton}</PopoverTrigger>
<PopoverContent boxShadow={'lg'} w={'500px'} maxW={'90vw'} py={4}>
<MyBox isLoading={loading || showAnimation}>
<PopoverArrow />
......
......@@ -10,7 +10,10 @@ import { useTranslation } from 'next-i18next';
import type { UserChatItemValueItemType } 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 { 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 { useMemoizedFn } from 'ahooks';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
......@@ -202,44 +205,47 @@ const ChatItem = (props: Props) => {
}, [chat.obj, chat.value]);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const onOpenCiteModal = useMemoizedFn(
(item?: {
collectionId?: string;
sourceId?: string;
sourceName?: string;
datasetId?: string;
quoteId?: string;
}) => {
const collectionIdList = item?.collectionId
? [item.collectionId]
: [...new Set(quoteList.map((item) => item.collectionId))];
setCiteModalData({
rawSearch: quoteList,
metadata:
item?.collectionId && isShowFullText
? {
...chatAuthTarget,
chatId: chatId,
chatItemDataId: chat.dataId,
collectionId: item.collectionId,
collectionIdList,
sourceId: item.sourceId || '',
sourceName: item.sourceName || '',
datasetId: item.datasetId || '',
quoteId: item.quoteId
}
: {
...chatAuthTarget,
chatId: chatId,
chatItemDataId: chat.dataId,
collectionIdList,
sourceId: item?.sourceId,
sourceName: item?.sourceName
}
});
}
);
const onOpenCiteModal = useMemoizedFn((item?: OnOpenCiteModalProps) => {
const selectedQuote = item?.quoteId
? quoteList.find((quote) => quote.id === item.quoteId)
: undefined;
const isSingleQuote = item?.singleQuote === true && !!selectedQuote;
// 引用已经不在当前消息的 quoteList 时,不能打开空的单条阅读器。
if (item?.singleQuote && !isSingleQuote) return;
const collectionId = item?.collectionId ?? selectedQuote?.collectionId;
const rawSearch = isSingleQuote && selectedQuote ? [selectedQuote] : quoteList;
const collectionIdList = collectionId
? [collectionId]
: [...new Set(quoteList.map((quote) => quote.collectionId))];
setCiteModalData({
rawSearch,
singleQuote: isSingleQuote,
metadata:
collectionId && isShowFullText
? {
...chatAuthTarget,
chatId,
chatItemDataId: chat.dataId,
collectionId,
collectionIdList,
sourceId: item?.sourceId ?? selectedQuote?.sourceId ?? '',
sourceName: item?.sourceName ?? selectedQuote?.sourceName ?? '',
datasetId: item?.datasetId ?? selectedQuote?.datasetId ?? '',
quoteId: item?.quoteId
}
: {
...chatAuthTarget,
chatId,
chatItemDataId: chat.dataId,
collectionIdList,
sourceId: item?.sourceId ?? selectedQuote?.sourceId,
sourceName: item?.sourceName ?? selectedQuote?.sourceName
}
});
});
return (
<Flex data-chat-id={chat.dataId} direction={'column'} gap={4}>
......
......@@ -329,6 +329,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
......
......@@ -112,6 +112,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
......
......@@ -234,6 +234,7 @@ const DetailLogsModal = ({
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
......
......@@ -166,6 +166,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
......
......@@ -13,6 +13,7 @@ const CollectionQuoteItem = ({
setQuoteIndex,
refreshList,
canEdit,
alwaysShowCopy = false,
updated,
isCurrentSelected,
......@@ -26,6 +27,7 @@ const CollectionQuoteItem = ({
setQuoteIndex: (quoteIndex: number) => void;
refreshList: () => void;
canEdit: boolean;
alwaysShowCopy?: boolean;
updated?: boolean;
isCurrentSelected: boolean;
......@@ -46,6 +48,7 @@ const CollectionQuoteItem = ({
quoteRefs.current.set(dataId, el);
}}
p={'12px'}
pb={alwaysShowCopy ? '40px' : '12px'}
cursor={hasBeenSearched ? 'pointer' : 'default'}
bg={isCurrentSelected ? 'blue.50' : ''}
position={'relative'}
......@@ -101,7 +104,7 @@ const CollectionQuoteItem = ({
bottom={'12px'}
right={'12px'}
gap={1.5}
visibility={'hidden'}
visibility={alwaysShowCopy ? 'visible' : 'hidden'}
>
<MyTooltip label={t('common:Copy')}>
<Flex
......@@ -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)'
}
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'} />
</Flex>
......
import { Box, Flex } from '@chakra-ui/react';
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 { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
......@@ -24,11 +23,13 @@ import { getChatAuthTargetInput } from '@/web/core/chat/utils';
const CollectionReader = ({
rawSearch,
metadata,
singleQuote = false,
onClose,
onBack
}: {
rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetCollectionQuoteDataProps;
singleQuote?: boolean;
onClose: () => void;
onBack?: () => void;
}) => {
......@@ -118,20 +119,23 @@ const CollectionReader = ({
[datasetDataList, currentQuoteItem?.id, isLoading]
);
const formatedDataList = useMemo(
() =>
datasetDataList.map((item) => {
const isCurrentSelected = currentQuoteItem?.id === item._id;
const quoteIndex = filterResults.findIndex((res) => res.id === item._id);
const formatedDataList = useMemo(() => {
// 分块接口会同时返回锚点前后的数据,单条模式只保留当前引用对应的分块。
const visibleDataList = singleQuote
? datasetDataList.filter((item) => item._id === currentQuoteItem?.id)
: datasetDataList;
return {
...item,
isCurrentSelected,
quoteIndex
};
}),
[currentQuoteItem?.id, datasetDataList, filterResults]
);
return visibleDataList.map((item) => {
const isCurrentSelected = currentQuoteItem?.id === item._id;
const quoteIndex = filterResults.findIndex((res) => res.id === item._id);
return {
...item,
isCurrentSelected,
quoteIndex
};
});
}, [currentQuoteItem?.id, datasetDataList, filterResults, singleQuote]);
const canShowSourceActions = useMemo(
() =>
......@@ -241,7 +245,7 @@ const CollectionReader = ({
</Box>
{/* header control */}
{datasetDataList.length > 0 && (
{!singleQuote && datasetDataList.length > 0 && (
<Flex
w={'full'}
h={'56px'}
......@@ -299,7 +303,7 @@ const CollectionReader = ({
)}
{/* quote list */}
{isLoading || datasetDataList.length > 0 ? (
{isLoading || formatedDataList.length > 0 ? (
<ScrollData flex={'1 0 0'} p={'12px'}>
<Flex flexDir={'column'} gap={'12px'}>
{formatedDataList.map((item) => (
......@@ -316,6 +320,7 @@ const CollectionReader = ({
dataId={item._id}
collectionId={collectionId}
canEdit={!!userInfo && !!datasetData?.permission?.hasWritePer}
alwaysShowCopy={singleQuote}
/>
))}
</Flex>
......@@ -337,11 +342,13 @@ const CollectionReader = ({
</Flex>
)}
<Box px={5} py={3}>
<Flex fontSize={'mini'} justifyContent={'center'} color={'myGray.500'}>
{t('chat:quote_result_notice')}
</Flex>
</Box>
{!singleQuote && (
<Box px={5} py={3}>
<Flex fontSize={'mini'} justifyContent={'center'} color={'myGray.500'}>
{t('chat:quote_result_notice')}
</Flex>
</Box>
)}
</MyBox>
);
};
......
......@@ -9,12 +9,14 @@ const QuoteItem = ({
icon,
sourceName,
onClick,
alwaysShowCopy = false,
q,
a
}: {
icon: string;
sourceName: string;
onClick?: () => void;
alwaysShowCopy?: boolean;
q: string;
a?: string;
}) => {
......@@ -25,6 +27,7 @@ const QuoteItem = ({
return (
<Box
p={'12px'}
pb={alwaysShowCopy ? '40px' : '12px'}
position={'relative'}
overflow={'hidden'}
borderRadius={'6px'}
......@@ -92,7 +95,7 @@ const QuoteItem = ({
bottom={2}
right={5}
gap={1.5}
visibility={'hidden'}
visibility={alwaysShowCopy ? 'visible' : 'hidden'}
>
<MyTooltip label={t('common:Copy')}>
<Flex
......@@ -110,7 +113,7 @@ const QuoteItem = ({
cursor={'pointer'}
onClick={(e) => {
e.stopPropagation();
copyData(q + '\n' + a);
copyData([q, a].filter(Boolean).join('\n'));
}}
>
<MyIcon name="copy" w={'14px'} color={'myGray.500'} />
......
......@@ -23,11 +23,13 @@ type MobileQuoteTab = 'detail' | 'source';
const QuoteReader = ({
rawSearch,
metadata,
singleQuote = false,
onClose,
onOpenCollectionQuote
}: {
rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetAllQuoteDataProps;
singleQuote?: boolean;
onClose: () => void;
onOpenCollectionQuote: (metadata: GetCollectionQuoteDataProps) => void;
}) => {
......@@ -132,7 +134,13 @@ const QuoteReader = ({
};
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 && (
<Flex flexDir={'column'} gap={'12px'}>
{formatedDataList?.map((item) => (
......@@ -142,7 +150,8 @@ const QuoteReader = ({
sourceName={item.sourceName}
q={item.q}
a={item.a}
onClick={item.sourceId ? () => openCollectionQuote(item) : undefined}
alwaysShowCopy={singleQuote}
onClick={singleQuote || !item.sourceId ? undefined : () => openCollectionQuote(item)}
/>
))}
</Flex>
......@@ -189,25 +198,73 @@ const QuoteReader = ({
);
return (
<Flex flexDirection={'column'} minH={'full'} h={'full'}>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
justifyContent={'center'}
px={6}
py={'16px'}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
position={'relative'}
>
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'16px'}>
{isPc ? t('common:chat.quote_detail_title') : t('common:core.chat.Quote')}
</Box>
<Flex flexDirection={'column'} minH={'full'} h={'full'} position={'relative'}>
{!singleQuote && (
<>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
justifyContent={'center'}
px={6}
py={'16px'}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
position={'relative'}
>
<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
position={'absolute'}
top={2}
right={4}
zIndex={1}
justifyContent={'center'}
alignItems={'center'}
cursor={'pointer'}
......@@ -220,40 +277,18 @@ const QuoteReader = ({
>
<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>
)}
{/* quote list */}
{isPc || mobileTab === 'detail' ? quoteDetailList : quoteSourceList}
{singleQuote || isPc || mobileTab === 'detail' ? quoteDetailList : quoteSourceList}
<Box px={5} py={3}>
<Flex fontSize={'mini'} color={'myGray.500'} justifyContent={'center'}>
{t('chat:quote_result_notice')}
</Flex>
</Box>
{!singleQuote && (
<Box px={5} py={3}>
<Flex fontSize={'mini'} color={'myGray.500'} justifyContent={'center'}>
{t('chat:quote_result_notice')}
</Flex>
</Box>
)}
</Flex>
);
};
......
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { type SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type';
import {
type GetCollectionQuoteDataProps,
......@@ -7,28 +7,31 @@ import {
import CollectionQuoteReader from './CollectionQuoteReader';
import QuoteReader from './QuoteReader';
const ChatQuoteList = ({
rawSearch = [],
metadata,
onClose
}: {
type Props = {
rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetQuoteProps;
singleQuote?: boolean;
onClose: () => void;
}) => {
};
/**
* 管理引用总览与分块详情之间的切换;外部引用变化由父级 key 触发同步重置。
*/
const ChatQuoteListContent = ({
rawSearch = [],
metadata,
singleQuote = false,
onClose
}: Props) => {
const [activeMetadata, setActiveMetadata] = useState<GetQuoteProps>(metadata);
const [canBackToQuoteList, setCanBackToQuoteList] = useState(false);
useEffect(() => {
setActiveMetadata(metadata);
setCanBackToQuoteList(false);
}, [metadata]);
if ('collectionId' in activeMetadata) {
return (
<CollectionQuoteReader
rawSearch={rawSearch}
metadata={activeMetadata}
singleQuote={singleQuote}
onClose={onClose}
onBack={
canBackToQuoteList
......@@ -45,6 +48,7 @@ const ChatQuoteList = ({
<QuoteReader
rawSearch={rawSearch}
metadata={activeMetadata}
singleQuote={singleQuote}
onClose={onClose}
onOpenCollectionQuote={(nextMetadata: GetCollectionQuoteDataProps) => {
setActiveMetadata(nextMetadata);
......@@ -57,4 +61,13 @@ const ChatQuoteList = ({
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;
......@@ -115,6 +115,7 @@ const Chat = () => {
<ChatQuoteList
metadata={datasetCiteData.metadata}
rawSearch={datasetCiteData.rawSearch}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</PageContainer>
......
......@@ -470,6 +470,7 @@ const OutLink = (props: Props) => {
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
singleQuote={datasetCiteData.singleQuote}
onClose={() => setCiteModalData(undefined)}
/>
</PageContainer>
......@@ -484,7 +485,15 @@ const Render = (props: Props) => {
const { toast } = useToast();
const { shareId, authToken, customUid, appId } = props;
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 chatHistoryProviderParams = useMemoEnhance<GetHistoriesBodyType>(() => {
......@@ -509,9 +518,11 @@ const Render = (props: Props) => {
};
}, [outLinkAuthData, chatId]);
useMount(() => {
useEffect(() => {
if (!chatStoreLoaded) return;
setSource('share');
});
}, [chatStoreLoaded, setSource]);
// Set default localUId
useEffect(() => {
......@@ -524,18 +535,20 @@ const Render = (props: Props) => {
// Init outLinkAuthData
useEffect(() => {
if (outLinkAuthData.outLinkUid) {
setOutLinkAuthData(outLinkAuthData);
}
if (!chatStoreLoaded || !outLinkAuthData.outLinkUid) return;
setOutLinkAuthData(outLinkAuthData);
return () => {
setOutLinkAuthData({});
};
}, [outLinkAuthData, setOutLinkAuthData]);
}, [chatStoreLoaded, outLinkAuthData, setOutLinkAuthData]);
// Watch appId
useEffect(() => {
if (!chatStoreLoaded) return;
setAppId(appId);
}, [appId, setAppId]);
}, [appId, chatStoreLoaded, setAppId]);
useMount(() => {
if (!appId) {
toast({
......@@ -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}>
<ChatItemContextProvider
showRouteToDatasetDetail={false}
......
......@@ -73,6 +73,7 @@ export type GetQuoteProps = GetAllQuoteDataProps | GetCollectionQuoteDataProps;
export type QuoteDataType = {
rawSearch: SearchDataResponseQuoteListItemType[];
metadata: GetQuoteProps;
singleQuote?: boolean;
};
export type OnOpenCiteModalProps = {
collectionId?: string;
......@@ -80,6 +81,7 @@ export type OnOpenCiteModalProps = {
sourceName?: string;
datasetId?: string;
quoteId?: string;
singleQuote?: boolean;
};
type ChatItemContextType = {
......
......@@ -10,6 +10,7 @@ export enum AgentChatTestTabEnum {
}
type State = {
loaded: boolean;
source?: `${ChatSourceEnum}`;
setSource: (e: `${ChatSourceEnum}`) => any;
......@@ -65,6 +66,21 @@ const getAppChatIdCacheKey = ({
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 = () => {
// source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage
const sessionKeys = ['source', 'chatId', 'appId', 'agentChatTestTab'];
......@@ -116,6 +132,7 @@ export const useChatStore = create<State>()(
devtools(
persist(
immer((set) => ({
loaded: registerChatStoreLoadedSetter(set),
source: undefined,
setSource(e) {
set((state) => {
......@@ -283,6 +300,16 @@ export const useChatStore = create<State>()(
{
name: 'chatStore',
storage: createJSONStorage(createCustomStorage),
onRehydrateStorage: () => (state, error) => {
if (error) {
markChatStoreLoaded?.();
return;
}
if (state) {
state.loaded = true;
}
},
partialize: (state) => ({
source: state.source,
chatId: state.chatId,
......
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useChatStore, createCustomStorage } from '@/web/core/chat/context/useChatStore';
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
......@@ -19,7 +18,13 @@ const mockStorage = {
vi.stubGlobal('sessionStorage', mockStorage);
vi.stubGlobal('localStorage', mockStorage);
const { useChatStore, createCustomStorage } = await import('@/web/core/chat/context/useChatStore');
describe('useChatStore', () => {
beforeAll(async () => {
await useChatStore.persist.rehydrate();
});
beforeEach(() => {
mockStorage.clear.mockClear();
mockStorage.getItem.mockClear();
......@@ -28,6 +33,7 @@ describe('useChatStore', () => {
// Reset the store
useChatStore.setState({
loaded: false,
source: undefined,
appId: '',
lastChatAppId: '',
......@@ -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', () => {
const store = useChatStore.getState();
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