mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 17:07:03 +08:00
fix(plugins): refresh model defaults after plugin changes
This commit is contained in:
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user