refactor(web): retire provider context (#41944)

This commit is contained in:
yyh 2026-09-08 04:16:45 +00:00 committed by GitHub
parent 7ba2cfb30b
commit d978fe710f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 562 additions and 476 deletions

View File

@ -1,23 +0,0 @@
import type { ProviderContextState } from '@/context/provider-context'
import { merge } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
// Avoid being mocked in tests
export const baseProviderContextValue: ProviderContextState = {
modelProviders: [],
modelProviderPlugins: {},
refreshModelProviders: async () => {},
isLoadingModelProviders: false,
isSuccessModelProviders: false,
}
export const createMockProviderContextValue = (
overrides: Partial<ProviderContextState> = {},
): ProviderContextState => {
const merged = merge({}, baseProviderContextValue, overrides)
return {
...merged,
refreshModelProviders: merged.refreshModelProviders ?? noop,
}
}

View File

@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import { ExternalServiceSync } from './external-service-sync'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
@ -21,9 +20,7 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod
export function ConsoleContextProviders({ children }: { children: ReactNode }) {
return (
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>{children}</ModalContextProvider>
</ProviderContextProvider>
<ModalContextProvider>{children}</ModalContextProvider>
</EventEmitterContextProvider>
)
}

View File

@ -28,15 +28,6 @@ vi.mock('@/service/annotation', () => ({
clearAllAnnotations: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
usage: { annotatedResponse: 0 },
total: { annotatedResponse: 10 },
},
}),
}))
vi.mock('@/app/components/billing/annotation-full', () => ({
default: () => <div data-testid="annotation-full" />,
}))

View File

