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:
CodingOnStar
2026-03-11 15:29:47 +08:00
parent f18fd566ba
commit c2def7a840
4 changed files with 105 additions and 16 deletions

View File

@ -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

View File

@ -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" />}

View File

@ -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({

View File

@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
params: {
collectionId,
},
body: query,
body: query ?? {},
}, {
signal: options?.signal,
})