fix(plugins): refresh model defaults after plugin changes

This commit is contained in:
Jingyi-Dify
2026-06-02 15:54:52 -07:00
parent 4581ce2f45
commit 3a2ea826ff
8 changed files with 112 additions and 53 deletions

View File

@ -7,6 +7,9 @@ const mockInvalidateInstalledPluginList = vi.fn()
const mockRefetchLLMModelList = vi.fn()
const mockRefetchEmbeddingModelList = vi.fn()
const mockRefetchRerankModelList = vi.fn()
const mockRefetchSpeech2textModelList = vi.fn()
const mockRefetchTTSModelList = vi.fn()
const mockInvalidateDefaultModel = vi.fn()
const mockRefreshModelProviders = vi.fn()
const mockInvalidateAllToolProviders = vi.fn()
const mockInvalidateAllBuiltInTools = vi.fn()
@ -21,18 +24,21 @@ vi.mock('@/service/use-plugins', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
ModelTypeEnum: { textGeneration: 'text-generation', textEmbedding: 'text-embedding', rerank: 'rerank' },
ModelTypeEnum: { textGeneration: 'llm', textEmbedding: 'text-embedding', rerank: 'rerank', speech2text: 'speech2text', tts: 'tts' },
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: (type: string) => {
const map: Record<string, { mutate: ReturnType<typeof vi.fn> }> = {
'text-generation': { mutate: mockRefetchLLMModelList },
'llm': { mutate: mockRefetchLLMModelList },
'text-embedding': { mutate: mockRefetchEmbeddingModelList },
'rerank': { mutate: mockRefetchRerankModelList },
'speech2text': { mutate: mockRefetchSpeech2textModelList },
'tts': { mutate: mockRefetchTTSModelList },
}
return map[type] ?? { mutate: vi.fn() }
},
useInvalidateDefaultModel: () => mockInvalidateDefaultModel,
}))
vi.mock('@/context/provider-context', () => ({
@ -95,6 +101,14 @@ describe('useRefreshPluginList', () => {
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchSpeech2textModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchTTSModelList).toHaveBeenCalledTimes(1)
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
expect(mockInvalidateDefaultModel).toHaveBeenCalledWith('llm')
expect(mockInvalidateDefaultModel).toHaveBeenCalledWith('text-embedding')
expect(mockInvalidateDefaultModel).toHaveBeenCalledWith('rerank')
expect(mockInvalidateDefaultModel).toHaveBeenCalledWith('speech2text')
expect(mockInvalidateDefaultModel).toHaveBeenCalledWith('tts')
})
it('should refresh datasource lists for datasource category manifest', () => {
@ -138,6 +152,9 @@ describe('useRefreshPluginList', () => {
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchSpeech2textModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchTTSModelList).toHaveBeenCalledTimes(1)
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1)
})

View File

@ -1,6 +1,6 @@
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useInvalidateDefaultModel, useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useProviderContext } from '@/context/provider-context'
import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
@ -14,11 +14,22 @@ type PluginCategoryPayload = {
category: PluginCategoryEnum | string
}
const SYSTEM_MODEL_TYPES = [
ModelTypeEnum.textGeneration,
ModelTypeEnum.textEmbedding,
ModelTypeEnum.rerank,
ModelTypeEnum.speech2text,
ModelTypeEnum.tts,
]
const useRefreshPluginList = () => {
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration)
const { mutate: refetchEmbeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { mutate: refetchRerankModelList } = useModelList(ModelTypeEnum.rerank)
const { mutate: refetchSpeech2textModelList } = useModelList(ModelTypeEnum.speech2text)
const { mutate: refetchTTSModelList } = useModelList(ModelTypeEnum.tts)
const invalidateDefaultModel = useInvalidateDefaultModel()
const { refreshModelProviders } = useProviderContext()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
@ -59,6 +70,9 @@ const useRefreshPluginList = () => {
refetchLLMModelList()
refetchEmbeddingModelList()
refetchRerankModelList()
refetchSpeech2textModelList()
refetchTTSModelList()
SYSTEM_MODEL_TYPES.forEach(type => invalidateDefaultModel(type))
}
// agent select

View File

@ -20,15 +20,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => (
<button data-testid="dropdown-trigger" className={className}>{children}</button>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div data-testid="dropdown-content">{children}</div>
DropdownMenuContent: ({ children, popupClassName }: { children: ReactNode, popupClassName?: string }) => (
<div data-testid="dropdown-content" className={popupClassName}>{children}</div>
),
DropdownMenuItem: ({ children, onClick, render, destructive }: { children: ReactNode, onClick?: () => void, render?: ReactElement, destructive?: boolean }) => {
DropdownMenuItem: ({ children, className, onClick, render, variant }: { children: ReactNode, className?: string, onClick?: () => void, render?: ReactElement, variant?: string }) => {
if (render)
return cloneElement(render, { onClick, 'data-destructive': destructive } as Record<string, unknown>, children)
return <div data-testid="dropdown-item" data-destructive={destructive} onClick={onClick}>{children}</div>
return cloneElement(render, { className, onClick, 'data-variant': variant } as Record<string, unknown>, children)
return <div data-testid="dropdown-item" className={className} data-variant={variant} onClick={onClick}>{children}</div>
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
DropdownMenuSeparator: ({ className }: { className?: string }) => <hr data-testid="dropdown-separator" className={className} />,
}))
describe('OperationDropdown', () => {
@ -60,6 +60,15 @@ describe('OperationDropdown', () => {
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
})
it('should render figma-aligned dropdown surface and rows', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('dropdown-content')).toHaveClass('w-[192px]', 'py-1')
expect(screen.getByText('plugin.detailPanel.operation.viewDetail').closest('a')).toHaveClass('px-2', 'py-1', 'system-md-regular')
expect(screen.getByText('plugin.detailPanel.operation.remove').closest('[data-testid="dropdown-item"]')).toHaveClass('px-2', 'py-1', 'system-md-regular')
expect(screen.getByTestId('dropdown-separator')).toHaveClass('my-0')
})
it('should render info option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
@ -90,6 +99,12 @@ describe('OperationDropdown', () => {
expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument()
})
it('should render remove option with normal text color', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByText('plugin.detailPanel.operation.remove').closest('[data-testid="dropdown-item"]')).not.toHaveAttribute('data-variant', 'destructive')
})
it('should not render info option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)

View File

@ -13,9 +13,8 @@ type VersionPickerMock = {
const {
mockSetShowUpdatePluginModal,
mockRefreshModelProviders,
mockInvalidateCheckInstalled,
mockInvalidateAllToolProviders,
mockRefreshPluginList,
mockUninstallPlugin,
mockFetchReleases,
mockCheckForUpdates,
@ -23,9 +22,8 @@ const {
} = vi.hoisted(() => {
return {
mockSetShowUpdatePluginModal: vi.fn(),
mockRefreshModelProviders: vi.fn(),
mockInvalidateCheckInstalled: vi.fn(),
mockInvalidateAllToolProviders: vi.fn(),
mockRefreshPluginList: vi.fn(),
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
@ -54,9 +52,9 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
refreshModelProviders: mockRefreshModelProviders,
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
@ -68,10 +66,6 @@ vi.mock('@/service/use-plugins', () => ({
useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled,
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../install-plugin/hooks')>()
return {
@ -461,7 +455,7 @@ describe('usePluginOperations', () => {
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
})
it('should refresh model providers when deleting model plugin', async () => {
it('should refresh plugin list when deleting model plugin', async () => {
const detail = createPluginDetail({
declaration: {
author: 'test-author',
@ -487,10 +481,10 @@ describe('usePluginOperations', () => {
await result.current.handleDelete()
})
expect(mockRefreshModelProviders).toHaveBeenCalled()
expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: 'model' })
})
it('should invalidate tool providers when deleting tool plugin', async () => {
it('should refresh plugin list when deleting tool plugin', async () => {
const detail = createPluginDetail({
declaration: {
author: 'test-author',
@ -516,7 +510,7 @@ describe('usePluginOperations', () => {
await result.current.handleDelete()
})
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: 'tool' })
})
it('should track plugin uninstalled event', async () => {

View File

@ -6,13 +6,12 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { checkForUpdates, fetchReleases } from '../../../install-plugin/hooks'
import { PluginCategoryEnum, PluginSource } from '../../../types'
import { PluginSource } from '../../../types'
type UsePluginOperationsParams = {
detail: PluginDetail
@ -40,9 +39,8 @@ export const usePluginOperations = ({
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
const { t } = useTranslation()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const { refreshPluginList } = useRefreshPluginList()
const invalidateCheckInstalled = useInvalidateCheckInstalled()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const { id, meta, plugin_id } = detail
const { author, category, name } = detail.declaration || detail
@ -120,12 +118,7 @@ export const usePluginOperations = ({
modalStates.hideDeleteConfirm()
toast.success(t('action.deleteSuccess', { ns: 'plugin' }))
handlePluginUpdated(true)
if (PluginCategoryEnum.model.includes(category))
refreshModelProviders()
if (PluginCategoryEnum.tool.includes(category))
invalidateAllToolProviders()
refreshPluginList({ category })
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
}
@ -136,8 +129,7 @@ export const usePluginOperations = ({
name,
modalStates,
handlePluginUpdated,
refreshModelProviders,
invalidateAllToolProviders,
refreshPluginList,
])
return {

View File

@ -28,6 +28,10 @@ type Props = {
triggerSize?: 'm' | 'xs'
}
const operationMenuPopupClassName = 'w-[192px] py-1'
const operationMenuItemClassName = 'px-2 py-1 text-text-secondary system-md-regular'
const operationMenuLabelClassName = 'min-w-0 grow truncate px-1 py-0.5'
const OperationDropdown: FC<Props> = ({
source,
detailUrl,
@ -57,29 +61,29 @@ const OperationDropdown: FC<Props> = ({
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName={cn('w-auto min-w-[160px]', popupClassName)}
popupClassName={cn(operationMenuPopupClassName, popupClassName)}
>
{source === PluginSource.github && (
<DropdownMenuItem onClick={onInfo}>
{t('detailPanel.operation.info', { ns: 'plugin' })}
<DropdownMenuItem className={operationMenuItemClassName} onClick={onInfo}>
<span className={operationMenuLabelClassName}>{t('detailPanel.operation.info', { ns: 'plugin' })}</span>
</DropdownMenuItem>
)}
{source === PluginSource.github && (
<DropdownMenuItem onClick={onCheckVersion}>
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
<DropdownMenuItem className={operationMenuItemClassName} onClick={onCheckVersion}>
<span className={operationMenuLabelClassName}>{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}</span>
</DropdownMenuItem>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<DropdownMenuItem render={<a href={detailUrl} target="_blank" rel="noopener noreferrer" />}>
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
<DropdownMenuItem className={operationMenuItemClassName} render={<a href={detailUrl} target="_blank" rel="noopener noreferrer" />}>
<span className={operationMenuLabelClassName}>{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
<span className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-tertiary" />
</DropdownMenuItem>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<DropdownMenuSeparator />
<DropdownMenuSeparator className="my-0" />
)}
<DropdownMenuItem variant="destructive" onClick={onRemove}>
{t('detailPanel.operation.remove', { ns: 'plugin' })}
<DropdownMenuItem className={operationMenuItemClassName} onClick={onRemove}>
<span className={operationMenuLabelClassName}>{t('detailPanel.operation.remove', { ns: 'plugin' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -61,6 +61,29 @@ describe('PluginVersionPicker', () => {
expect(screen.getByText('CURRENT')).toBeInTheDocument()
})
it('renders figma-aligned version rows', () => {
render(
<PluginVersionPicker
isShow
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
onSelect={vi.fn()}
/>,
)
const currentVersion = screen.getByText('2.0.0')
const currentBadge = screen.getByText('CURRENT')
const oldVersion = screen.getByText('1.0.0')
expect(screen.getByText('plugin.detailPanel.switchVersion')).toHaveClass('px-3', 'pb-0.5', 'pt-1')
expect(currentVersion.closest('.cursor-default')).toHaveClass('px-2', 'py-1', 'opacity-30')
expect(oldVersion.closest('.cursor-pointer')).toHaveClass('px-2', 'py-1')
expect(currentVersion.parentElement).toHaveClass('min-h-5', 'gap-1', 'px-1')
expect(currentBadge).toHaveClass('bg-components-badge-bg-dimm')
})
it('calls onSelect with downgrade metadata and closes the picker', () => {
const onSelect = vi.fn()
const onShowChange = vi.fn()

View File

@ -84,7 +84,7 @@ const PluginVersionPicker: FC<Props> = ({
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="relative w-[209px] bg-components-panel-bg-blur p-1 backdrop-blur-xs"
popupClassName="relative w-[209px] bg-components-panel-bg-blur p-1 backdrop-blur-[5px]"
>
<div className="px-3 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary">
{t('detailPanel.switchVersion', { ns: 'plugin' })}
@ -94,7 +94,7 @@ const PluginVersionPicker: FC<Props> = ({
<div
key={version.unique_identifier}
className={cn(
'flex h-7 cursor-pointer items-center gap-1 rounded-lg px-3 py-1 hover:bg-state-base-hover',
'flex cursor-pointer items-center rounded-lg px-2 py-1 hover:bg-state-base-hover',
currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent',
)}
onClick={() => handleSelect({
@ -103,11 +103,11 @@ const PluginVersionPicker: FC<Props> = ({
isDowngrade: isEarlierThanVersion(version.version, currentVersion),
})}
>
<div className="flex grow items-center">
<div className="system-sm-medium text-text-secondary">{version.version}</div>
{currentVersion === version.version && <Badge className="ml-1" text="CURRENT" />}
<div className="flex min-h-5 min-w-0 grow items-center gap-1 px-1">
<div className="min-w-0 grow truncate system-sm-medium text-text-secondary">{version.version}</div>
{currentVersion === version.version && <Badge className="shrink-0" variant="dimm" text="CURRENT" />}
<div className="shrink-0 system-xs-regular text-text-tertiary">{formatDate(version.created_at, format!)}</div>
</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{formatDate(version.created_at, format!)}</div>
</div>
))}
</div>