@ -93,12 +93,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs${path}`,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModel: (...args: unknown[]) => mockModelListQueryAndDefaultModel(...args),
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>

View File

@ -32,12 +32,6 @@ vi.mock('ky', () => {
return { __esModule: true, default: ky }
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockModelListQueryAndDefaultModelAndCurrentProviderAndModel(...args),

View File

@ -7,7 +7,6 @@ import type {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -162,15 +161,15 @@ describe('ModelParameterTrigger', () => {
onDebugWithMultipleModelChange: vi.fn(),
})
mockProvidersSummary.mockReturnValue(
createMockProviderContextValue({
modelProviders: [
{
data: [
{
...createModelProvider(),
is_configured: true,
plugin_id: 'langgenius/openai',
} as unknown as ModelProviderSummaryResponse,
],
}).modelProviders,
}.data,
)
mockUseCredentialPanelState.mockReturnValue({
variant: 'api-active',

View File

@ -3,16 +3,27 @@ import type { UpdateWorkflowNodesMapPayload } from '../index'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { act, render, screen } from '@testing-library/react'
import { act, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useReactFlow, useStoreApi } from 'reactflow'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { useSelectOrDelete } from '../../../hooks'
import WorkflowVariableBlockComponent from '../component'
import { UPDATE_WORKFLOW_NODES_MAP } from '../index'
import { WorkflowVariableBlockNode } from '../node'
const render: typeof renderWithConsoleQuery = (ui, options) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
data: [],
plugins: {},
})
return renderWithConsoleQuery(ui, { ...options, queryClient })
}
const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({
mockVarLabel: vi.fn(),
mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false),

View File

@ -1,15 +1,28 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data'
import { useLlmModelPluginInstalled } from '../use-llm-model-plugin-installed'
let mockModelProviders: Array<{ provider: string }> = []
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: <T>(
selector: (state: { modelProviders: Array<{ provider: string }> }) => T,
): T => selector({ modelProviders: mockModelProviders }),
}))
let mockModelProviders: Array<{ provider: string }> = []
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
({
@ -20,6 +33,18 @@ const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap
},
}) as unknown as WorkflowNodesMap
const renderHook: typeof renderHookWithConsoleQuery = (callback, options) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
data: mockModelProviders.map(
(provider) =>
({ ...providerSummaryFixture, ...provider }) satisfies ModelProviderSummaryResponse,
),
plugins: {},
})
return renderHookWithConsoleQuery(callback, { ...options, queryClient })
}
describe('useLlmModelPluginInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -1,7 +1,8 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { useQuery } from '@tanstack/react-query'
import { BlockEnum } from '@/app/components/workflow/types'
import { extractPluginId } from '@/app/components/workflow/utils/plugin'
import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/console'
export function useLlmModelPluginInstalled(
nodeId: string,
@ -11,8 +12,13 @@ export function useLlmModelPluginInstalled(
const modelProvider = node?.type === BlockEnum.LLM ? node.modelProvider : undefined
const modelPluginId = modelProvider ? extractPluginId(modelProvider) : undefined
return useProviderContextSelector((state) => {
if (!modelPluginId) return true
return state.modelProviders.some((p) => extractPluginId(p.provider) === modelPluginId)
})
const { data: isInstalled = !modelPluginId } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) =>
!modelPluginId ||
response.data.some((provider) => extractPluginId(provider.provider) === modelPluginId),
}),
)
return isInstalled
}

View File

@ -204,22 +204,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
useModelModalHandler: () => vi.fn(),
}))
// Mock provider-context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
embeddingsModelList: [],
rerankModelList: [],
agentThoughtModelList: [],
modelProviders: [],
textEmbeddingModelList: [],
speech2textModelList: [],
ttsModelList: [],
moderationModelList: [],
hasSettedApiKey: true,
plan: { type: 'free' },
}),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))

View File

@ -75,7 +75,6 @@ vi.mock('@/service/use-common', async (importOriginal) => {
return {
...actual,
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}
})

View File

@ -1,20 +1,25 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
QuotaUnitEnum,
} from '../declarations'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import ModelProviderPage from '../index'
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
quota_limit: 100,
quota_used: 1,
last_used: 0,
is_valid: true,
}
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
@ -23,23 +28,6 @@ vi.mock('@/config', async (importOriginal) => {
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [
{
provider: 'openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
},
],
}),
}))
vi.mock('../hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
useLanguage: () => 'en_US',
@ -128,6 +116,18 @@ vi.mock('@/service/console', async (importOriginal) => {
...originalWorkspaces,
current: {
...originalWorkspaces.current,
modelProviders: {
summary: {
get: {
queryKey: () =>
originalWorkspaces.current.modelProviders.summary.get.queryKey(),
queryOptions: () => ({
...originalWorkspaces.current.modelProviders.summary.get.queryOptions(),
queryFn: () => new Promise(() => {}),
}),
},
},
},
plugin: {
...originalWorkspaces.current.plugin,
list: {
@ -176,9 +176,18 @@ vi.mock('@/service/console', async (importOriginal) => {
}
})
const renderPage: typeof renderWithConsoleQuery = (ui, options) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
data: [{ ...providerSummaryFixture } satisfies ModelProviderSummaryResponse],
plugins: {},
})
return renderWithConsoleQuery(ui, { ...options, queryClient })
}
describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => {
renderWithConsoleQuery(<ModelProviderPage searchText="" />, {
renderPage(<ModelProviderPage searchText="" />, {
systemFeatures: { enable_marketplace: false },
})

View File

@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
@ -6,7 +7,8 @@ import {
getStepByStepTourTargetSelector,
STEP_BY_STEP_TOUR_TARGETS,
} from '@/app/components/step-by-step-tour/target-registry'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@ -15,6 +17,23 @@ import {
} from '../declarations'
import ModelProviderPage from '../index'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
type MockReferenceSetting = {
permission: Record<string, never>
auto_upgrade?: {
@ -52,11 +71,10 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
},
}))
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
mockProviderContextState: {
isLoadingModelProviders: false,
isSuccessModelProviders: true,
modelProviderPlugins: {} as Record<
const { mockSummaryState } = vi.hoisted(() => ({
mockSummaryState: {
isLoading: false,
plugins: {} as Record<
string,
{
installation_id: string
@ -68,7 +86,6 @@ const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() =>
}
>,
},
mockRefreshModelProviders: vi.fn(),
}))
const { mockInstalledModelPlugins, mockUseInstalledPluginList } = vi.hoisted(() => ({
@ -87,22 +104,6 @@ const mockQuotaConfig = {
is_valid: true,
}
const renderModelProviderPage = (
props: {
enableMarketplace?: boolean
searchText?: string
stickyToolbar?: boolean
} = {},
) => {
const { searchText = '', enableMarketplace = true, stickyToolbar = true } = props
return renderWithConsoleQuery(
<ModelProviderPage searchText={searchText} stickyToolbar={stickyToolbar} />,
{
systemFeatures: { enable_marketplace: enableMarketplace },
},
)
}
const saveUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
@ -214,20 +215,52 @@ const mockProviders: MockProvider[] = [
},
]
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockProviders.map((provider) => ({
...provider,
is_configured:
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
provider.system_configuration.enabled,
})),
modelProviderPlugins: mockProviderContextState.modelProviderPlugins,
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
isSuccessModelProviders: mockProviderContextState.isSuccessModelProviders,
refreshModelProviders: mockRefreshModelProviders,
}),
}))
const renderModelProviderPage = (
props: {
enableMarketplace?: boolean
searchText?: string
stickyToolbar?: boolean
} = {},
) => {
const { searchText = '', enableMarketplace = true, stickyToolbar = true } = props
const queryClient = createConsoleQueryClient()
if (mockSummaryState.isLoading) {
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(),
{ queryFn: () => new Promise(() => {}) },
)
} else {
queryClient.setQueryData(
consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(),
{
data: mockProviders.map(
(provider) =>
({
...providerSummaryFixture,
...{
...provider,
is_configured:
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
provider.system_configuration.enabled,
},
custom_configuration: {
...providerSummaryFixture.custom_configuration,
...provider.custom_configuration,
},
}) satisfies ModelProviderSummaryResponse,
),
plugins: mockSummaryState.plugins,
},
)
}
return renderWithConsoleQuery(
<ModelProviderPage searchText={searchText} stickyToolbar={stickyToolbar} />,
{
queryClient,
systemFeatures: { enable_marketplace: enableMarketplace },
},
)
}
const mockDefaultModels: Record<string, { data: unknown; isLoading: boolean }> = {
llm: { data: null, isLoading: false },
@ -341,6 +374,18 @@ vi.mock('@/service/console', async (importOriginal) => {
...originalWorkspaces,
current: {
...originalWorkspaces.current,
modelProviders: {
summary: {
get: {
queryKey: () =>
originalWorkspaces.current.modelProviders.summary.get.queryKey(),
queryOptions: () => ({
...originalWorkspaces.current.modelProviders.summary.get.queryOptions(),
queryFn: () => new Promise(() => {}),
}),
},
},
},
plugin: {
...originalWorkspaces.current.plugin,
list: {
@ -394,11 +439,9 @@ describe('ModelProviderPage', () => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseInstalledPluginList.mockClear()
mockRefreshModelProviders.mockClear()
mockInstalledModelPlugins.value = []
mockProviderContextState.isLoadingModelProviders = false
mockProviderContextState.isSuccessModelProviders = true
mockProviderContextState.modelProviderPlugins = {}
mockSummaryState.isLoading = false
mockSummaryState.plugins = {}
mockPluginSettingsAccess.canSetPluginPreferences = true
mockAutoUpgradeError.value = undefined
mockReferenceSetting.auto_upgrade = {
@ -608,7 +651,7 @@ describe('ModelProviderPage', () => {
quota_configurations: [mockQuotaConfig],
},
})
mockProviderContextState.modelProviderPlugins = {
mockSummaryState.plugins = {
'langgenius/openai-marketplace': {
installation_id: 'openai-installation',
plugin_id: 'langgenius/openai-marketplace',
@ -652,7 +695,7 @@ describe('ModelProviderPage', () => {
})
it('should not refresh providers when remote plugin metadata already comes from summary', () => {
mockProviderContextState.modelProviderPlugins = {
mockSummaryState.plugins = {
'langgenius/debug-model': {
installation_id: 'debug-installation',
plugin_id: 'langgenius/debug-model',
@ -664,8 +707,6 @@ describe('ModelProviderPage', () => {
}
renderModelProviderPage()
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
})
it('should render remote source from the authoritative summary plugin entry', () => {
@ -680,7 +721,7 @@ describe('ModelProviderPage', () => {
quota_configurations: [mockQuotaConfig],
},
})
mockProviderContextState.modelProviderPlugins = {
mockSummaryState.plugins = {
'langgenius/openai': {
installation_id: 'openai-debug-installation',
plugin_id: 'langgenius/openai',
@ -698,12 +739,10 @@ describe('ModelProviderPage', () => {
'langgenius/openai',
)
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', 'remote')
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
})
it('should show provider placeholders while model providers are loading', () => {
mockProviderContextState.isLoadingModelProviders = true
mockProviderContextState.isSuccessModelProviders = false
mockSummaryState.isLoading = true
renderModelProviderPage()
@ -923,7 +962,7 @@ describe('ModelProviderPage', () => {
},
},
)
mockProviderContextState.modelProviderPlugins = {
mockSummaryState.plugins = {
'langgenius/debug-model': {
installation_id: 'debug-installation',
plugin_id: 'langgenius/debug-model',

View File

@ -1,5 +1,6 @@
import type {
ModelProviderPluginSummaryResponse,
ModelProviderSummaryListResponse,
ModelProviderSummaryResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
@ -11,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/console'
import UpdateSettingDialog from '../update-setting-dialog'
@ -20,6 +20,9 @@ import { useDefaultModel } from './hooks'
import ModelProviderPageBody from './model-provider-page-body'
import SystemModelSelector from './system-model-selector'
const EMPTY_MODEL_PROVIDERS: ModelProviderSummaryListResponse['data'] = []
const EMPTY_MODEL_PROVIDER_PLUGINS: ModelProviderSummaryListResponse['plugins'] = {}
type SystemModelConfigStatus =
| 'no-provider'
| 'none-configured'
@ -68,11 +71,11 @@ const ModelProviderPage = ({
ModelTypeEnum.tts,
defaultModelQueryOptions,
)
const {
modelProviders: providers,
modelProviderPlugins = {},
isLoadingModelProviders,
} = useProviderContext()
const { data: providerSummary, isLoading: isLoadingModelProviders } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions(),
)
const providers = providerSummary?.data ?? EMPTY_MODEL_PROVIDERS
const modelProviderPlugins = providerSummary?.plugins ?? EMPTY_MODEL_PROVIDER_PLUGINS
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const marketplacePluginIds = useMemo(

View File

@ -41,12 +41,6 @@ let activeTextGenerationModelList: Array<Record<string, unknown>> = [
},
]
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet: true,
}),
}))
vi.mock('@/service/use-common', () => ({
useModelParameterRules: () => ({
data: {

View File

@ -36,9 +36,6 @@ vi.mock('nuqs', async (importOriginal) => {
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockModelProviders.current }),
}))
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => ({
variant: 'credits-active',

View File

@ -1,4 +1,5 @@
import type {
ModelProviderSummaryResponse,
ProviderModelWithStatusEntity,
ProviderWithModelsResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
@ -8,6 +9,7 @@ import type { ModelSelectorPreviewPayload } from '../popup-item'
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
@ -20,6 +22,23 @@ import {
} from '../../declarations'
import PopupItem from '../popup-item'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US'))
@ -88,10 +107,7 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
const mockUseProviderContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const mockProviderSummary = vi.hoisted(() => vi.fn())
const mockConsoleStateReader = vi.hoisted(() => vi.fn())
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
@ -137,7 +153,12 @@ const makeModel = (
...overrides,
})
const makeProvider = (overrides: Record<string, unknown> = {}) => ({
const makeProvider = (
overrides: Partial<Omit<ModelProviderSummaryResponse, 'custom_configuration'>> & {
custom_configuration?: Partial<ModelProviderSummaryResponse['custom_configuration']>
} = {},
) => ({
...providerSummaryFixture,
provider: 'openai',
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
@ -160,6 +181,10 @@ const renderPopupItem = (node: ReactElement) => {
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [makeProvider()],
})
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
...mockProviderSummary(),
plugins: {},
})
return renderWithConsoleQuery(createPopupItemNode(node), { queryClient })
}
@ -168,8 +193,8 @@ describe('PopupItem', () => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage']
mockUseLanguage.mockReturnValue('en_US')
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider()],
mockProviderSummary.mockReturnValue({
data: [makeProvider()],
})
mockConsoleStateReader.mockReturnValue({
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
@ -187,8 +212,8 @@ describe('PopupItem', () => {
})
it('should render nothing when provider is not found in modelProviders', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [],
mockProviderSummary.mockReturnValue({
data: [],
})
const { container } = renderPopupItem(
@ -423,8 +448,8 @@ describe('PopupItem', () => {
})
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [
mockProviderSummary.mockReturnValue({
data: [
makeProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
@ -450,8 +475,8 @@ describe('PopupItem', () => {
})
it('should show credits info when using system provider with remaining credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [
mockProviderSummary.mockReturnValue({
data: [
makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
}),
@ -474,8 +499,8 @@ describe('PopupItem', () => {
})
it('should show credits exhausted when system provider has no credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [
mockProviderSummary.mockReturnValue({
data: [
makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
}),

View File

@ -1,4 +1,5 @@
import type {
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ProviderModelWithStatusEntity,
ProviderWithModelsResponse,
@ -6,10 +7,11 @@ import type {
import type { ReactElement } from 'react'
import type { PopupProps } from '../popup'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@ -18,6 +20,23 @@ import {
} from '../../declarations'
import Popup from '../popup'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
let mockLanguage = 'en_US'
vi.mock(
@ -32,7 +51,7 @@ vi.mock('@/utils/tool-call', () => ({
supportFunctionCall: mockSupportFunctionCall,
}))
type MockContextProvider = Pick<
type MockSummaryProvider = Pick<
ModelProviderSummaryResponse,
| 'provider'
| 'label'
@ -42,10 +61,10 @@ type MockContextProvider = Pick<
| 'system_configuration'
>
const mockContextModelProviders = vi.hoisted(() => ({
current: [] as MockContextProvider[],
const mockModelProviders = vi.hoisted(() => ({
current: [] as MockSummaryProvider[],
}))
const mockContextModelProviderPlugins = vi.hoisted(() => ({
const mockModelProviderPlugins = vi.hoisted(() => ({
current: {} as Record<string, { plugin_id: string }>,
}))
const mockTrialModels = vi.hoisted(() => ({
@ -70,13 +89,6 @@ vi.mock('../popup-item', () => ({
),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockContextModelProviders.current,
modelProviderPlugins: mockContextModelProviderPlugins.current,
}),
}))
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange' | 'onSelect'>
function PopupHarness(props: PopupTestProps) {
@ -114,8 +126,36 @@ function PopupContentHarness(props: PopupTestProps) {
const renderPopup = (
ui: ReactElement<PopupTestProps>,
options: Parameters<typeof renderWithConsoleQuery>[1] = {},
) =>
renderWithConsoleQuery(ui, {
) => {
const queryClient = options.queryClient ?? createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
data: mockModelProviders.current.map(
(provider) =>
({
...providerSummaryFixture,
...provider,
custom_configuration: {
...providerSummaryFixture.custom_configuration,
...provider.custom_configuration,
},
}) satisfies ModelProviderSummaryResponse,
),
plugins: Object.fromEntries(
Object.entries(mockModelProviderPlugins.current).map(([id, plugin]) => [
id,
{
installation_id: 'installation',
plugin_unique_identifier: 'plugin:1.0.0',
runtime_type: 'local',
source: 'marketplace',
version: '1.0.0',
...plugin,
} satisfies ModelProviderPluginSummaryResponse,
]),
),
})
return renderWithConsoleQuery(ui, {
queryClient,
...options,
systemFeatures:
options.systemFeatures === null
@ -127,6 +167,7 @@ const renderPopup = (
},
trialModels: options.trialModels ?? mockTrialModels.current,
})
}
const mockTrialCredits = vi.hoisted(() => ({
credits: 200,
@ -227,19 +268,19 @@ const makeModel = (
...overrides,
})
const makeContextProvider = (
overrides: Partial<MockContextProvider> = {},
): MockContextProvider => ({
const makeSummaryProvider = (
overrides: Partial<MockSummaryProvider> = {},
): MockSummaryProvider => ({
provider: 'test-openai',
label: { en_US: 'Test OpenAI', zh_Hans: 'Test OpenAI' },
icon_small: { en_US: '', zh_Hans: '' },
icon_small_dark: { en_US: '', zh_Hans: '' },
custom_configuration: {
status: 'no-configure',
} as MockContextProvider['custom_configuration'],
} as MockSummaryProvider['custom_configuration'],
system_configuration: {
enabled: false,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
...overrides,
})
@ -255,8 +296,8 @@ describe('Popup', () => {
},
},
})
mockContextModelProviders.current = []
mockContextModelProviderPlugins.current = {}
mockModelProviders.current = []
mockModelProviderPlugins.current = {}
mockTrialModels.current = ['test-openai', 'test-anthropic']
Object.assign(mockTrialCredits, {
credits: 200,
@ -1004,12 +1045,12 @@ describe('Popup', () => {
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
@ -1021,49 +1062,66 @@ describe('Popup', () => {
)
})
it('should only mark API key fallback when the current credential is usable', () => {
it('should only mark API key fallback when the current credential is usable', async () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
current_credential_usable: false,
} as MockContextProvider['custom_configuration'],
} as MockSummaryProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
const { rerender } = renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
const { queryClient } = renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
'data-has-api-key-fallback',
'false',
)
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
current_credential_usable: true,
} as MockContextProvider['custom_configuration'],
} as MockSummaryProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
rerender(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
act(() => {
queryClient.setQueryData(
consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(),
{
data: mockModelProviders.current.map((provider) => ({
...providerSummaryFixture,
...provider,
custom_configuration: {
...providerSummaryFixture.custom_configuration,
...provider.custom_configuration,
},
})),
plugins: {},
},
)
})
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
'data-has-api-key-fallback',
'true',
await waitFor(() =>
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
'data-has-api-key-fallback',
'true',
),
)
})
@ -1074,12 +1132,12 @@ describe('Popup', () => {
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
@ -1095,15 +1153,15 @@ describe('Popup', () => {
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
} as MockContextProvider['custom_configuration'],
} as MockSummaryProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
@ -1179,8 +1237,8 @@ describe('Popup', () => {
})
it('should render marketplace providers that are not installed', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
mockContextModelProviderPlugins.current = {
mockModelProviders.current = [makeSummaryProvider({ provider: 'test-openai' })]
mockModelProviderPlugins.current = {
'langgenius/openai': { plugin_id: 'langgenius/openai' },
}
@ -1199,7 +1257,7 @@ describe('Popup', () => {
})
it('should hide marketplace providers when marketplace is disabled', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
mockModelProviders.current = [makeSummaryProvider({ provider: 'test-openai' })]
renderPopup(
<PopupHarness modelList={[makeModel({ provider: 'test-openai' })]} onHide={vi.fn()} />,
@ -1218,15 +1276,15 @@ describe('Popup', () => {
})
it('should show installed marketplace providers without models when AI credits are available', () => {
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
mockContextModelProviderPlugins.current = {
mockModelProviderPlugins.current = {
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
}
@ -1242,15 +1300,15 @@ describe('Popup', () => {
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
mockModelProviders.current = [
makeSummaryProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
} as MockSummaryProvider['system_configuration'],
}),
]
mockContextModelProviderPlugins.current = {
mockModelProviderPlugins.current = {
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
}
@ -1276,7 +1334,7 @@ describe('Popup', () => {
})
it('should hide a marketplace provider when its plugin is already installed', () => {
mockContextModelProviderPlugins.current = {
mockModelProviderPlugins.current = {
'langgenius/openai': { plugin_id: 'langgenius/openai' },
}

View File

@ -13,12 +13,13 @@ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgeni
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { renderI18nObject } from '@/i18n-config'
import { consoleQuery } from '@/service/console'
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
import {
useLanguage,
@ -68,10 +69,13 @@ function PopupItem({
const providerLabel = renderI18nObject(model.label, language)
const suggestionTip = t(($) => $['modelProvider.selector.suggestionTip'], { ns: 'common' })
const { setShowModelModal } = useModalContext()
const { modelProviders } = useProviderContext()
const { data: currentProvider } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) => response.data.find((provider) => provider.provider === model.provider),
}),
)
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const currentProvider = modelProviders.find((provider) => provider.provider === model.provider)
const { providerDetail, loadProviderDetail } = useLazyModelProviderDetail(model.provider)
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential

View File

@ -1,3 +1,4 @@
import type { ModelProviderSummaryListResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelSelectorPreviewPayload } from './popup-item'
import type {
ModelSelectorModel,
@ -19,7 +20,6 @@ import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { renderI18nObject } from '@/i18n-config'
import { consoleQuery } from '@/service/console'
@ -51,6 +51,9 @@ import {
ShowIncompatibleModelsButton,
} from './popup-layout'
const EMPTY_MODEL_PROVIDERS: ModelProviderSummaryListResponse['data'] = []
const EMPTY_MODEL_PROVIDER_PLUGINS: ModelProviderSummaryListResponse['plugins'] = {}
export type PopupProps = {
defaultModel?: ModelSelectorValue
inputValue: string
@ -88,7 +91,11 @@ function Popup({
)
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const { modelProviders, modelProviderPlugins = {} } = useProviderContext()
const { data: providerSummary } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions(),
)
const modelProviders = providerSummary?.data ?? EMPTY_MODEL_PROVIDERS
const modelProviderPlugins = providerSummary?.plugins ?? EMPTY_MODEL_PROVIDER_PLUGINS
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (systemFeatures) => systemFeatures.enable_marketplace,

View File

@ -4,6 +4,7 @@ import type { ModelProviderPluginSummary } from '../../index'
import { QueryClient } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { commonQueryKeys } from '@/service/use-common'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { seedSystemFeatures } from '@/test/console/query-data'
import { render } from '@/test/console/render'
@ -20,11 +21,11 @@ let mockWorkspacePermissionKeys: string[] = [
'credential.create',
'credential.manage',
]
const { mockInvalidateInstalledPluginList, mockProviderCardActions, mockRefreshModelProviders } =
const { mockInvalidateInstalledPluginList, mockProviderCardActions, mockInvalidateQueries } =
vi.hoisted(() => ({
mockInvalidateInstalledPluginList: vi.fn(),
mockProviderCardActions: vi.fn(),
mockRefreshModelProviders: vi.fn(),
mockInvalidateQueries: vi.fn(),
}))
const mockFetchModelProviderModels = vi.fn()
const mockQueryOptions = vi.fn(
@ -49,6 +50,7 @@ vi.mock('@/service/console', () => ({
workspaces: {
current: {
modelProviders: {
summary: { get: { key: () => ['model-provider-summary'] } },
byProvider: {
models: {
get: {
@ -80,11 +82,6 @@ vi.mock('@/context/permission-state', async () => {
}))
})
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: (selector: (state: object) => unknown) =>
selector({ refreshModelProviders: mockRefreshModelProviders }),
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
}))
@ -142,6 +139,7 @@ const createConsoleQueryClient = () =>
const renderWithQueryClient = (node: ReactElement) => {
const queryClient = createConsoleQueryClient()
vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(mockInvalidateQueries)
seedSystemFeatures(queryClient, { rbac_enabled: mockRbacEnabled })
return render(node, { wrapper: createQueryClientWrapper(queryClient) })
}
@ -249,7 +247,7 @@ describe('ProviderAddedCard', () => {
it('refreshes provider data and installed plugin details after an update', async () => {
let resolveProviderRefresh: (() => void) | undefined
let resolveInstalledPluginRefresh: (() => void) | undefined
mockRefreshModelProviders.mockReturnValue(
mockInvalidateQueries.mockReturnValue(
new Promise<void>((resolve) => {
resolveProviderRefresh = resolve
}),
@ -279,7 +277,10 @@ describe('ProviderAddedCard', () => {
})
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model)
expect(mockRefreshModelProviders).toHaveBeenCalledOnce()
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-provider-summary'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: commonQueryKeys.modelProviderDetails,
})
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
resolveProviderRefresh?.()

View File

@ -4,17 +4,17 @@ import type { ModelProvider } from '../declarations'
import type { ModelProviderPluginSummary } from '../index'
import type { ModelProviderQuotaGetPaid } from '../utils'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContextSelector } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { renderI18nObject } from '@/i18n-config'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { hasPermission } from '@/utils/permission'
import { useModelProviderListExpanded, useSetModelProviderListExpanded } from '../atoms'
@ -56,7 +56,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
}),
})
const language = useLanguage()
const refreshModelProviders = useProviderContextSelector((state) => state.refreshModelProviders)
const queryClient = useQueryClient()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const currentProviderName = provider.provider
const expanded = useModelProviderListExpanded(currentProviderName)
@ -113,10 +113,13 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
const refreshPluginData = useCallback(async () => {
await Promise.all([
refreshModelProviders(),
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
}),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
invalidateInstalledPluginList(PluginCategoryEnum.model),
])
}, [invalidateInstalledPluginList, refreshModelProviders])
}, [invalidateInstalledPluginList, queryClient])
const handleOpenModelList = useCallback(() => {
if (loading) return

View File

@ -2,6 +2,8 @@ import type { GetWorkspacesCurrentModelsModelTypesByModelTypeData } from '@dify/
import type { OperationKey } from '@orpc/tanstack-query'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { PluginCategoryEnum } from '../../../types'
// Mock invalidation / refresh functions
@ -13,7 +15,7 @@ const mockRefetchRerankModelList = vi.fn()
const mockRefetchSpeech2textModelList = vi.fn()
const mockRefetchTTSModelList = vi.fn()
const mockInvalidateDefaultModel = vi.fn()
const mockRefreshModelProviders = vi.fn()
const mockInvalidateQueries = vi.fn()
const mockInvalidateAllToolProviders = vi.fn()
const mockInvalidateAllBuiltInTools = vi.fn()
const mockInvalidateAllDataSources = vi.fn()
@ -41,10 +43,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
useInvalidateDefaultModel: () => mockInvalidateDefaultModel,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ refreshModelProviders: mockRefreshModelProviders }),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
useInvalidateAllBuiltInTools: () => mockInvalidateAllBuiltInTools,
@ -100,7 +98,12 @@ describe('useRefreshPluginList', () => {
result.current.refreshPluginList({ category: PluginCategoryEnum.model } as never)
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: commonQueryKeys.modelProviderDetails,
})
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
@ -151,7 +154,12 @@ describe('useRefreshPluginList', () => {
expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1)
expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1)
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: commonQueryKeys.modelProviderDetails,
})
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
@ -168,7 +176,7 @@ describe('useRefreshPluginList', () => {
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
expect(mockInvalidateAllToolProviders).not.toHaveBeenCalled()
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
@ -180,7 +188,7 @@ describe('useRefreshPluginList', () => {
result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never)
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
@ -191,6 +199,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
useQuery: (options: {
queryKey: OperationKey<
'query',

View File

@ -1,9 +1,9 @@
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useInvalidateDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import {
@ -60,7 +60,7 @@ const useRefreshPluginList = () => {
}),
)
const invalidateDefaultModel = useInvalidateDefaultModel()
const { refreshModelProviders } = useProviderContext()
const queryClient = useQueryClient()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
@ -104,7 +104,10 @@ const useRefreshPluginList = () => {
// model select
if ((manifest && PluginCategoryEnum.model.includes(manifest.category)) || refreshAllType) {
refreshModelProviders()
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails })
refetchLLMModelList()
refetchEmbeddingModelList()
refetchRerankModelList()

View File

@ -35,13 +35,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
// Mock provider context
const mockProviderContextValue = {
isAPIKeySet: true,
modelProviders: [],
}
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContextValue,
}))
// Mock model list hook
const mockTextGenerationList: ProviderWithModelsResponse[] = []
@ -243,8 +236,6 @@ describe('ModelParameterModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderContextValue.isAPIKeySet = true
mockProviderContextValue.modelProviders = []
setupModelLists()
mockFetchAndMergeValidCompletionParams.mockResolvedValue({ params: {}, removedDetails: {} })
})
@ -660,7 +651,6 @@ describe('ModelParameterModal', () => {
describe('Memoization - disabled', () => {
it('should keep active TTS model settings available without an active text generation model', async () => {
const user = userEvent.setup()
mockProviderContextValue.isAPIKeySet = false
const ttsModel = createModel({
provider: 'tts-provider',
models: [
@ -693,7 +683,6 @@ describe('ModelParameterModal', () => {
it('should keep model selection available when isAPIKeySet is false', () => {
// Arrange
mockProviderContextValue.isAPIKeySet = false
const model = createModel({
provider: 'openai',
models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })],
@ -737,7 +726,6 @@ describe('ModelParameterModal', () => {
it('should set disabled to false when all conditions are met', () => {
// Arrange
mockProviderContextValue.isAPIKeySet = true
const model = createModel({
provider: 'openai',
models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })],
@ -1330,14 +1318,12 @@ describe('ModelParameterModal', () => {
models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })],
})
setupModelLists({ textGeneration: [model] })
mockProviderContextValue.isAPIKeySet = true
const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } })
// Act
const { rerender } = render(<ModelParameterModal {...props} />)
expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false')
mockProviderContextValue.isAPIKeySet = false
rerender(<ModelParameterModal {...props} />)
// Assert

View File

@ -1,14 +1,42 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactElement } from 'react'
import type { Collection } from '../../types'
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { AuthType, CollectionType } from '../../types'
import ProviderDetail from '../detail'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
data: [
{
...providerSummaryFixture,
...{ provider: 'model-collection-id' },
} satisfies ModelProviderSummaryResponse,
],
plugins: {},
})
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [{ provider: 'model-collection-id' }],
})
@ -48,12 +76,6 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{ provider: 'model-collection-id', name: 'TestModel' }],
}),
}))
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
const mockFetchModelToolList = vi.fn().mockResolvedValue([])

View File

@ -1,4 +1,5 @@
'use client'
import type {
Collection,
CustomCollectionBackend,
@ -29,7 +30,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -45,9 +46,9 @@ import ConfigCredential from '@/app/components/tools/setting/build-in/config-cre
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { getLanguage } from '@/i18n-config/language'
import { consoleQuery } from '@/service/console'
import {
deleteWorkflowTool,
fetchBuiltInToolList,
@ -92,20 +93,23 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => {
// built in provider
const [showSettingAuth, setShowSettingAuth] = useState(false)
const { setShowModelModal } = useModalContext()
const { modelProviders: providers } = useProviderContext()
const { data: modelProvider } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) => response.data.find((provider) => provider.provider === collection?.id),
}),
)
const queryClient = useQueryClient()
const showSettingAuthModal = async () => {
if (!canOpenCredentialSettings) return
if (isModel) {
const summary = providers.find((item) => item.provider === collection?.id)
if (!summary) return
if (!modelProvider) return
try {
const response = await queryClient.query({
...modelProviderDetailsQueryOptions(),
staleTime: 'static',
})
const provider = response.data.find((item) => item.provider === summary.provider)
const provider = response.data.find((item) => item.provider === modelProvider.provider)
if (!provider) return
setShowModelModal({
payload: {

View File

@ -154,12 +154,6 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: (
selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown,
) => selector({ modelProviders: mockModelProviders }),
}))
// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook)
// ---------------------------------------------------------------------------
@ -864,6 +858,8 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
{ params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] }
>
}) => {
if (options.queryKey[0].includes('modelProviders') && options.queryKey[0].includes('summary'))
return { data: mockModelProviders }
if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options)
return { data: [] }
},

View File

@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { AgentNodeType } from '../nodes/agent/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types'
@ -30,7 +31,6 @@ import { normalizeModelProviderModelsResponse } from '@/app/components/header/ac
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
import {
createAgentToolProviderCatalog,
@ -80,6 +80,8 @@ import useNodesAvailableVarList, {
import { useNodesMetaData } from './use-nodes-meta-data'
import { useGetToolIcon } from './use-tool-icon'
const EMPTY_MODEL_PROVIDERS: ModelProviderSummaryResponse[] = []
export type ChecklistItem = {
id: string
type: BlockEnum | string
@ -184,7 +186,11 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
const appMode = useAppStore.getState().appDetail?.mode
const shouldCheckStartNode =
appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const modelProviders = useProviderContextSelector((s) => s.modelProviders)
const { data: modelProviders = EMPTY_MODEL_PROVIDERS } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) => response.data,
}),
)
const workflowStore = useWorkflowStore()
const configsMap = useHooksStore((s) => s.configsMap)
@ -618,7 +624,11 @@ export const useChecklistBeforePublish = () => {
const store = useStoreApi()
const { nodesMap: nodesExtraData } = useNodesMetaData()
const { data: strategyProviders } = useStrategyProviders()
const modelProviders = useProviderContextSelector((s) => s.modelProviders)
const { data: modelProviders = EMPTY_MODEL_PROVIDERS } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) => response.data,
}),
)
const updateDatasetsDetail = useDatasetsDetailStore((s) => s.updateDatasetsDetail)
const updateTimeRef = useRef(0)
const workflowStore = useWorkflowStore()

View File

@ -1,15 +1,33 @@
import { renderHook } from '@testing-library/react'
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
createCredentialState,
createModel,
createModelItem,
createProviderMeta,
} from '@/app/components/workflow/__tests__/model-provider-fixtures'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data'
import { useEmbeddingModelStatus } from '../use-embedding-model-status'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
const mockUseProviderContext = vi.hoisted(() => vi.fn())
const mockProviderSummary = vi.hoisted(() => vi.fn())
vi.mock(
'@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state',
@ -18,18 +36,26 @@ vi.mock(
}),
)
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const renderHook: typeof renderHookWithConsoleQuery = (callback, options) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), {
...mockProviderSummary(),
plugins: {},
})
return renderHookWithConsoleQuery(callback, { ...options, queryClient })
}
describe('useEmbeddingModelStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [
createProviderMeta({
supported_model_types: [ModelTypeEnum.textEmbedding],
}),
mockProviderSummary.mockReturnValue({
data: [
{
...providerSummaryFixture,
...{
supported_model_types: [ModelTypeEnum.textEmbedding],
},
} satisfies ModelProviderSummaryResponse,
],
})
mockUseCredentialPanelState.mockReturnValue(createCredentialState())

View File

@ -3,11 +3,11 @@ import type {
ProviderModelWithStatusEntity,
ProviderWithModelsResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { deriveModelStatus } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/console'
type UseEmbeddingModelStatusProps = {
embeddingModel?: string
@ -16,7 +16,7 @@ type UseEmbeddingModelStatusProps = {
}
type UseEmbeddingModelStatusResult = {
providerMeta: ModelProviderSummaryResponse | ModelProvider | undefined
providerMeta: ModelProviderSummaryResponse | undefined
modelProvider: ProviderWithModelsResponse | undefined
currentModel: ProviderModelWithStatusEntity | undefined
status: ReturnType<typeof deriveModelStatus>
@ -27,11 +27,12 @@ export const useEmbeddingModelStatus = ({
embeddingModelProvider,
embeddingModelList,
}: UseEmbeddingModelStatusProps): UseEmbeddingModelStatusResult => {
const { modelProviders } = useProviderContext()
const providerMeta = useMemo(() => {
return modelProviders.find((provider) => provider.provider === embeddingModelProvider)
}, [embeddingModelProvider, modelProviders])
const { data: providerMeta } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) =>
response.data.find((provider) => provider.provider === embeddingModelProvider),
}),
)
const modelProvider = useMemo(() => {
return embeddingModelList.find((provider) => provider.provider === embeddingModelProvider)

View File

@ -1,26 +1,36 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { LLMNodeType } from '../types'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { PanelProps } from '@/types/workflow'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import { FlowType } from '@/types/common'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { BlockEnum } from '../../../types'
import Panel from '../panel'
const providerSummaryFixture = {
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
preferred_provider_type: 'custom',
is_configured: true,
system_configuration: { enabled: false },
custom_configuration: {
status: 'active',
available_credentials: [],
current_credential_usable: true,
has_custom_models: false,
},
} satisfies ModelProviderSummaryResponse
const mockUseConfig = vi.fn()
const mockFetchAndMergeValidCompletionParams = vi.mocked(fetchAndMergeValidCompletionParams)
@ -82,37 +92,6 @@ vi.mock('../components/reasoning-format-config', () => ({
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
const createMockModelProvider = (provider: string): ModelProvider => ({
provider,
label: { en_US: provider, zh_Hans: provider },
help: {
title: { en_US: provider, zh_Hans: provider },
url: { en_US: '', zh_Hans: '' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: '', zh_Hans: '' },
placeholder: { en_US: '', zh_Hans: '' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
const baseNodeData: LLMNodeType = {
type: BlockEnum.LLM,
title: 'LLM',
@ -176,24 +155,28 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
...overrides,
})
let panelQueryClient = createConsoleQueryWrapper().queryClient
const renderPanelElement = (data?: Partial<LLMNodeType>) => (
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its special provider.
<ProviderContext.Provider
value={createMockProviderContextValue({
modelProviders: [
{
...createMockModelProvider('openai'),
is_configured: true,
plugin_id: 'langgenius/openai',
} as unknown as ModelProviderSummaryResponse,
],
})}
>
<QueryClientProvider client={panelQueryClient}>
<Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} />
</ProviderContext.Provider>
</QueryClientProvider>
)
const renderPanel = (data?: Partial<LLMNodeType>, flowType?: FlowType) => {
panelQueryClient = createConsoleQueryWrapper().queryClient
panelQueryClient.setQueryData(
consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(),
{
data: [
{
...providerSummaryFixture,
...{ provider: 'openai' },
} satisfies ModelProviderSummaryResponse,
],
plugins: {},
},
)
return renderWorkflowFlowComponent(renderPanelElement(data), {
hooksStoreProps: flowType
? { configsMap: { flowId: 'test-flow', flowType, fileSettings: {} } }

View File

@ -10,6 +10,7 @@ import {
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@ -19,7 +20,7 @@ import Field from '@/app/components/workflow/nodes/_base/components/field'
import FormInputTypeSwitch from '@/app/components/workflow/nodes/_base/components/form-input-type-switch'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/console'
import { FlowType } from '@/types/common'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { extractPluginId } from '../../utils/plugin'
@ -87,15 +88,17 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
handleReasoningFormatChange,
} = useConfig(id, data)
const isModelProviderInstalled = useProviderContextSelector((state) => {
const modelIssue = getLLMModelIssue({ modelProvider: model?.provider })
if (modelIssue === LLMModelIssueCode.providerRequired) return true
const modelProviderPluginId = extractPluginId(model.provider)
return state.modelProviders.some(
(provider) => extractPluginId(provider.provider) === modelProviderPluginId,
)
})
const isProviderRequired =
getLLMModelIssue({ modelProvider: model?.provider }) === LLMModelIssueCode.providerRequired
const { data: isModelProviderInstalled = isProviderRequired } = useQuery(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions({
select: (response) =>
isProviderRequired ||
response.data.some(
(provider) => extractPluginId(provider.provider) === extractPluginId(model.provider),
),
}),
)
const hasModelWarning =
getLLMModelIssue({
modelProvider: model?.provider,

View File

@ -1,42 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { ProviderContext } from './provider-context'
type ProviderContextProviderProps = {
children: ReactNode
}
export const ProviderContextProvider = ({ children }: ProviderContextProviderProps) => {
const queryClient = useQueryClient()
const {
data: providersData,
isLoading: isLoadingModelProviders,
isSuccess: isSuccessModelProviders,
} = useQuery(consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions())
const refreshModelProviders = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
}),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
]).then(() => undefined)
return (
<ProviderContext.Provider
value={{
modelProviders: providersData?.data || [],
modelProviderPlugins: providersData?.plugins || {},
isLoadingModelProviders,
isSuccessModelProviders,
refreshModelProviders,
}}
>
{children}
</ProviderContext.Provider>
)
}

View File

@ -1,32 +0,0 @@
'use client'
import type {
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
export type ProviderContextState = {
modelProviders: ModelProviderSummaryResponse[]
modelProviderPlugins: Record<string, ModelProviderPluginSummaryResponse>
isLoadingModelProviders: boolean
isSuccessModelProviders: boolean
refreshModelProviders: () => Promise<void>
}
const baseProviderContextValue: ProviderContextState = {
modelProviders: [],
modelProviderPlugins: {},
isLoadingModelProviders: false,
isSuccessModelProviders: false,
refreshModelProviders: async () => {},
}
export const ProviderContext = createContext<ProviderContextState>(baseProviderContextValue)
export const useProviderContext = () => useContext(ProviderContext)
// Adding a dangling comma to avoid the generic parsing issue in tsx, see:
// https://github.com/microsoft/TypeScript/issues/15713
export const useProviderContextSelector = <T>(selector: (state: ProviderContextState) => T): T =>
useContextSelector(ProviderContext, selector)