mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
✨ feat: support swr local cache (#10884)
* feat: add localstorage cache in swr provider * feat: add use fetch topic into cache * feat: add homepage recents api cache * feat: add group chat initial cache * docs: update the hint
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import React, { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { useFetchTopics } from '@/hooks/useFetchTopics';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
@@ -20,6 +22,7 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
|
||||
const { t } = useTranslation(['topic', 'common']);
|
||||
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
|
||||
const dropdownMenu = useTopicActionsDropdownMenu();
|
||||
const { isRevalidating } = useFetchTopics();
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
@@ -38,9 +41,12 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
|
||||
paddingBlock={4}
|
||||
paddingInline={'8px 4px'}
|
||||
title={
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
|
||||
</Text>
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
|
||||
</Text>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<SkeletonList />}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { AccordionItem, ActionIcon, Flexbox, Text } from '@lobehub/ui';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { Loader2Icon, UserPlus } from 'lucide-react';
|
||||
import { MouseEvent, memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useInitGroupConfig } from '@/hooks/useInitGroupConfig';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||
|
||||
@@ -22,6 +23,7 @@ const Members = memo<MembersProps>(({ itemKey }) => {
|
||||
const membersCount = useAgentGroupStore(
|
||||
agentGroupSelectors.getGroupAgentCount(activeGroupId || ''),
|
||||
);
|
||||
const { isRevalidating } = useInitGroupConfig();
|
||||
|
||||
const handleAddMember = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -31,12 +33,15 @@ const Members = memo<MembersProps>(({ itemKey }) => {
|
||||
return (
|
||||
<AccordionItem
|
||||
action={
|
||||
<ActionIcon
|
||||
icon={UserPlus}
|
||||
onClick={handleAddMember}
|
||||
size={'small'}
|
||||
title={t('groupSidebar.members.addMember')}
|
||||
/>
|
||||
<>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
<ActionIcon
|
||||
icon={UserPlus}
|
||||
onClick={handleAddMember}
|
||||
size={'small'}
|
||||
title={t('groupSidebar.members.addMember')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
itemKey={itemKey}
|
||||
paddingBlock={4}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import React, { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { useFetchTopics } from '@/hooks/useFetchTopics';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
@@ -20,6 +22,7 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
|
||||
const { t } = useTranslation(['topic', 'common']);
|
||||
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
|
||||
const dropdownMenu = useTopicActionsDropdownMenu();
|
||||
const { isRevalidating } = useFetchTopics();
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
@@ -38,9 +41,12 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
|
||||
paddingBlock={4}
|
||||
paddingInline={'8px 4px'}
|
||||
title={
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
|
||||
</Text>
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
|
||||
</Text>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<SkeletonList />}>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import React, { Suspense, memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFetchAgentList } from '@/hooks/useFetchAgentList';
|
||||
|
||||
import SkeletonList from '../../../../../../../features/NavPanel/components/SkeletonList';
|
||||
import { useCreateMenuItems } from '../../hooks';
|
||||
import Actions from './Actions';
|
||||
@@ -17,6 +20,7 @@ interface AgentProps {
|
||||
|
||||
const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const { isRevalidating } = useFetchAgentList();
|
||||
|
||||
const {
|
||||
openGroupWizardModal,
|
||||
@@ -102,9 +106,12 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
paddingBlock={4}
|
||||
paddingInline={'8px 4px'}
|
||||
title={
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{t('navPanel.agent')}
|
||||
</Text>
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{t('navPanel.agent')}
|
||||
</Text>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<SkeletonList rows={6} />}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Dropdown } from '@lobehub/ui';
|
||||
import { FileTextIcon, MoreHorizontal } from 'lucide-react';
|
||||
import { FileTextIcon, Loader2Icon, MoreHorizontal } from 'lucide-react';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
|
||||
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { homeRecentSelectors } from '@/store/home/selectors';
|
||||
import { FilesTabs } from '@/types/files';
|
||||
@@ -23,6 +24,7 @@ const RecentPage = memo(() => {
|
||||
const setCategory = useResourceManagerStore((s) => s.setCategory);
|
||||
const recentPages = useHomeStore(homeRecentSelectors.recentPages);
|
||||
const isInit = useHomeStore(homeRecentSelectors.isRecentPagesInit);
|
||||
const { isRevalidating } = useInitRecentPage();
|
||||
|
||||
// After loaded, if no data, don't render
|
||||
if (isInit && (!recentPages || recentPages.length === 0)) {
|
||||
@@ -32,22 +34,25 @@ const RecentPage = memo(() => {
|
||||
return (
|
||||
<GroupBlock
|
||||
action={
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'all-documents',
|
||||
label: t('menu.allPages'),
|
||||
onClick: () => {
|
||||
setCategory(FilesTabs.Pages);
|
||||
navigate('/resource');
|
||||
<>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'all-documents',
|
||||
label: t('menu.allPages'),
|
||||
onClick: () => {
|
||||
setCategory(FilesTabs.Pages);
|
||||
navigate('/resource');
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ActionIcon icon={MoreHorizontal} size="small" />
|
||||
</Dropdown>
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ActionIcon icon={MoreHorizontal} size="small" />
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
icon={FileTextIcon}
|
||||
title={t('home.recentPages')}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Dropdown } from '@lobehub/ui';
|
||||
import { Clock, MoreHorizontal } from 'lucide-react';
|
||||
import { Clock, Loader2Icon, MoreHorizontal } from 'lucide-react';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
|
||||
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { homeRecentSelectors } from '@/store/home/selectors';
|
||||
import { FilesTabs } from '@/types/files';
|
||||
@@ -23,6 +24,7 @@ const RecentResource = memo(() => {
|
||||
const setCategory = useResourceManagerStore((s) => s.setCategory);
|
||||
const recentResources = useHomeStore(homeRecentSelectors.recentResources);
|
||||
const isInit = useHomeStore(homeRecentSelectors.isRecentResourcesInit);
|
||||
const { isRevalidating } = useInitRecentResource();
|
||||
|
||||
// After loaded, if no data, don't render
|
||||
if (isInit && (!recentResources || recentResources.length === 0)) {
|
||||
@@ -32,22 +34,25 @@ const RecentResource = memo(() => {
|
||||
return (
|
||||
<GroupBlock
|
||||
action={
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'all-files',
|
||||
label: t('menu.allFiles'),
|
||||
onClick: () => {
|
||||
setCategory(FilesTabs.All);
|
||||
navigate('/resource');
|
||||
<>
|
||||
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'all-files',
|
||||
label: t('menu.allFiles'),
|
||||
onClick: () => {
|
||||
setCategory(FilesTabs.All);
|
||||
navigate('/resource');
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ActionIcon icon={MoreHorizontal} size="small" />
|
||||
</Dropdown>
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ActionIcon icon={MoreHorizontal} size="small" />
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
icon={Clock}
|
||||
title={t('home.recentFiles')}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BotMessageSquareIcon } from 'lucide-react';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { BotMessageSquareIcon, Loader2Icon } from 'lucide-react';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { homeRecentSelectors } from '@/store/home/selectors';
|
||||
|
||||
@@ -15,6 +17,7 @@ const RecentTopic = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const recentTopics = useHomeStore(homeRecentSelectors.recentTopics);
|
||||
const isInit = useHomeStore(homeRecentSelectors.isRecentTopicsInit);
|
||||
const { isRevalidating } = useInitRecentTopic();
|
||||
|
||||
// After loaded, if no data, don't render
|
||||
if (isInit && (!recentTopics || recentTopics.length === 0)) {
|
||||
@@ -22,7 +25,11 @@ const RecentTopic = memo(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupBlock icon={BotMessageSquareIcon} title={t('topic.recent')}>
|
||||
<GroupBlock
|
||||
action={isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
|
||||
icon={BotMessageSquareIcon}
|
||||
title={t('topic.recent')}
|
||||
>
|
||||
<ScrollShadowWithButton>
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -2,9 +2,18 @@ import { useHomeStore } from '@/store/home';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
||||
|
||||
/**
|
||||
* Hook to fetch agent list
|
||||
* @returns isValidating - true when background revalidation is in progress (has cached data but fetching new)
|
||||
*/
|
||||
export const useFetchAgentList = () => {
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
const useFetchAgentList = useHomeStore((s) => s.useFetchAgentList);
|
||||
const useFetchAgentListHook = useHomeStore((s) => s.useFetchAgentList);
|
||||
|
||||
useFetchAgentList(isLogin);
|
||||
const { isValidating, data } = useFetchAgentListHook(isLogin);
|
||||
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
return {
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
*/
|
||||
export const useFetchTopics = () => {
|
||||
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
|
||||
const [activeAgentId, activeGroupId, useFetchTopics] = useChatStore((s) => [
|
||||
const [activeAgentId, activeGroupId, useFetchTopicsHook] = useChatStore((s) => [
|
||||
s.activeAgentId,
|
||||
s.activeGroupId,
|
||||
s.useFetchTopics,
|
||||
@@ -18,10 +18,15 @@ export const useFetchTopics = () => {
|
||||
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
|
||||
|
||||
// If in group session, use groupId; otherwise use agentId
|
||||
useFetchTopics(true, {
|
||||
const { isValidating, data } = useFetchTopicsHook(true, {
|
||||
agentId: activeAgentId,
|
||||
groupId: activeGroupId,
|
||||
isInbox: activeGroupId ? false : isInbox,
|
||||
pageSize: topicPageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,11 +12,14 @@ export const useInitGroupConfig = () => {
|
||||
|
||||
// Only fetch group detail if we have a valid group ID and user is logged in
|
||||
const shouldFetch = Boolean(isLogin && activeGroupId);
|
||||
const data = useFetchGroupDetail(shouldFetch, activeGroupId || '');
|
||||
const { isValidating, data, ...rest } = useFetchGroupDetail(shouldFetch, activeGroupId || '');
|
||||
|
||||
return {
|
||||
...data,
|
||||
error: data.error || (!shouldFetch ? undefined : data.error),
|
||||
isLoading: (data.isLoading && isLogin) || !shouldFetch,
|
||||
...rest,
|
||||
data,
|
||||
error: rest.error || (!shouldFetch ? undefined : rest.error),
|
||||
isLoading: (rest.isLoading && isLogin) || !shouldFetch,
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,13 @@ export const useInitRecentPage = () => {
|
||||
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
|
||||
const data = useFetchRecentPages(isLogin);
|
||||
const { isValidating, data, ...rest } = useFetchRecentPages(isLogin);
|
||||
|
||||
return { ...data, isLoading: data.isLoading && isLogin };
|
||||
return {
|
||||
...rest,
|
||||
data,
|
||||
isLoading: rest.isLoading && isLogin,
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,13 @@ export const useInitRecentResource = () => {
|
||||
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
|
||||
const data = useFetchRecentResources(isLogin);
|
||||
const { isValidating, data, ...rest } = useFetchRecentResources(isLogin);
|
||||
|
||||
return { ...data, isLoading: data.isLoading && isLogin };
|
||||
return {
|
||||
...rest,
|
||||
data,
|
||||
isLoading: rest.isLoading && isLogin,
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,13 @@ export const useInitRecentTopic = () => {
|
||||
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
|
||||
const data = useFetchRecentTopics(isLogin);
|
||||
const { isValidating, data, ...rest } = useFetchRecentTopics(isLogin);
|
||||
|
||||
return { ...data, isLoading: data.isLoading && isLogin };
|
||||
return {
|
||||
...rest,
|
||||
data,
|
||||
isLoading: rest.isLoading && isLogin,
|
||||
// isRevalidating: 有缓存数据,后台正在更新
|
||||
isRevalidating: isValidating && !!data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React, { PropsWithChildren, useState } from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { swrCacheProvider } from '@/libs/swr/localStorageProvider';
|
||||
import { lambdaQuery, lambdaQueryClient } from '@/libs/trpc/client';
|
||||
|
||||
const QueryProvider = ({ children }: PropsWithChildren) => {
|
||||
@@ -12,10 +14,15 @@ const QueryProvider = ({ children }: PropsWithChildren) => {
|
||||
typeof lambdaQuery.Provider
|
||||
>['queryClient'];
|
||||
|
||||
// 使用 useState 确保 provider 只创建一次
|
||||
const [provider] = useState(swrCacheProvider);
|
||||
|
||||
return (
|
||||
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={providerQueryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</lambdaQuery.Provider>
|
||||
<SWRConfig value={{ provider }}>
|
||||
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={providerQueryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</lambdaQuery.Provider>
|
||||
</SWRConfig>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useClientDataSWR: SWRHook = (key, fetch, config) =>
|
||||
* This type of request method is relatively "dead" request mode, which will only be triggered on the first request.
|
||||
* it suitable for first time request like `initUserState`
|
||||
|
||||
* 这一类请求方法是相对“死”的请求模式,只会在第一次请求时触发。
|
||||
* 这一类请求方法是相对"死"的请求模式,只会在第一次请求时触发。
|
||||
* 适用于第一次请求,例如 `initUserState`
|
||||
*/
|
||||
// @ts-ignore
|
||||
@@ -93,3 +93,6 @@ export interface SWRRefreshParams<T, A = (...args: any[]) => any> {
|
||||
export type SWRefreshMethod<T> = <A extends (...args: any[]) => Promise<any>>(
|
||||
params?: SWRRefreshParams<T, A>,
|
||||
) => ReturnType<A>;
|
||||
|
||||
// 导出带自动同步功能的 hook
|
||||
export { useClientDataSWRWithSync } from './useClientDataSWRWithSync';
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearSWRCache, createLocalStorageProvider } from './localStorageProvider';
|
||||
|
||||
describe('createLocalStorageProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should return a function that creates a Map', () => {
|
||||
const provider = createLocalStorageProvider();
|
||||
const map = provider();
|
||||
|
||||
expect(map).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should store and retrieve values', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['test-key'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
map.set('test-key', { value: 'test' });
|
||||
|
||||
expect(map.get('test-key')).toEqual({ value: 'test' });
|
||||
});
|
||||
|
||||
it('should delete values', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['test-key'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
map.set('test-key', { value: 'test' });
|
||||
map.delete('test-key');
|
||||
|
||||
expect(map.has('test-key')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitelist filtering', () => {
|
||||
it('should only persist keys matching cacheablePatterns', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['fetchSessions', 'fetchAgentList'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
// Set both cacheable and non-cacheable keys
|
||||
map.set('fetchSessions', { sessions: [] });
|
||||
map.set('fetchAgentList', { agents: [] });
|
||||
map.set('fetchMessages', { messages: [] }); // Not in whitelist
|
||||
|
||||
// Trigger save via beforeunload event
|
||||
window.dispatchEvent(new Event('beforeunload'));
|
||||
|
||||
const stored = localStorage.getItem('lobechat-swr-cache');
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
const entries = JSON.parse(stored!);
|
||||
const keys = entries.map(([key]: [string, unknown]) => key);
|
||||
|
||||
expect(keys).toContain('fetchSessions');
|
||||
expect(keys).toContain('fetchAgentList');
|
||||
expect(keys).not.toContain('fetchMessages');
|
||||
});
|
||||
|
||||
it('should match array keys by first element', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['fetchSessions'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
// SWR often uses array keys like ['fetchSessions', userId]
|
||||
const arrayKey = JSON.stringify(['fetchSessions', 'user-123']);
|
||||
map.set(arrayKey, { data: 'test' });
|
||||
|
||||
// Trigger save via beforeunload event
|
||||
window.dispatchEvent(new Event('beforeunload'));
|
||||
|
||||
const stored = localStorage.getItem('lobechat-swr-cache');
|
||||
const entries = JSON.parse(stored!);
|
||||
|
||||
expect(entries.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL expiration', () => {
|
||||
it('should load non-expired entries from localStorage', () => {
|
||||
const now = Date.now();
|
||||
const validEntry = {
|
||||
data: { valid: true },
|
||||
timestamp: now - 1000, // 1 second ago
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'lobechat-swr-cache',
|
||||
JSON.stringify([['valid-key', validEntry]]),
|
||||
);
|
||||
|
||||
const provider = createLocalStorageProvider({
|
||||
ttl: 60 * 1000, // 1 minute
|
||||
version: '1.0.0',
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
expect(map.get('valid-key')).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not load expired entries from localStorage', () => {
|
||||
const now = Date.now();
|
||||
const expiredEntry = {
|
||||
data: { expired: true },
|
||||
timestamp: now - 2 * 60 * 60 * 1000, // 2 hours ago
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'lobechat-swr-cache',
|
||||
JSON.stringify([['expired-key', expiredEntry]]),
|
||||
);
|
||||
|
||||
const provider = createLocalStorageProvider({
|
||||
ttl: 60 * 60 * 1000, // 1 hour
|
||||
version: '1.0.0',
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
expect(map.has('expired-key')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version control', () => {
|
||||
it('should not load entries with different version', () => {
|
||||
const oldVersionEntry = {
|
||||
data: { old: true },
|
||||
timestamp: Date.now(),
|
||||
version: '0.9.0',
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'lobechat-swr-cache',
|
||||
JSON.stringify([['old-key', oldVersionEntry]]),
|
||||
);
|
||||
|
||||
const provider = createLocalStorageProvider({
|
||||
version: '1.0.0',
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
expect(map.has('old-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should load entries with matching version', () => {
|
||||
const currentVersionEntry = {
|
||||
data: { current: true },
|
||||
timestamp: Date.now(),
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'lobechat-swr-cache',
|
||||
JSON.stringify([['current-key', currentVersionEntry]]),
|
||||
);
|
||||
|
||||
const provider = createLocalStorageProvider({
|
||||
version: '1.0.0',
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
expect(map.get('current-key')).toEqual({ current: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('capacity limits', () => {
|
||||
it('should respect maxEntries limit', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['item'],
|
||||
maxEntries: 3,
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
// Add more entries than the limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
map.set(`item-${i}`, { index: i });
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(2500);
|
||||
|
||||
const stored = localStorage.getItem('lobechat-swr-cache');
|
||||
const entries = JSON.parse(stored!);
|
||||
|
||||
expect(entries.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounced saving', () => {
|
||||
it('should debounce multiple set operations', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['key'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
// Multiple rapid sets
|
||||
map.set('key-1', { v: 1 });
|
||||
map.set('key-2', { v: 2 });
|
||||
map.set('key-3', { v: 3 });
|
||||
|
||||
// Before debounce timeout, nothing should be saved
|
||||
expect(localStorage.getItem('lobechat-swr-cache')).toBeNull();
|
||||
|
||||
// After debounce timeout
|
||||
vi.advanceTimersByTime(2500);
|
||||
|
||||
expect(localStorage.getItem('lobechat-swr-cache')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle corrupted localStorage data', () => {
|
||||
localStorage.setItem('lobechat-swr-cache', 'invalid-json');
|
||||
|
||||
const onError = vi.fn();
|
||||
const provider = createLocalStorageProvider({ onError });
|
||||
const map = provider();
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle QuotaExceededError gracefully', () => {
|
||||
const provider = createLocalStorageProvider({
|
||||
cacheablePatterns: ['key'],
|
||||
});
|
||||
const map = provider();
|
||||
|
||||
// Mock localStorage.setItem to throw QuotaExceededError
|
||||
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw quotaError;
|
||||
});
|
||||
|
||||
map.set('key', { data: 'test' });
|
||||
vi.advanceTimersByTime(2500);
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSR compatibility', () => {
|
||||
it('should return empty Map when window is undefined', () => {
|
||||
// This test verifies the SSR check in the implementation
|
||||
// The actual SSR scenario is tested implicitly by the guard clause
|
||||
const provider = createLocalStorageProvider();
|
||||
const map = provider();
|
||||
|
||||
expect(map).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSWRCache', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should clear the cache from localStorage', () => {
|
||||
localStorage.setItem('lobechat-swr-cache', JSON.stringify([['key', { data: 'test' }]]));
|
||||
|
||||
clearSWRCache();
|
||||
|
||||
expect(localStorage.getItem('lobechat-swr-cache')).toBeNull();
|
||||
});
|
||||
|
||||
it('should use custom cache key if provided', () => {
|
||||
const customKey = 'custom-cache-key';
|
||||
localStorage.setItem(customKey, JSON.stringify([['key', { data: 'test' }]]));
|
||||
|
||||
clearSWRCache(customKey);
|
||||
|
||||
expect(localStorage.getItem(customKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* SWR localStorage Cache Provider
|
||||
*
|
||||
* Provides localStorage persistence for selected SWR requests.
|
||||
* Only keys matching the whitelist patterns will be cached to localStorage.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SWRConfig value={{ provider: createLocalStorageProvider() }}>
|
||||
* <App />
|
||||
* </SWRConfig>
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface CacheEntry<T = unknown> {
|
||||
/** Cached data */
|
||||
data: T;
|
||||
/** Cache timestamp */
|
||||
timestamp: number;
|
||||
/** App version */
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface LocalStorageCacheOptions {
|
||||
/** localStorage key name, defaults to 'lobechat-swr-cache' */
|
||||
cacheKey?: string;
|
||||
/** Allowed SWR key patterns (whitelist) */
|
||||
cacheablePatterns?: string[];
|
||||
/** Maximum cache entries, defaults to 50 */
|
||||
maxEntries?: number;
|
||||
/** Error callback */
|
||||
onError?: (error: Error) => void;
|
||||
/** Cache TTL in milliseconds, defaults to 24 hours */
|
||||
ttl?: number;
|
||||
/** App version, cache is cleared when version changes */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default cacheable SWR key patterns
|
||||
* Only requests matching these patterns will be persisted
|
||||
*/
|
||||
const DEFAULT_CACHEABLE_PATTERNS: string[] = [
|
||||
// Add patterns as needed
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if localStorage is available
|
||||
*/
|
||||
const isLocalStorageAvailable = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const testKey = '__swr_cache_test__';
|
||||
localStorage.setItem(testKey, 'test');
|
||||
localStorage.removeItem(testKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if key matches whitelist patterns
|
||||
*
|
||||
* SWR keys can be:
|
||||
* - String: 'fetchSessions'
|
||||
* - Serialized array: '["fetchSessions","user-123"]'
|
||||
*
|
||||
* We check if the key string contains any whitelist pattern
|
||||
*/
|
||||
const matchesCacheablePattern = (key: string, patterns: string[]): boolean => {
|
||||
if (patterns.length === 0) return false;
|
||||
return patterns.some((pattern) => key.includes(pattern));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create localStorage cache provider
|
||||
*
|
||||
* Features:
|
||||
* - Whitelist mechanism: only cache specified keys
|
||||
* - TTL expiration: auto-cleanup expired data
|
||||
* - Version control: auto-cleanup when app version changes
|
||||
* - Capacity limit: prevent exceeding localStorage limit
|
||||
* - SSR compatible: returns empty Map on server
|
||||
* - Error recovery: fallback to memory cache on error
|
||||
*/
|
||||
export function createLocalStorageProvider(options: LocalStorageCacheOptions = {}) {
|
||||
const {
|
||||
cacheKey = 'lobechat-swr-cache',
|
||||
ttl = 24 * 60 * 60 * 1000, // 24 hours
|
||||
maxEntries = 50,
|
||||
version = '1.0.0',
|
||||
cacheablePatterns = DEFAULT_CACHEABLE_PATTERNS,
|
||||
onError = (error) => console.error('[SWR Cache]', error),
|
||||
} = options;
|
||||
|
||||
// Return memory cache when SSR or localStorage unavailable
|
||||
if (!isLocalStorageAvailable()) {
|
||||
return () => new Map();
|
||||
}
|
||||
|
||||
return (): Map<string, unknown> => {
|
||||
/**
|
||||
* Load cache from localStorage
|
||||
*/
|
||||
const loadCache = (): Map<string, unknown> => {
|
||||
try {
|
||||
const stored = localStorage.getItem(cacheKey);
|
||||
if (!stored) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const entries: [string, CacheEntry][] = JSON.parse(stored);
|
||||
const now = Date.now();
|
||||
|
||||
// Filter: expired data, version mismatch
|
||||
const validEntries = entries
|
||||
.filter(([, entry]) => {
|
||||
const isExpired = now - entry.timestamp > ttl;
|
||||
const isValidVersion = entry.version === version;
|
||||
return !isExpired && isValidVersion;
|
||||
})
|
||||
.map(([key, entry]) => [key, entry.data] as [string, unknown]);
|
||||
|
||||
return new Map(validEntries);
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
||||
const initialData = loadCache();
|
||||
|
||||
// Debounced save variables (outside class to avoid initialization order issues)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cacheMapInstance: Map<string, unknown> | null = null;
|
||||
|
||||
const saveCache = () => {
|
||||
if (!cacheMapInstance) return;
|
||||
try {
|
||||
// Only save whitelisted keys
|
||||
const entries = Array.from(cacheMapInstance.entries())
|
||||
.filter(([key]) => matchesCacheablePattern(key, cacheablePatterns))
|
||||
.slice(-maxEntries)
|
||||
.map(([key, data]) => [key, { data, timestamp: Date.now(), version } as CacheEntry]);
|
||||
|
||||
const serialized = JSON.stringify(entries);
|
||||
|
||||
// Check size, cleanup half when exceeding 4MB
|
||||
const sizeInMB = new Blob([serialized]).size / (1024 * 1024);
|
||||
if (sizeInMB > 4) {
|
||||
const reduced = entries.slice(-Math.floor(maxEntries / 2));
|
||||
localStorage.setItem(cacheKey, JSON.stringify(reduced));
|
||||
console.warn(`[SWR Cache] Cache too large (${sizeInMB.toFixed(2)}MB), cleaned up`);
|
||||
} else {
|
||||
localStorage.setItem(cacheKey, serialized);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as DOMException).name === 'QuotaExceededError') {
|
||||
// Quota exceeded, clear cache
|
||||
try {
|
||||
localStorage.removeItem(cacheKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
console.error('[SWR Cache] Quota exceeded, cache cleared');
|
||||
} else {
|
||||
onError(error as Error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSave = () => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveCache, 2000);
|
||||
};
|
||||
|
||||
// Save immediately on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveCache();
|
||||
});
|
||||
|
||||
// Multi-tab sync
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === cacheKey && event.newValue && cacheMapInstance) {
|
||||
try {
|
||||
const parsedEntries: [string, CacheEntry][] = JSON.parse(event.newValue);
|
||||
// Only update whitelisted keys
|
||||
parsedEntries.forEach(([key, entry]) => {
|
||||
if (matchesCacheablePattern(key, cacheablePatterns)) {
|
||||
cacheMapInstance!.set(key, entry.data);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create Map with interception
|
||||
* Only whitelisted keys trigger persistence
|
||||
*/
|
||||
class LocalStorageCacheMap extends Map<string, unknown> {
|
||||
private initialized = false;
|
||||
|
||||
constructor(entries?: readonly (readonly [string, unknown])[] | null) {
|
||||
super();
|
||||
// Manually add initial data to avoid triggering set in super()
|
||||
if (entries) {
|
||||
for (const [key, value] of entries) {
|
||||
super.set(key, value);
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
return super.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): this {
|
||||
super.set(key, value);
|
||||
// Only trigger save after initialization and for whitelisted keys
|
||||
if (this.initialized && matchesCacheablePattern(key, cacheablePatterns)) {
|
||||
debouncedSave();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const result = super.delete(key);
|
||||
if (this.initialized && matchesCacheablePattern(key, cacheablePatterns)) {
|
||||
debouncedSave();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Map instance
|
||||
const cacheMap = new LocalStorageCacheMap(Array.from(initialData.entries()));
|
||||
cacheMapInstance = cacheMap;
|
||||
|
||||
return cacheMap;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear SWR localStorage cache
|
||||
* Can be used for manual cleanup or when app version changes
|
||||
*/
|
||||
export function clearSWRCache(cacheKey = 'lobechat-swr-cache'): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(cacheKey);
|
||||
console.log('[SWR Cache] Cache cleared');
|
||||
} catch (error) {
|
||||
console.error('[SWR Cache] Failed to clear cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SWR localStorage cache whitelist
|
||||
* Only keys matching these patterns will be persisted to localStorage
|
||||
*/
|
||||
const SWR_CACHEABLE_PATTERNS = [
|
||||
// Home page data
|
||||
'fetchAgentList', // Agent list
|
||||
'fetchGroups', // Group list
|
||||
'fetchRecentTopics', // Recent topics
|
||||
'fetchRecentResources', // Recent resources
|
||||
'fetchRecentPages', // Recent pages
|
||||
// Chat page data
|
||||
'SWR_USE_FETCH_TOPIC', // Topic list (cached per agentId/groupId)
|
||||
'fetchGroupDetail', // Group detail (cached per groupId)
|
||||
];
|
||||
|
||||
/**
|
||||
* Export provider factory function for SWRConfig
|
||||
*/
|
||||
export const swrCacheProvider = () => {
|
||||
return createLocalStorageProvider({
|
||||
cacheablePatterns: SWR_CACHEABLE_PATTERNS,
|
||||
ttl: 12 * 60 * 60 * 1000, // 12 hours
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* useClientDataSWR with automatic Zustand store sync
|
||||
*
|
||||
* Solves the problem of SWR cached data not being immediately synced to Zustand store.
|
||||
* When SWR returns data from localStorage cache, it will automatically sync to store via onData callback.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { SWRConfiguration, SWRResponse } from 'swr';
|
||||
|
||||
import { useClientDataSWR } from './index';
|
||||
|
||||
type Key = string | readonly unknown[] | null | undefined;
|
||||
|
||||
interface UseClientDataSWRWithSyncOptions<T> extends SWRConfiguration<T> {
|
||||
/**
|
||||
* Data sync callback, called when data is available (both cached and fresh data)
|
||||
* Used to sync data to Zustand store
|
||||
*/
|
||||
onData?: (data: T) => void;
|
||||
/**
|
||||
* Whether to skip sync (optional, for conditional skipping)
|
||||
*/
|
||||
skipSync?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced version of useClientDataSWR with automatic cache data sync to Zustand store
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* useClientDataSWRWithSync(
|
||||
* isLogin ? ['fetchAgentList', isLogin] : null,
|
||||
* () => homeService.getSidebarAgentList(),
|
||||
* {
|
||||
* onData: (data) => {
|
||||
* // Auto sync to store, whether cached or fresh data
|
||||
* set({ ...mapResponseToState(data), isInit: true });
|
||||
* },
|
||||
* skipSync: state.isInit, // Optional: skip after initialized
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useClientDataSWRWithSync<T>(
|
||||
key: Key,
|
||||
fetcher: (() => Promise<T>) | null,
|
||||
options?: UseClientDataSWRWithSyncOptions<T>,
|
||||
): SWRResponse<T> {
|
||||
const { onData, skipSync, onSuccess, ...swrOptions } = options || {};
|
||||
const hasSyncedRef = useRef(false);
|
||||
|
||||
const response = useClientDataSWR<T>(key, fetcher, {
|
||||
...swrOptions,
|
||||
onSuccess: (data, key, config) => {
|
||||
// Call original onSuccess
|
||||
onSuccess?.(data, key, config);
|
||||
// Also sync via onData
|
||||
if (onData && !skipSync) {
|
||||
onData(data);
|
||||
hasSyncedRef.current = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = response;
|
||||
|
||||
// When cached data is available, sync to store immediately
|
||||
useEffect(() => {
|
||||
if (data && onData && !skipSync && !hasSyncedRef.current) {
|
||||
onData(data);
|
||||
hasSyncedRef.current = true;
|
||||
}
|
||||
}, [data, onData, skipSync]);
|
||||
|
||||
// Reset sync state when key changes
|
||||
useEffect(() => {
|
||||
hasSyncedRef.current = false;
|
||||
}, [key?.toString()]);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { mutate } from 'swr';
|
||||
import { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import type { ChatGroupItem } from '@/database/schemas/chatGroup';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { chatGroupService } from '@/services/chatGroup';
|
||||
import { getAgentStoreState } from '@/store/agent';
|
||||
import { ChatGroupStore } from '@/store/agentGroup/store';
|
||||
@@ -156,15 +156,15 @@ const chatGroupInternalSlice: StateCreator<
|
||||
},
|
||||
|
||||
useFetchGroupDetail: (enabled, groupId) =>
|
||||
useClientDataSWR<AgentGroupDetail | null>(
|
||||
useClientDataSWRWithSync<AgentGroupDetail | null>(
|
||||
enabled && groupId ? [FETCH_GROUP_DETAIL_KEY, groupId] : null,
|
||||
async ([, id]) => {
|
||||
const groupDetail = await chatGroupService.getGroupDetail(id as string);
|
||||
if (!groupDetail) throw new Error(`Group ${id} not found`);
|
||||
async () => {
|
||||
const groupDetail = await chatGroupService.getGroupDetail(groupId);
|
||||
if (!groupDetail) throw new Error(`Group ${groupId} not found`);
|
||||
return groupDetail;
|
||||
},
|
||||
{
|
||||
onSuccess: (groupDetail) => {
|
||||
onData: (groupDetail) => {
|
||||
if (!groupDetail) return;
|
||||
|
||||
// Update groupMap with detailed group info including agents
|
||||
@@ -181,7 +181,7 @@ const chatGroupInternalSlice: StateCreator<
|
||||
groupMap: nextGroupMap,
|
||||
},
|
||||
false,
|
||||
n('useFetchGroupDetail/onSuccess', { groupId: groupDetail.id }),
|
||||
n('useFetchGroupDetail/onData', { groupId: groupDetail.id }),
|
||||
);
|
||||
|
||||
// Sync group agents to agentStore for builtin agent resolution (e.g., supervisor slug)
|
||||
@@ -207,12 +207,12 @@ const chatGroupInternalSlice: StateCreator<
|
||||
// SWR Hooks for data fetching
|
||||
// This is not used for now, as we are combining group in the session lambda's response
|
||||
useFetchGroups: (enabled, isLogin) =>
|
||||
useClientDataSWR<ChatGroupItem[]>(
|
||||
useClientDataSWRWithSync<ChatGroupItem[]>(
|
||||
enabled ? [FETCH_GROUPS_KEY, isLogin] : null,
|
||||
async () => chatGroupService.getGroups(),
|
||||
{
|
||||
fallbackData: [],
|
||||
onSuccess: (groups) => {
|
||||
onData: (groups) => {
|
||||
// Update both groups list and groupMap
|
||||
const currentMap = get().groupMap;
|
||||
const nextGroupMap = groups.reduce(
|
||||
@@ -237,7 +237,7 @@ const chatGroupInternalSlice: StateCreator<
|
||||
groupsInit: true,
|
||||
},
|
||||
false,
|
||||
n('useFetchGroups/onSuccess'),
|
||||
n('useFetchGroups/onData'),
|
||||
);
|
||||
},
|
||||
suspense: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { message } from '@/components/AntdStaticMethods';
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { chatService } from '@/services/chat';
|
||||
import { messageService } from '@/services/message';
|
||||
import { topicService } from '@/services/topic';
|
||||
@@ -274,16 +274,15 @@ export const chatTopic: StateCreator<
|
||||
const containerKey = topicMapKey({ agentId, groupId });
|
||||
const hasValidContainer = !!(groupId || agentId);
|
||||
|
||||
return useClientDataSWR<{ items: ChatTopic[]; total: number }>(
|
||||
return useClientDataSWRWithSync<{ items: ChatTopic[]; total: number }>(
|
||||
enable && hasValidContainer
|
||||
? [SWR_USE_FETCH_TOPIC, containerKey, { isInbox, pageSize }]
|
||||
: null,
|
||||
async ([, key, params]: [string, string, { isInbox?: boolean; pageSize: number }]) => {
|
||||
const { isInbox, pageSize } = params;
|
||||
// agentId and groupId come from the outer scope closure
|
||||
async () => {
|
||||
// agentId, groupId, isInbox, pageSize come from the outer scope closure
|
||||
if (!agentId && !groupId) return { items: [], total: 0 };
|
||||
|
||||
const currentData = get().topicDataMap[key];
|
||||
const currentData = get().topicDataMap[containerKey];
|
||||
const lastPageSize = currentData?.pageSize;
|
||||
const hasExistingItems = (currentData?.items?.length || 0) > 0;
|
||||
|
||||
@@ -292,7 +291,7 @@ export const chatTopic: StateCreator<
|
||||
const isExpanding =
|
||||
hasExistingItems && typeof lastPageSize === 'number' && pageSize > lastPageSize;
|
||||
if (isExpanding) {
|
||||
get().internal_updateTopicData(key, { isExpandingPageSize: true });
|
||||
get().internal_updateTopicData(containerKey, { isExpandingPageSize: true });
|
||||
}
|
||||
|
||||
const result = await topicService.getTopics({
|
||||
@@ -305,14 +304,14 @@ export const chatTopic: StateCreator<
|
||||
|
||||
// Reset expanding state after fetch completes
|
||||
if (isExpanding) {
|
||||
get().internal_updateTopicData(key, { isExpandingPageSize: false });
|
||||
get().internal_updateTopicData(containerKey, { isExpandingPageSize: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{
|
||||
// onSuccess: responsible for state updates
|
||||
onSuccess: async (result) => {
|
||||
// onData: responsible for state updates (fires for both cached and fresh data)
|
||||
onData: (result) => {
|
||||
if (!hasValidContainer) return;
|
||||
|
||||
const { items: topics, total: totalCount } = result;
|
||||
@@ -338,7 +337,7 @@ export const chatTopic: StateCreator<
|
||||
},
|
||||
},
|
||||
false,
|
||||
n('useFetchTopics(success)', { containerKey }),
|
||||
n('useFetchTopics(onData)', { containerKey }),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mutate } from 'swr';
|
||||
import type { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import type { SidebarAgentItem, SidebarAgentListResponse } from '@/database/repositories/home';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { useClientDataSWR, useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { homeService } from '@/services/home';
|
||||
import type { HomeStore } from '@/store/home/store';
|
||||
import { setNamespace } from '@/utils/storeDebug';
|
||||
@@ -58,11 +58,11 @@ export const createAgentListSlice: StateCreator<
|
||||
},
|
||||
|
||||
useFetchAgentList: (isLogin) =>
|
||||
useClientDataSWR<SidebarAgentListResponse>(
|
||||
useClientDataSWRWithSync<SidebarAgentListResponse>(
|
||||
isLogin === true ? [FETCH_AGENT_LIST_KEY, isLogin] : null,
|
||||
() => homeService.getSidebarAgentList(),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onData: (data) => {
|
||||
const state = get();
|
||||
const newState = mapResponseToState(data);
|
||||
|
||||
@@ -82,7 +82,7 @@ export const createAgentListSlice: StateCreator<
|
||||
isAgentListInit: true,
|
||||
},
|
||||
false,
|
||||
n('useFetchAgentList/onSuccess'),
|
||||
n('useFetchAgentList/onData'),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import isEqual from 'fast-deep-equal';
|
||||
import type { SWRResponse } from 'swr';
|
||||
import type { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { fileService } from '@/services/file';
|
||||
import { topicService } from '@/services/topic';
|
||||
import type { HomeStore } from '@/store/home/store';
|
||||
@@ -29,54 +29,54 @@ export const createRecentSlice: StateCreator<
|
||||
RecentAction
|
||||
> = (set, get) => ({
|
||||
useFetchRecentPages: (isLogin) =>
|
||||
useClientDataSWR<any[]>(
|
||||
useClientDataSWRWithSync<any[]>(
|
||||
// Only fetch when login status is explicitly true (not null/undefined)
|
||||
isLogin === true ? [FETCH_RECENT_PAGES_KEY, isLogin] : null,
|
||||
async () => fileService.getRecentPages(12),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onData: (data) => {
|
||||
if (get().isRecentPagesInit && isEqual(get().recentPages, data)) return;
|
||||
|
||||
set(
|
||||
{ isRecentPagesInit: true, recentPages: data },
|
||||
false,
|
||||
n('useFetchRecentPages/onSuccess'),
|
||||
n('useFetchRecentPages/onData'),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
useFetchRecentResources: (isLogin) =>
|
||||
useClientDataSWR<FileListItem[]>(
|
||||
useClientDataSWRWithSync<FileListItem[]>(
|
||||
// Only fetch when login status is explicitly true (not null/undefined)
|
||||
isLogin === true ? [FETCH_RECENT_RESOURCES_KEY, isLogin] : null,
|
||||
async () => fileService.getRecentFiles(12),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onData: (data) => {
|
||||
if (get().isRecentResourcesInit && isEqual(get().recentResources, data)) return;
|
||||
|
||||
set(
|
||||
{ isRecentResourcesInit: true, recentResources: data },
|
||||
false,
|
||||
n('useFetchRecentResources/onSuccess'),
|
||||
n('useFetchRecentResources/onData'),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
useFetchRecentTopics: (isLogin) =>
|
||||
useClientDataSWR<RecentTopic[]>(
|
||||
useClientDataSWRWithSync<RecentTopic[]>(
|
||||
// Only fetch when login status is explicitly true (not null/undefined)
|
||||
isLogin === true ? [FETCH_RECENT_TOPICS_KEY, isLogin] : null,
|
||||
async () => topicService.getRecentTopics(12),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onData: (data) => {
|
||||
if (get().isRecentTopicsInit && isEqual(get().recentTopics, data)) return;
|
||||
|
||||
set(
|
||||
{ isRecentTopicsInit: true, recentTopics: data },
|
||||
false,
|
||||
n('useFetchRecentTopics/onSuccess'),
|
||||
n('useFetchRecentTopics/onData'),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user