mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
fix: enhance model provider popup functionality and loading state handling
- Updated the model provider popup to include loading state for marketplace plugins. - Improved filtering logic for installed models and marketplace providers. - Added tests to ensure correct behavior when no models are found and when query parameters are omitted. - Refactored the handling of model lists to better manage installed and available models.
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import type { Model, ModelItem, ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -31,13 +31,29 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
|
||||
type MockMarketplacePlugin = {
|
||||
plugin_id: string
|
||||
latest_package_identifier: string
|
||||
}
|
||||
|
||||
type MockContextProvider = Pick<ModelProvider, 'provider' | 'custom_configuration' | 'system_configuration'>
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({
|
||||
current: [] as MockMarketplacePlugin[],
|
||||
isLoading: false,
|
||||
}))
|
||||
const mockContextModelProviders = vi.hoisted(() => ({
|
||||
current: [] as MockContextProvider[],
|
||||
}))
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => mockLanguage,
|
||||
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
|
||||
useMarketplaceAllPlugins: () => ({
|
||||
plugins: mockMarketplacePlugins.current,
|
||||
isLoading: mockMarketplacePlugins.isLoading,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@ -46,7 +62,7 @@ vi.mock('./popup-item', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: [] }),
|
||||
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/use-trial-credits', () => ({
|
||||
@ -122,12 +138,25 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeContextProvider = (overrides: Partial<MockContextProvider> = {}): MockContextProvider => ({
|
||||
provider: 'test-openai',
|
||||
custom_configuration: {
|
||||
status: 'no-configure',
|
||||
} as MockContextProvider['custom_configuration'],
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Popup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
mockMarketplacePlugins.current = []
|
||||
mockMarketplacePlugins.isLoading = false
|
||||
mockContextModelProviders.current = []
|
||||
})
|
||||
|
||||
it('should filter models by search and allow clearing search', () => {
|
||||
@ -273,9 +302,11 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should render marketplace providers that are not installed', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel({ provider: 'test-openai' })]}
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
@ -287,6 +318,22 @@ describe('Popup', () => {
|
||||
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide installed marketplace providers when they are absent from the current modelList', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-anthropic' })]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle marketplace section collapse', () => {
|
||||
render(
|
||||
<Popup
|
||||
|
||||
@ -49,6 +49,7 @@ const Popup: FC<PopupProps> = ({
|
||||
const { modelProviders } = useProviderContext()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isMarketplacePluginsLoading,
|
||||
} = useMarketplaceAllPlugins(modelProviders, '')
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
@ -66,7 +67,7 @@ const Popup: FC<PopupProps> = ({
|
||||
}, [modelProviders])
|
||||
|
||||
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
|
||||
if (!allPlugins || installingProvider)
|
||||
if (!allPlugins || isMarketplacePluginsLoading || installingProvider)
|
||||
return
|
||||
const pluginId = providerKeyToPluginId[key]
|
||||
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
|
||||
@ -87,10 +88,30 @@ const Popup: FC<PopupProps> = ({
|
||||
finally {
|
||||
setInstallingProvider(null)
|
||||
}
|
||||
}, [allPlugins, installingProvider, installPackageFromMarketPlace, refreshPluginList])
|
||||
}, [allPlugins, installPackageFromMarketPlace, installingProvider, isMarketplacePluginsLoading, refreshPluginList])
|
||||
|
||||
const installedModelList = useMemo(() => {
|
||||
const modelMap = new Map(modelList.map(model => [model.provider, model]))
|
||||
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
|
||||
const installedProvider = modelProviders.find(provider => provider.provider === providerKey)
|
||||
|
||||
if (!installedProvider)
|
||||
return []
|
||||
|
||||
const matchedModel = modelMap.get(providerKey)
|
||||
return matchedModel ? [matchedModel] : []
|
||||
})
|
||||
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
|
||||
|
||||
return [...installedMarketplaceModels, ...otherModels]
|
||||
}, [modelList, modelProviders])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const filtered = modelList.map((model) => {
|
||||
const filtered = installedModelList.map((model) => {
|
||||
const matchesProviderSearch = !searchText
|
||||
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|
||||
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
|
||||
const filteredModels = model.models
|
||||
.filter((modelItem) => {
|
||||
if (modelItem.label[language] !== undefined)
|
||||
@ -108,8 +129,11 @@ const Popup: FC<PopupProps> = ({
|
||||
return modelItem.features?.includes(feature) ?? false
|
||||
})
|
||||
})
|
||||
if (!matchesProviderSearch || filteredModels.length === 0)
|
||||
return null
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
}).filter(model => model.models.length > 0)
|
||||
}).filter((model): model is Model => model !== null)
|
||||
|
||||
if (defaultModel?.provider) {
|
||||
filtered.sort((a, b) => {
|
||||
@ -120,12 +144,12 @@ const Popup: FC<PopupProps> = ({
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [defaultModel?.provider, language, modelList, scopeFeatures, searchText])
|
||||
}, [defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
|
||||
|
||||
const marketplaceProviders = useMemo(() => {
|
||||
const installedProviders = new Set(modelList.map(m => m.provider))
|
||||
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
|
||||
}, [modelList])
|
||||
}, [modelProviders])
|
||||
|
||||
return (
|
||||
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
|
||||
@ -172,7 +196,7 @@ const Popup: FC<PopupProps> = ({
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !modelList.length && (
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-gradient-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
@ -198,7 +222,7 @@ const Popup: FC<PopupProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!filteredModelList.length && modelList.length > 0 && (
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
@ -237,7 +261,7 @@ const Popup: FC<PopupProps> = ({
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => handleInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
|
||||
@ -234,6 +234,24 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should send an empty body when query is omitted', async () => {
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith({
|
||||
params: {
|
||||
collectionId: 'test-collection',
|
||||
},
|
||||
body: {},
|
||||
}, expect.objectContaining({
|
||||
signal: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
|
||||
@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
body: query,
|
||||
body: query ?? {},
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user