diff --git a/.claude/skills/component-refactoring/SKILL.md b/.claude/skills/component-refactoring/SKILL.md index ea695ea442..7006c382c8 100644 --- a/.claude/skills/component-refactoring/SKILL.md +++ b/.claude/skills/component-refactoring/SKILL.md @@ -187,7 +187,7 @@ const Template = useMemo(() => { **When**: Component directly handles API calls, data transformation, or complex async operations. -**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. Project is migrating from SWR to React Query. +**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. ```typescript // ❌ Before: API logic in component diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 91bdb3f99a..a0ccde957d 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import * as React from 'react' +import { AppInitializer } from '@/app/components/app-initializer' import AmplitudeProvider from '@/app/components/base/amplitude' import GA, { GaType } from '@/app/components/base/ga' import Zendesk from '@/app/components/base/zendesk' @@ -7,7 +8,6 @@ import GotoAnything from '@/app/components/goto-anything' import Header from '@/app/components/header' import HeaderWrapper from '@/app/components/header/header-wrapper' import ReadmePanel from '@/app/components/plugins/readme-panel' -import SwrInitializer from '@/app/components/swr-initializer' import { AppContextProvider } from '@/context/app-context' import { EventEmitterContextProvider } from '@/context/event-emitter' import { ModalContextProvider } from '@/context/modal-context' @@ -20,7 +20,7 @@ const Layout = ({ children }: { children: ReactNode }) => { <> - + @@ -38,7 +38,7 @@ const Layout = ({ children }: { children: ReactNode }) => { - + ) } diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx index f264441b86..e4125015d9 100644 --- a/web/app/account/(commonLayout)/layout.tsx +++ b/web/app/account/(commonLayout)/layout.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react' import * as React from 'react' +import { AppInitializer } from '@/app/components/app-initializer' import AmplitudeProvider from '@/app/components/base/amplitude' import GA, { GaType } from '@/app/components/base/ga' import HeaderWrapper from '@/app/components/header/header-wrapper' -import SwrInitor from '@/app/components/swr-initializer' import { AppContextProvider } from '@/context/app-context' import { EventEmitterContextProvider } from '@/context/event-emitter' import { ModalContextProvider } from '@/context/modal-context' @@ -15,7 +15,7 @@ const Layout = ({ children }: { children: ReactNode }) => { <> - + @@ -30,7 +30,7 @@ const Layout = ({ children }: { children: ReactNode }) => { - + ) } diff --git a/web/app/components/swr-initializer.tsx b/web/app/components/app-initializer.tsx similarity index 80% rename from web/app/components/swr-initializer.tsx rename to web/app/components/app-initializer.tsx index 31be6d62b5..0f710abf39 100644 --- a/web/app/components/swr-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' -import { SWRConfig } from 'swr' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, @@ -11,12 +10,13 @@ import { import { fetchSetupStatus } from '@/service/common' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' -type SwrInitializerProps = { +type AppInitializerProps = { children: ReactNode } -const SwrInitializer = ({ + +export const AppInitializer = ({ children, -}: SwrInitializerProps) => { +}: AppInitializerProps) => { const router = useRouter() const searchParams = useSearchParams() // Tokens are now stored in cookies, no need to check localStorage @@ -69,20 +69,5 @@ const SwrInitializer = ({ })() }, [isSetupFinished, router, pathname, searchParams]) - return init - ? ( - new Map(), - }} - > - {children} - - ) - : null + return init ? children : null } - -export default SwrInitializer diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx new file mode 100644 index 0000000000..32ef133453 --- /dev/null +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -0,0 +1,270 @@ +import type { ReactNode } from 'react' +import type { ChatConfig } from '../types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { ToastProvider } from '@/app/components/base/toast' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from '@/service/share' +import { shareQueryKeys } from '@/service/use-share' +import { CONVERSATION_ID_INFO } from '../constants' +import { useChatWithHistory } from './hooks' + +vi.mock('@/hooks/use-app-favicon', () => ({ + useAppFavicon: vi.fn(), +})) + +vi.mock('@/i18n-config/i18next-config', () => ({ + changeLanguage: vi.fn().mockResolvedValue(undefined), +})) + +const mockStoreState: { + appInfo: AppData | null + appMeta: AppMeta | null + appParams: ChatConfig | null +} = { + appInfo: null, + appMeta: null, + appParams: null, +} + +const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => { + return selector ? selector(mockStoreState) : mockStoreState +}) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), +})) + +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils') + return { + ...actual, + getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({ user_id: 'user-1' }), + getRawInputsFromUrlParams: vi.fn().mockResolvedValue({}), + getRawUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + } +}) + +vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + delConversation: vi.fn(), + pinConversation: vi.fn(), + renameConversation: vi.fn(), + unpinConversation: vi.fn(), + updateFeedback: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const renderWithClient = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: '', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 100, + ...overrides, +}) + +const setConversationIdInfo = (appId: string, conversationId: string) => { + const value = { + [appId]: { + 'user-1': conversationId, + 'DEFAULT': conversationId, + }, + } + localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value)) +} + +// Scenario: useChatWithHistory integrates share queries for conversations and chat list. +describe('useChatWithHistory', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.removeItem(CONVERSATION_ID_INFO) + mockStoreState.appInfo = { + app_id: 'app-1', + custom_config: null, + site: { + title: 'Test App', + default_language: 'en-US', + }, + } + mockStoreState.appMeta = { + tool_icons: {}, + } + mockStoreState.appParams = null + setConversationIdInfo('app-1', 'conversation-1') + }) + + afterEach(() => { + localStorage.removeItem(CONVERSATION_ID_INFO) + }) + + // Scenario: share query results populate conversation lists and trigger chat list fetch. + describe('Share queries', () => { + it('should load pinned, unpinned, and chat list data from share queries', async () => { + // Arrange + const pinnedData = createConversationData({ + data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })], + }) + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }) + mockFetchChatList.mockResolvedValue({ data: [] }) + + // Act + const { result } = renderWithClient(() => useChatWithHistory()) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + }) + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + }) + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + }) + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) + }) + + // Scenario: completion invalidates share caches and merges generated names. + describe('New conversation completion', () => { + it('should invalidate share conversations and apply generated name', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + const generatedConversation = createConversationItem({ + id: 'conversation-new', + name: 'Generated', + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(generatedConversation) + + const { result, queryClient } = renderWithClient(() => useChatWithHistory()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + }) + await waitFor(() => { + expect(result.current.conversationList[0]).toEqual(generatedConversation) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) + }) + + // Scenario: chat list queries stop when reload key is cleared. + describe('Chat list gating', () => { + it('should not refetch chat list when newConversationId matches current conversation', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + + const { result } = renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-1') + }) + + // Assert + await waitFor(() => { + expect(result.current.chatShouldReloadKey).toBe('') + }) + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: conversation id updates persist to localStorage. + describe('Conversation id persistence', () => { + it('should store new conversation id in localStorage after completion', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + + const { result } = renderWithClient(() => useChatWithHistory()) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const parsed = storedValue ? JSON.parse(storedValue) : {} + const storedUserId = parsed['app-1']?.['user-1'] + const storedDefaultId = parsed['app-1']?.DEFAULT + expect([storedUserId, storedDefaultId]).toContain('conversation-new') + }) + }) + }) +}) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 3acc480518..8a3617129e 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -20,7 +20,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { useToastContext } from '@/app/components/base/toast' import { InputVarType } from '@/app/components/workflow/types' @@ -29,14 +28,17 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { changeLanguage } from '@/i18n-config/i18next-config' import { delConversation, - fetchChatList, - fetchConversations, - generationConversationName, pinConversation, renameConversation, unpinConversation, updateFeedback, } from '@/service/share' +import { + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from '@/service/use-share' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { CONVERSATION_ID_INFO } from '../constants' @@ -174,21 +176,42 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return currentConversationId }, [currentConversationId, newConversationId]) - const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR( - appId ? ['appConversationData', isInstalledApp, appId, true] : null, - () => fetchConversations(isInstalledApp, appId, undefined, true, 100), - { revalidateOnFocus: false, revalidateOnReconnect: false }, - ) - const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR( - appId ? ['appConversationData', isInstalledApp, appId, false] : null, - () => fetchConversations(isInstalledApp, appId, undefined, false, 100), - { revalidateOnFocus: false, revalidateOnReconnect: false }, - ) - const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR( - chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, - () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId), - { revalidateOnFocus: false, revalidateOnReconnect: false }, - ) + const { data: appPinnedConversationData } = useShareConversations({ + isInstalledApp, + appId, + pinned: true, + limit: 100, + }, { + enabled: !!appId, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + const { + data: appConversationData, + isLoading: appConversationDataLoading, + } = useShareConversations({ + isInstalledApp, + appId, + pinned: false, + limit: 100, + }, { + enabled: !!appId, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + const { + data: appChatListData, + isLoading: appChatListDataLoading, + } = useShareChatList({ + conversationId: chatShouldReloadKey, + isInstalledApp, + appId, + }, { + enabled: !!chatShouldReloadKey, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + const invalidateShareConversations = useInvalidateShareConversations() const [clearChatList, setClearChatList] = useState(false) const [isResponding, setIsResponding] = useState(false) @@ -309,7 +332,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { handleNewConversationInputsChange(conversationInputs) }, [handleNewConversationInputsChange, inputsForms]) - const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false }) + const { data: newConversation } = useShareConversationName({ + conversationId: newConversationId, + isInstalledApp, + appId, + }, { + refetchOnWindowFocus: false, + }) const [originConversationList, setOriginConversationList] = useState([]) useEffect(() => { if (appConversationData?.data && !appConversationDataLoading) @@ -429,9 +458,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setClearChatList(true) }, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms]) const handleUpdateConversationList = useCallback(() => { - mutateAppConversationData() - mutateAppPinnedConversationData() - }, [mutateAppConversationData, mutateAppPinnedConversationData]) + invalidateShareConversations() + }, [invalidateShareConversations]) const handlePinConversation = useCallback(async (conversationId: string) => { await pinConversation(isInstalledApp, appId, conversationId) @@ -518,8 +546,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setNewConversationId(newConversationId) handleConversationIdInfoChange(newConversationId) setShowNewConversationItemInList(false) - mutateAppConversationData() - }, [mutateAppConversationData, handleConversationIdInfoChange]) + invalidateShareConversations() + }, [handleConversationIdInfoChange, invalidateShareConversations]) const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx new file mode 100644 index 0000000000..ca6a90c4d8 --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -0,0 +1,257 @@ +import type { ReactNode } from 'react' +import type { ChatConfig } from '../types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { ToastProvider } from '@/app/components/base/toast' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from '@/service/share' +import { shareQueryKeys } from '@/service/use-share' +import { CONVERSATION_ID_INFO } from '../constants' +import { useEmbeddedChatbot } from './hooks' + +vi.mock('@/i18n-config/i18next-config', () => ({ + changeLanguage: vi.fn().mockResolvedValue(undefined), +})) + +const mockStoreState: { + appInfo: AppData | null + appMeta: AppMeta | null + appParams: ChatConfig | null + embeddedConversationId: string | null + embeddedUserId: string | null +} = { + appInfo: null, + appMeta: null, + appParams: null, + embeddedConversationId: null, + embeddedUserId: null, +} + +const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => { + return selector ? selector(mockStoreState) : mockStoreState +}) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), +})) + +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils') + return { + ...actual, + getProcessedInputsFromUrlParams: vi.fn().mockResolvedValue({}), + getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + getProcessedUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + } +}) + +vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + updateFeedback: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const renderWithClient = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: '', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 100, + ...overrides, +}) + +// Scenario: useEmbeddedChatbot integrates share queries for conversations and chat list. +describe('useEmbeddedChatbot', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.removeItem(CONVERSATION_ID_INFO) + mockStoreState.appInfo = { + app_id: 'app-1', + custom_config: null, + site: { + title: 'Test App', + default_language: 'en-US', + }, + } + mockStoreState.appMeta = { + tool_icons: {}, + } + mockStoreState.appParams = null + mockStoreState.embeddedConversationId = 'conversation-1' + mockStoreState.embeddedUserId = 'embedded-user-1' + }) + + afterEach(() => { + localStorage.removeItem(CONVERSATION_ID_INFO) + }) + + // Scenario: share query results populate conversation lists and trigger chat list fetch. + describe('Share queries', () => { + it('should load pinned, unpinned, and chat list data from share queries', async () => { + // Arrange + const pinnedData = createConversationData({ + data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })], + }) + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }) + mockFetchChatList.mockResolvedValue({ data: [] }) + + // Act + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + }) + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + }) + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + }) + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) + }) + + // Scenario: completion invalidates share caches and merges generated names. + describe('New conversation completion', () => { + it('should invalidate share conversations and apply generated name', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + const generatedConversation = createConversationItem({ + id: 'conversation-new', + name: 'Generated', + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(generatedConversation) + + const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + }) + await waitFor(() => { + expect(result.current.conversationList[0]).toEqual(generatedConversation) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) + }) + + // Scenario: chat list queries stop when reload key is cleared. + describe('Chat list gating', () => { + it('should not refetch chat list when newConversationId matches current conversation', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-1') + }) + + // Assert + await waitFor(() => { + expect(result.current.chatShouldReloadKey).toBe('') + }) + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: conversation id updates persist to localStorage. + describe('Conversation id persistence', () => { + it('should store new conversation id in localStorage after completion', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const parsed = storedValue ? JSON.parse(storedValue) : {} + const storedUserId = parsed['app-1']?.['embedded-user-1'] + const storedDefaultId = parsed['app-1']?.DEFAULT + expect([storedUserId, storedDefaultId]).toContain('conversation-new') + }) + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 9e9125fc45..678590cde2 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -19,18 +19,18 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { useToastContext } from '@/app/components/base/toast' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { InputVarType } from '@/app/components/workflow/types' import { useWebAppStore } from '@/context/web-app-context' import { changeLanguage } from '@/i18n-config/i18next-config' +import { updateFeedback } from '@/service/share' import { - fetchChatList, - fetchConversations, - generationConversationName, - updateFeedback, -} from '@/service/share' + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from '@/service/use-share' import { TransferMethod } from '@/types/app' import { getProcessedFilesFromResponse } from '../../file-uploader/utils' import { CONVERSATION_ID_INFO } from '../constants' @@ -137,9 +137,30 @@ export const useEmbeddedChatbot = () => { return currentConversationId }, [currentConversationId, newConversationId]) - const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100)) - const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100)) - const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId)) + const { data: appPinnedConversationData } = useShareConversations({ + isInstalledApp, + appId, + pinned: true, + limit: 100, + }) + const { + data: appConversationData, + isLoading: appConversationDataLoading, + } = useShareConversations({ + isInstalledApp, + appId, + pinned: false, + limit: 100, + }) + const { + data: appChatListData, + isLoading: appChatListDataLoading, + } = useShareChatList({ + conversationId: chatShouldReloadKey, + isInstalledApp, + appId, + }) + const invalidateShareConversations = useInvalidateShareConversations() const [clearChatList, setClearChatList] = useState(false) const [isResponding, setIsResponding] = useState(false) @@ -259,7 +280,13 @@ export const useEmbeddedChatbot = () => { handleNewConversationInputsChange(conversationInputs) }, [handleNewConversationInputsChange, inputsForms]) - const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false }) + const { data: newConversation } = useShareConversationName({ + conversationId: newConversationId, + isInstalledApp, + appId, + }, { + refetchOnWindowFocus: false, + }) const [originConversationList, setOriginConversationList] = useState([]) useEffect(() => { if (appConversationData?.data && !appConversationDataLoading) @@ -379,8 +406,8 @@ export const useEmbeddedChatbot = () => { setNewConversationId(newConversationId) handleConversationIdInfoChange(newConversationId) setShowNewConversationItemInList(false) - mutateAppConversationData() - }, [mutateAppConversationData, handleConversationIdInfoChange]) + invalidateShareConversations() + }, [handleConversationIdInfoChange, invalidateShareConversations]) const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId) diff --git a/web/knip.config.ts b/web/knip.config.ts index 6151a78af7..a63f742a21 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -76,7 +76,7 @@ const config: KnipConfig = { // Browser initialization (runs on client startup) 'app/components/browser-initializer.tsx!', 'app/components/sentry-initializer.tsx!', - 'app/components/swr-initializer.tsx!', + 'app/components/app-initializer.tsx!', // i18n initialization (server and client) 'app/components/i18n.tsx!', diff --git a/web/package.json b/web/package.json index 369cc212ab..00b4c7498c 100644 --- a/web/package.json +++ b/web/package.json @@ -138,7 +138,6 @@ "semver": "^7.7.3", "sharp": "^0.33.5", "sortablejs": "^1.15.6", - "swr": "^2.3.6", "tailwind-merge": "^2.6.0", "tldts": "^7.0.17", "use-context-selector": "^2.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f69ac5adda..3ec415c183 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -330,9 +330,6 @@ importers: sortablejs: specifier: ^1.15.6 version: 1.15.6 - swr: - specifier: ^2.3.6 - version: 2.3.7(react@19.2.3) tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -8096,11 +8093,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.3.7: - resolution: {integrity: sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -17682,12 +17674,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.7(react@19.2.3): - dependencies: - dequal: 2.0.3 - react: 19.2.3 - use-sync-external-store: 1.6.0(react@19.2.3) - symbol-tree@3.2.4: {} synckit@0.11.11: diff --git a/web/scripts/analyze-component.js b/web/scripts/analyze-component.js index 8b01744b3d..9347a82fa5 100755 --- a/web/scripts/analyze-component.js +++ b/web/scripts/analyze-component.js @@ -46,7 +46,6 @@ Features Detected: ${analysis.hasEvents ? '✓' : '✗'} Event handlers ${analysis.hasRouter ? '✓' : '✗'} Next.js routing ${analysis.hasAPI ? '✓' : '✗'} API calls - ${analysis.hasSWR ? '✓' : '✗'} SWR data fetching ${analysis.hasReactQuery ? '✓' : '✗'} React Query ${analysis.hasAhooks ? '✓' : '✗'} ahooks ${analysis.hasForwardRef ? '✓' : '✗'} Ref forwarding (forwardRef) @@ -236,7 +235,7 @@ Create the test file at: ${testPath} // ===== API Calls ===== if (analysis.hasAPI) { guidelines.push('🌐 API calls detected:') - guidelines.push(' - Mock API calls/hooks (useSWR, useQuery, fetch, etc.)') + guidelines.push(' - Mock API calls/hooks (useQuery, useMutation, fetch, etc.)') guidelines.push(' - Test loading, success, and error states') guidelines.push(' - Focus on component behavior, not the data fetching lib') } diff --git a/web/scripts/component-analyzer.js b/web/scripts/component-analyzer.js index c53b652bc2..8bd3dc4409 100644 --- a/web/scripts/component-analyzer.js +++ b/web/scripts/component-analyzer.js @@ -21,6 +21,7 @@ export class ComponentAnalyzer { const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath) const fileName = path.basename(filePath, path.extname(filePath)) const lineCount = code.split('\n').length + const hasReactQuery = /\buse(?:Query|Queries|InfiniteQuery|SuspenseQuery|SuspenseInfiniteQuery|Mutation)\b/.test(code) // Calculate complexity metrics const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code) @@ -44,14 +45,13 @@ export class ComponentAnalyzer { hasMemo: code.includes('useMemo'), hasEvents: /on[A-Z]\w+/.test(code), hasRouter: code.includes('useRouter') || code.includes('usePathname'), - hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'), + hasAPI: code.includes('service/') || code.includes('fetch(') || hasReactQuery, hasForwardRef: code.includes('forwardRef'), hasComponentMemo: /React\.memo|memo\(/.test(code), hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code), hasPortal: code.includes('createPortal'), hasImperativeHandle: code.includes('useImperativeHandle'), - hasSWR: code.includes('useSWR'), - hasReactQuery: code.includes('useQuery') || code.includes('useMutation'), + hasReactQuery, hasAhooks: code.includes('from \'ahooks\''), complexity, maxComplexity, diff --git a/web/scripts/refactor-component.js b/web/scripts/refactor-component.js index f890540515..a054650ba3 100644 --- a/web/scripts/refactor-component.js +++ b/web/scripts/refactor-component.js @@ -123,7 +123,6 @@ Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ${analysis.hasRouter ? '✓' : '✗'} Next.js routing ${analysis.hasAPI ? '✓' : '✗'} API calls ${analysis.hasReactQuery ? '✓' : '✗'} React Query - ${analysis.hasSWR ? '✓' : '✗'} SWR (should migrate to React Query) ${analysis.hasAhooks ? '✓' : '✗'} ahooks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -150,7 +149,7 @@ ${this.buildRequirements(analysis)} Follow Dify project conventions: - Place extracted hooks in \`hooks/\` subdirectory or as \`use-.ts\` -- Use React Query (\`@tanstack/react-query\`) for data fetching, not SWR +- Use React Query (\`@tanstack/react-query\`) for data fetching - Follow existing patterns in \`web/service/use-*.ts\` for API hooks - Keep each new file under 300 lines - Maintain TypeScript strict typing @@ -173,12 +172,8 @@ After refactoring, verify: } // Priority 2: Extract API/data logic - if (analysis.hasAPI && (analysis.hasEffects || analysis.hasSWR)) { - if (analysis.hasSWR) { - actions.push('🔄 MIGRATE SWR TO REACT QUERY: Replace useSWR with useQuery from @tanstack/react-query') - } + if (analysis.hasAPI) actions.push('🌐 EXTRACT DATA HOOK: Move API calls and data fetching logic into a dedicated hook using React Query') - } // Priority 3: Split large components if (analysis.lineCount > 300) { diff --git a/web/service/annotation.ts b/web/service/annotation.ts index acdb944386..8a19425044 100644 --- a/web/service/annotation.ts +++ b/web/service/annotation.ts @@ -1,4 +1,3 @@ -import type { Fetcher } from 'swr' import type { AnnotationCreateResponse, AnnotationEnableStatus, AnnotationItemBasic, EmbeddingModelConfig } from '@/app/components/app/annotation/type' import { ANNOTATION_DEFAULT } from '@/config' import { del, get, post } from './base' @@ -44,11 +43,11 @@ export const addAnnotation = (appId: string, body: AnnotationItemBasic) => { return post(`apps/${appId}/annotations`, { body }) } -export const annotationBatchImport: Fetcher<{ job_id: string, job_status: string }, { url: string, body: FormData }> = ({ url, body }) => { +export const annotationBatchImport = ({ url, body }: { url: string, body: FormData }): Promise<{ job_id: string, job_status: string }> => { return post<{ job_id: string, job_status: string }>(url, { body }, { bodyStringify: false, deleteContentType: true }) } -export const checkAnnotationBatchImportProgress: Fetcher<{ job_id: string, job_status: string }, { jobID: string, appId: string }> = ({ jobID, appId }) => { +export const checkAnnotationBatchImportProgress = ({ jobID, appId }: { jobID: string, appId: string }): Promise<{ job_id: string, job_status: string }> => { return get<{ job_id: string, job_status: string }>(`/apps/${appId}/annotations/batch-import-status/${jobID}`) } diff --git a/web/service/apps.ts b/web/service/apps.ts index 1e3c93a33a..db79141ec6 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -12,7 +12,6 @@ export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promis return get(`${url}/${id}`) } -// Direct API call function for non-SWR usage export const fetchAppDetailDirect = async ({ url, id }: { url: string, id: string }): Promise => { return get(`${url}/${id}`) } diff --git a/web/service/plugins.ts b/web/service/plugins.ts index afaebf4fb5..cf05d8c20d 100644 --- a/web/service/plugins.ts +++ b/web/service/plugins.ts @@ -1,4 +1,3 @@ -import type { Fetcher } from 'swr' import type { MarketplaceCollectionPluginsResponse, MarketplaceCollectionsResponse, @@ -82,11 +81,11 @@ export const fetchPluginInfoFromMarketPlace = async ({ return getMarketplace<{ data: { plugin: PluginInfoFromMarketPlace, version: { version: string } } }>(`/plugins/${org}/${name}`) } -export const fetchMarketplaceCollections: Fetcher = ({ url }) => { +export const fetchMarketplaceCollections = ({ url }: { url: string }): Promise => { return get(url) } -export const fetchMarketplaceCollectionPlugins: Fetcher = ({ url }) => { +export const fetchMarketplaceCollectionPlugins = ({ url }: { url: string }): Promise => { return get(url) } diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 7db65caccb..77bb2a11fa 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -55,7 +55,7 @@ export const commonQueryKeys = { ] as const, notionBinding: (code?: string | null) => [NAME_SPACE, 'notion-binding', code] as const, modelParameterRules: (provider?: string, model?: string) => [NAME_SPACE, 'model-parameter-rules', provider, model] as const, - langGeniusVersion: (currentVersion?: string | null) => [NAME_SPACE, 'lang-genius-version', currentVersion] as const, + langGeniusVersion: (currentVersion?: string | null) => [NAME_SPACE, 'langgenius-version', currentVersion] as const, forgotPasswordValidity: (token?: string | null) => [NAME_SPACE, 'forgot-password-validity', token] as const, dataSourceIntegrates: [NAME_SPACE, 'data-source-integrates'] as const, } diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx new file mode 100644 index 0000000000..d0202ed140 --- /dev/null +++ b/web/service/use-share.spec.tsx @@ -0,0 +1,231 @@ +import type { ReactNode } from 'react' +import type { AppConversationData, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from './share' +import { + shareQueryKeys, + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from './use-share' + +vi.mock('./share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const renderShareHook = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: 'Intro', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 20, + ...overrides, +}) + +// Scenario: share conversation list queries behave consistently with params and enablement. +describe('useShareConversations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch conversations when enabled for non-installed apps', async () => { + // Arrange + const params = { + isInstalledApp: false, + appId: undefined, + pinned: true, + limit: 50, + } + const response = createConversationData() + mockFetchConversations.mockResolvedValueOnce(response) + + // Act + const { result, queryClient } = renderShareHook(() => useShareConversations(params)) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, undefined, undefined, true, 50) + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + expect(queryClient.getQueryCache().find({ queryKey: shareQueryKeys.conversationList(params) })).toBeDefined() + }) + + it('should not fetch conversations when installed app lacks appId', async () => { + // Arrange + const params = { + isInstalledApp: true, + appId: undefined, + } + + // Act + const { result } = renderShareHook(() => useShareConversations(params)) + + // Assert + await waitFor(() => { + expect(result.current.fetchStatus).toBe('idle') + }) + expect(mockFetchConversations).not.toHaveBeenCalled() + }) +}) + +// Scenario: chat list queries respect conversation ID and app installation rules. +describe('useShareChatList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch chat list when conversationId is provided', async () => { + // Arrange + const params = { + conversationId: 'conversation-1', + isInstalledApp: true, + appId: 'app-1', + } + const response = { data: [] } + mockFetchChatList.mockResolvedValueOnce(response) + + // Act + const { result } = renderShareHook(() => useShareChatList(params)) + + // Assert + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', true, 'app-1') + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + }) + + it('should not fetch chat list when conversationId is empty', async () => { + // Arrange + const params = { + conversationId: '', + isInstalledApp: false, + appId: undefined, + } + + // Act + const { result } = renderShareHook(() => useShareChatList(params)) + + // Assert + await waitFor(() => { + expect(result.current.fetchStatus).toBe('idle') + }) + expect(mockFetchChatList).not.toHaveBeenCalled() + }) +}) + +// Scenario: conversation name queries follow enabled flags and installation constraints. +describe('useShareConversationName', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch conversation name when enabled and conversationId exists', async () => { + // Arrange + const params = { + conversationId: 'conversation-2', + isInstalledApp: false, + appId: undefined, + } + const response = createConversationItem({ id: 'conversation-2', name: 'Generated' }) + mockGenerationConversationName.mockResolvedValueOnce(response) + + // Act + const { result } = renderShareHook(() => useShareConversationName(params)) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, undefined, 'conversation-2') + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + }) + + it('should not fetch conversation name when disabled via options', async () => { + // Arrange + const params = { + conversationId: 'conversation-3', + isInstalledApp: false, + appId: undefined, + } + + // Act + const { result } = renderShareHook(() => useShareConversationName(params, { enabled: false })) + + // Assert + await waitFor(() => { + expect(result.current.fetchStatus).toBe('idle') + }) + expect(mockGenerationConversationName).not.toHaveBeenCalled() + }) +}) + +// Scenario: invalidation helper clears share conversation caches. +describe('useInvalidateShareConversations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should invalidate share conversations query key when invoked', () => { + // Arrange + const { result, queryClient } = renderShareHook(() => useInvalidateShareConversations()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current() + }) + + // Assert + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) +}) diff --git a/web/service/use-share.ts b/web/service/use-share.ts index a5e0a11100..4dd43e06aa 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -1,11 +1,58 @@ +import type { AppConversationData, ConversationItem } from '@/models/share' import { useQuery } from '@tanstack/react-query' -import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share' +import { + fetchAppInfo, + fetchAppMeta, + fetchAppParams, + fetchChatList, + fetchConversations, + generationConversationName, + getAppAccessModeByAppCode, +} from './share' +import { useInvalid } from './use-base' const NAME_SPACE = 'webapp' +type ShareConversationsParams = { + isInstalledApp: boolean + appId?: string + lastId?: string + pinned?: boolean + limit?: number +} + +type ShareChatListParams = { + conversationId: string + isInstalledApp: boolean + appId?: string +} + +type ShareConversationNameParams = { + conversationId: string + isInstalledApp: boolean + appId?: string +} + +type ShareQueryOptions = { + enabled?: boolean + refetchOnWindowFocus?: boolean + refetchOnReconnect?: boolean +} + +export const shareQueryKeys = { + appAccessMode: (code: string | null) => [NAME_SPACE, 'appAccessMode', code] as const, + appInfo: [NAME_SPACE, 'appInfo'] as const, + appParams: [NAME_SPACE, 'appParams'] as const, + appMeta: [NAME_SPACE, 'appMeta'] as const, + conversations: [NAME_SPACE, 'conversations'] as const, + conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const, + chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const, + conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const, +} + export const useGetWebAppAccessModeByCode = (code: string | null) => { return useQuery({ - queryKey: [NAME_SPACE, 'appAccessMode', code], + queryKey: shareQueryKeys.appAccessMode(code), queryFn: () => getAppAccessModeByAppCode(code!), enabled: !!code, staleTime: 0, // backend change the access mode may cause the logic error. Because /permission API is no cached. @@ -15,7 +62,7 @@ export const useGetWebAppAccessModeByCode = (code: string | null) => { export const useGetWebAppInfo = () => { return useQuery({ - queryKey: [NAME_SPACE, 'appInfo'], + queryKey: shareQueryKeys.appInfo, queryFn: () => { return fetchAppInfo() }, @@ -24,7 +71,7 @@ export const useGetWebAppInfo = () => { export const useGetWebAppParams = () => { return useQuery({ - queryKey: [NAME_SPACE, 'appParams'], + queryKey: shareQueryKeys.appParams, queryFn: () => { return fetchAppParams(false) }, @@ -33,9 +80,67 @@ export const useGetWebAppParams = () => { export const useGetWebAppMeta = () => { return useQuery({ - queryKey: [NAME_SPACE, 'appMeta'], + queryKey: shareQueryKeys.appMeta, queryFn: () => { return fetchAppMeta(false) }, }) } + +export const useShareConversations = (params: ShareConversationsParams, options: ShareQueryOptions = {}) => { + const { + enabled = true, + refetchOnReconnect, + refetchOnWindowFocus, + } = options + const isEnabled = enabled && (!params.isInstalledApp || !!params.appId) + return useQuery({ + queryKey: shareQueryKeys.conversationList(params), + queryFn: () => fetchConversations( + params.isInstalledApp, + params.appId, + params.lastId, + params.pinned, + params.limit, + ), + enabled: isEnabled, + refetchOnReconnect, + refetchOnWindowFocus, + }) +} + +export const useShareChatList = (params: ShareChatListParams, options: ShareQueryOptions = {}) => { + const { + enabled = true, + refetchOnReconnect, + refetchOnWindowFocus, + } = options + const isEnabled = enabled && (!params.isInstalledApp || !!params.appId) && !!params.conversationId + return useQuery({ + queryKey: shareQueryKeys.chatList(params), + queryFn: () => fetchChatList(params.conversationId, params.isInstalledApp, params.appId), + enabled: isEnabled, + refetchOnReconnect, + refetchOnWindowFocus, + }) +} + +export const useShareConversationName = (params: ShareConversationNameParams, options: ShareQueryOptions = {}) => { + const { + enabled = true, + refetchOnReconnect, + refetchOnWindowFocus, + } = options + const isEnabled = enabled && (!params.isInstalledApp || !!params.appId) && !!params.conversationId + return useQuery({ + queryKey: shareQueryKeys.conversationName(params), + queryFn: () => generationConversationName(params.isInstalledApp, params.appId, params.conversationId), + enabled: isEnabled, + refetchOnReconnect, + refetchOnWindowFocus, + }) +} + +export const useInvalidateShareConversations = () => { + return useInvalid(shareQueryKeys.conversations) +}