fix: can't debug model plugins (#38500)

This commit is contained in:
非法操作
2026-07-07 13:03:32 +00:00
committed by GitHub
parent 3ddfba5ca5
commit 6edce14e88
8 changed files with 648 additions and 55 deletions
@@ -41,6 +41,7 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('../hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
useLanguage: () => 'en_US',
}))
vi.mock('../provider-added-card', () => ({
@@ -84,9 +85,11 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useInvalidateInstalledPluginList: () => vi.fn(),
useInvalidateCheckInstalled: () => vi.fn(),
usePluginAutoUpgradeSettings: () => ({
data: {
category: 'model',
@@ -1,7 +1,9 @@
import type { ReactNode } from 'react'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@@ -41,10 +43,18 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
},
}))
const { mockProviderContextState } = vi.hoisted(() => ({
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
mockProviderContextState: {
isLoadingModelProviders: false,
},
mockRefreshModelProviders: vi.fn(),
}))
const { mockInstalledModelPlugins, mockUseInstalledPluginList } = vi.hoisted(() => ({
mockInstalledModelPlugins: {
value: [] as PluginDetail[],
},
mockUseInstalledPluginList: vi.fn(),
}))
const mockQuotaConfig = {
@@ -79,6 +89,70 @@ const saveUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
version: '1.0.0',
author: 'langgenius',
icon: 'debug-model.png',
icon_dark: 'debug-model-dark.png',
name: 'debug-model',
category: PluginCategoryEnum.model,
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
created_at: '2024-01-01',
resource: null,
plugins: null,
verified: false,
endpoint: null,
tool: undefined,
datasource: undefined,
model: {},
tags: [],
agent_strategy: null,
meta: {
version: '1.0.0',
},
trigger: {} as unknown as PluginDeclaration['trigger'],
...overrides,
})
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => {
const {
declaration: overrideDeclaration,
plugin_id: overridePluginId,
...restOverrides
} = overrides
const declaration = overrideDeclaration ?? createPluginDeclaration()
const pluginId = overridePluginId ?? 'langgenius/debug-model'
return {
id: 'plugin-installation-id',
created_at: '2024-01-01',
updated_at: '2024-01-01',
name: declaration.name,
plugin_id: pluginId,
plugin_unique_identifier: declaration.plugin_unique_identifier,
declaration,
installation_id: 'plugin-installation-id',
tenant_id: 'tenant-id',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: declaration.plugin_unique_identifier,
source: PluginSource.debugging,
meta: {
repo: '',
version: '1.0.0',
package: '',
},
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...restOverrides,
}
}
const mockProviders = [
{
provider: 'openai',
@@ -106,6 +180,7 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockProviders,
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
refreshModelProviders: mockRefreshModelProviders,
}),
}))
@@ -119,6 +194,7 @@ const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> =
vi.mock('../hooks', () => ({
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
useLanguage: () => 'en_US',
}))
vi.mock('../install-from-marketplace', () => ({
@@ -126,7 +202,24 @@ vi.mock('../install-from-marketplace', () => ({
}))
vi.mock('../provider-added-card', () => ({
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
default: ({
notConfigured,
provider,
pluginDetail,
}: {
notConfigured?: boolean
provider: { provider: string }
pluginDetail?: { plugin_id: string, source?: string }
}) => (
<div
data-testid="provider-card"
data-not-configured={String(!!notConfigured)}
data-plugin-id={pluginDetail?.plugin_id ?? ''}
data-plugin-source={pluginDetail?.source ?? ''}
>
{provider.provider}
</div>
),
}))
vi.mock('../provider-added-card/quota-panel', () => ({
@@ -160,12 +253,12 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useCheckInstalled: () => ({
data: { plugins: [] },
}),
useInstalledPluginList: (...args: unknown[]) => {
mockUseInstalledPluginList(...args)
return {
data: { plugins: mockInstalledModelPlugins.value },
}
},
usePluginAutoUpgradeSettings: () => ({
data: mockReferenceSetting.auto_upgrade
? {
@@ -280,6 +373,9 @@ describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseInstalledPluginList.mockClear()
mockRefreshModelProviders.mockClear()
mockInstalledModelPlugins.value = []
mockProviderContextState.isLoadingModelProviders = false
mockAutoUpgradeError.value = undefined
mockReferenceSetting.auto_upgrade = {
@@ -418,6 +514,107 @@ describe('ModelProviderPage', () => {
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { category: PluginCategoryEnum.model })
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
})
it('should not render installed model plugins that are not registered as model providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
}),
}),
]
renderModelProviderPage()
expect(screen.queryByText('Debug Model')).not.toBeInTheDocument()
expect(screen.queryByText('langgenius/debug-model')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' })).not.toBeInTheDocument()
})
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:debug',
name: 'openai',
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.debugging,
}),
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.marketplace,
}),
]
renderModelProviderPage()
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', PluginSource.debugging)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should show provider placeholders while model providers are loading', () => {
mockProviderContextState.isLoadingModelProviders = true
@@ -572,4 +769,66 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should prioritize debugging model plugins within their provider section', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI Fixed' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'zeta-provider',
label: { en_US: 'Zeta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/normal-model/normal-model',
label: { en_US: 'Normal Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/debug-model/debug-model',
label: { en_US: 'Debug Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
name: 'debug-model',
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual([
'langgenius/openai/openai',
'zeta-provider',
'langgenius/debug-model/debug-model',
'langgenius/normal-model/normal-model',
])
expect(screen.getAllByTestId('provider-card')[2]).toHaveAttribute('data-not-configured', 'true')
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
})
@@ -6,15 +6,15 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCheckInstalled } from '@/service/use-plugins'
import { useInstalledPluginList } from '@/service/use-plugins'
import UpdateSettingDialog from '../update-setting-dialog'
import {
CustomConfigurationStatusEnum,
@@ -25,7 +25,6 @@ import {
} from './hooks'
import ModelProviderPageBody from './model-provider-page-body'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
@@ -58,23 +57,43 @@ const ModelProviderPage = ({
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers, isLoadingModelProviders } = useProviderContext()
const { modelProviders: providers, isLoadingModelProviders, refreshModelProviders } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
enabled: allPluginIds.length > 0,
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
category: PluginCategoryEnum.model,
})
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
for (const plugin of enrichedPlugins)
map.set(plugin.plugin_id, plugin)
for (const plugin of enrichedPlugins) {
const existingPlugin = map.get(plugin.plugin_id)
if (!existingPlugin || plugin.source === PluginSource.debugging)
map.set(plugin.plugin_id, plugin)
}
return map
}, [enrichedPlugins])
const debuggingModelPluginKey = useMemo(() => {
const debuggingModelPluginIds = enrichedPlugins
.filter(plugin => plugin.source === PluginSource.debugging)
.map(plugin => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
.sort()
return debuggingModelPluginIds.join(',')
}, [enrichedPlugins])
const refreshedDebuggingModelPluginKeyRef = useRef('')
useEffect(() => {
if (!debuggingModelPluginKey) {
refreshedDebuggingModelPluginKeyRef.current = ''
return
}
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey)
return
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
refreshModelProviders?.()
}, [debuggingModelPluginKey, refreshModelProviders])
const enableMarketplace = systemFeatures.enable_marketplace
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
@@ -3,6 +3,7 @@ import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { PluginSource } from '@/app/components/plugins/types'
import { IS_CLOUD_EDITION } from '@/config'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
@@ -99,21 +100,40 @@ type ProviderCardListProps = {
notConfigured?: boolean
}
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
return pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
}
function ProviderCardList({
providers,
pluginDetailMap,
notConfigured,
}: ProviderCardListProps) {
const sortedProviders = [...providers]
.sort((a, b) => {
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
if (aIsDebuggingPlugin === bIsDebuggingPlugin)
return 0
return aIsDebuggingPlugin ? -1 : 1
})
return (
<div className="relative flex flex-col gap-2">
{providers.map(provider => (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
{sortedProviders.map((provider) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
return (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
)
})}
</div>
)
}
@@ -157,8 +177,8 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<div className="flex flex-col gap-2 pt-2">
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<ProviderCardList
notConfigured
providers={filteredNotConfiguredProviders}
notConfigured
pluginDetailMap={pluginDetailMap}
/>
</div>
@@ -158,6 +158,15 @@ describe('ProviderCardActions', () => {
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
})
it('should show a compact debug badge after the version for debugging plugins', () => {
render(<ProviderCardActions detail={createDetail({ source: PluginSource.debugging })} />)
const version = screen.getByText('1.0.0')
const debugBadge = screen.getByText('appDebug.operation.debugConfig')
expect(version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('should trigger the latest marketplace update when clicking the update button', () => {
render(<ProviderCardActions detail={createDetail()} />)
@@ -30,6 +30,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const isDebuggingPlugin = source === PluginSource.debugging
const {
modalStates,
@@ -80,31 +81,41 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
return (
<>
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<>
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<Badge
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
{isDebuggingPlugin && (
<Badge
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
className="border-state-warning-active bg-state-warning-hover text-text-warning"
size="xs"
uppercase={false}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
text={t('operation.debugConfig', { ns: 'appDebug' })}
/>
)}
/>
</>
)}
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (