mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
refactor(web): drop swr and migrate share/chat hooks to tanstack query (#30232)
Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
@ -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
|
||||
? (
|
||||
<SWRConfig value={{
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
focusThrottleInterval: 5000,
|
||||
provider: () => new Map(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
)
|
||||
: null
|
||||
return init ? children : null
|
||||
}
|
||||
|
||||
export default SwrInitializer
|
||||
270
web/app/components/base/chat/chat-with-history/hooks.spec.tsx
Normal file
270
web/app/components/base/chat/chat-with-history/hooks.spec.tsx
Normal file
@ -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<typeof import('../utils')>('../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 }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithClient = <T,>(hook: () => T) => {
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper(queryClient)
|
||||
return {
|
||||
queryClient,
|
||||
...renderHook(hook, { wrapper }),
|
||||
}
|
||||
}
|
||||
|
||||
const createConversationItem = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
|
||||
id: 'conversation-1',
|
||||
name: 'Conversation 1',
|
||||
inputs: null,
|
||||
introduction: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<ConversationItem[]>([])
|
||||
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)
|
||||
|
||||
257
web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
Normal file
257
web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
Normal file
@ -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<typeof import('../utils')>('../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 }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithClient = <T,>(hook: () => T) => {
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper(queryClient)
|
||||
return {
|
||||
queryClient,
|
||||
...renderHook(hook, { wrapper }),
|
||||
}
|
||||
}
|
||||
|
||||
const createConversationItem = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
|
||||
id: 'conversation-1',
|
||||
name: 'Conversation 1',
|
||||
inputs: null,
|
||||
introduction: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<ConversationItem[]>([])
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user