mirror of https://github.com/langgenius/dify.git
Merge remote-tracking branch 'origin/main' into 12-24-json-for-translation
This commit is contained in:
commit
3f8085766e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitializer>
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
|
|
@ -38,7 +38,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
<Zendesk />
|
||||
</SwrInitializer>
|
||||
</AppInitializer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitor>
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
|
|
@ -30,7 +30,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</SwrInitor>
|
||||
</AppInitializer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@
|
|||
"sharp": "^0.33.5",
|
||||
"sortablejs": "^1.15.6",
|
||||
"string-ts": "^2.3.1",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tldts": "^7.0.17",
|
||||
"use-context-selector": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -333,9 +333,6 @@ importers:
|
|||
string-ts:
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
swr:
|
||||
specifier: ^2.3.6
|
||||
version: 2.3.7(react@19.2.3)
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
|
|
@ -8127,11 +8124,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==}
|
||||
|
||||
|
|
@ -17753,12 +17745,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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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-<feature>.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) {
|
||||
|
|
|
|||
|
|
@ -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<AnnotationCreateResponse>(`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}`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promis
|
|||
return get<AppDetailResponse>(`${url}/${id}`)
|
||||
}
|
||||
|
||||
// Direct API call function for non-SWR usage
|
||||
export const fetchAppDetailDirect = async ({ url, id }: { url: string, id: string }): Promise<AppDetailResponse> => {
|
||||
return get<AppDetailResponse>(`${url}/${id}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MarketplaceCollectionsResponse, { url: string }> = ({ url }) => {
|
||||
export const fetchMarketplaceCollections = ({ url }: { url: string }): Promise<MarketplaceCollectionsResponse> => {
|
||||
return get<MarketplaceCollectionsResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchMarketplaceCollectionPlugins: Fetcher<MarketplaceCollectionPluginsResponse, { url: string }> = ({ url }) => {
|
||||
export const fetchMarketplaceCollectionPlugins = ({ url }: { url: string }): Promise<MarketplaceCollectionPluginsResponse> => {
|
||||
return get<MarketplaceCollectionPluginsResponse>(url)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderShareHook = <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: 'Intro',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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<AppConversationData>({
|
||||
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<ConversationItem>({
|
||||
queryKey: shareQueryKeys.conversationName(params),
|
||||
queryFn: () => generationConversationName(params.isInstalledApp, params.appId, params.conversationId),
|
||||
enabled: isEnabled,
|
||||
refetchOnReconnect,
|
||||
refetchOnWindowFocus,
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidateShareConversations = () => {
|
||||
return useInvalid(shareQueryKeys.conversations)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue