From d978fe710fa5ebd6b431ef10397f9ae9f6c6d9ee Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:16:45 +0000 Subject: [PATCH] refactor(web): retire provider context (#41944) --- web/__mocks__/provider-context.ts | 23 --- web/app/(commonLayout)/providers.tsx | 5 +- .../header-opts/__tests__/index.spec.tsx | 9 - .../settings-modal/__tests__/index.spec.tsx | 6 - .../__tests__/retrieval-section.spec.tsx | 6 - .../model-parameter-trigger.spec.tsx | 7 +- .../__tests__/component.spec.tsx | 13 +- .../use-llm-model-plugin-installed.spec.ts | 39 +++- .../use-llm-model-plugin-installed.ts | 16 +- .../settings/form/__tests__/index.spec.tsx | 16 -- .../account-setting/__tests__/index.spec.tsx | 1 - .../__tests__/index.non-cloud.spec.tsx | 73 ++++---- .../__tests__/index.spec.tsx | 139 +++++++++----- .../model-provider-page/index.tsx | 15 +- .../__tests__/index.spec.tsx | 6 - .../model-selector/__tests__/index.spec.tsx | 3 - .../__tests__/popup-item.spec.tsx | 55 ++++-- .../model-selector/__tests__/popup.spec.tsx | 174 ++++++++++++------ .../model-selector/popup-item.tsx | 10 +- .../model-selector/popup.tsx | 11 +- .../__tests__/index.spec.tsx | 19 +- .../provider-added-card/index.tsx | 13 +- .../__tests__/use-refresh-plugin-list.spec.ts | 27 ++- .../hooks/use-refresh-plugin-list.tsx | 11 +- .../model-selector/__tests__/index.spec.tsx | 14 -- .../tools/provider/__tests__/detail.spec.tsx | 34 +++- web/app/components/tools/provider/detail.tsx | 16 +- .../hooks/__tests__/use-checklist.spec.ts | 8 +- .../workflow/hooks/use-checklist.ts | 16 +- .../use-embedding-model-status.spec.ts | 48 +++-- .../hooks/use-embedding-model-status.ts | 17 +- .../nodes/llm/__tests__/panel.spec.tsx | 91 ++++----- .../components/workflow/nodes/llm/panel.tsx | 23 ++- web/context/provider-context-provider.tsx | 42 ----- web/context/provider-context.ts | 32 ---- 35 files changed, 562 insertions(+), 476 deletions(-) delete mode 100644 web/__mocks__/provider-context.ts delete mode 100644 web/context/provider-context-provider.tsx delete mode 100644 web/context/provider-context.ts diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts deleted file mode 100644 index 5fb7714733a..00000000000 --- a/web/__mocks__/provider-context.ts +++ /dev/null @@ -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 => { - const merged = merge({}, baseProviderContextValue, overrides) - - return { - ...merged, - refreshModelProviders: merged.refreshModelProviders ?? noop, - } -} diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index f414de22036..4cfb70c1ec8 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -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 ( - - {children} - + {children} ) } diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 0a6056b9dcb..6435c5d343f 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -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: () =>
, })) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index 7945ba69611..9260952c107 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -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[]) => diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx index 8ba6bc71c21..c9d42d9a6aa 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx @@ -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), diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx index edbd7b0f374..5aeb435cd43 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx @@ -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', diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx index 70391788716..f70f16c604e 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx @@ -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), diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts index 94bf87001c0..2466eca3c8a 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts @@ -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: ( - selector: (state: { modelProviders: Array<{ provider: string }> }) => T, - ): T => selector({ modelProviders: mockModelProviders }), -})) +let mockModelProviders: Array<{ provider: string }> = [] const createWorkflowNodesMap = (node: Record): WorkflowNodesMap => ({ @@ -20,6 +33,18 @@ const createWorkflowNodesMap = (node: Record): 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() diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts index 5c6f1b17375..5922a992a46 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts @@ -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 } diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 0bc05f9d189..461e1eb7255 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -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, })) diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index bfdebe8331c..bbc99191465 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -75,7 +75,6 @@ vi.mock('@/service/use-common', async (importOriginal) => { return { ...actual, useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })), - useProviderContext: vi.fn(), } }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx index 18d13b2bc38..8770adb2b6f 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx @@ -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() @@ -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(, { + renderPage(, { systemFeatures: { enable_marketplace: false }, }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 25a5c944ddd..04259f27d46 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -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 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( - , - { - 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( + , + { + queryClient, + systemFeatures: { enable_marketplace: enableMarketplace }, + }, + ) +} const mockDefaultModels: Record = { 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', diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 83f0df67996..3565e3a3c19 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -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( diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx index 3be0d8210e0..5da62eb4c50 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx @@ -41,12 +41,6 @@ let activeTextGenerationModelList: Array> = [ }, ] -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - isAPIKeySet: true, - }), -})) - vi.mock('@/service/use-common', () => ({ useModelParameterRules: () => ({ data: { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index df93e22d2c2..45084128556 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -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', diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index d71234fafca..97ff1711d3e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -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 = {}) => ({ +const makeProvider = ( + overrides: Partial> & { + custom_configuration?: Partial + } = {}, +) => ({ + ...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, }), diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index 9244bed97a3..84850fe0385 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -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, })) 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 function PopupHarness(props: PopupTestProps) { @@ -114,8 +126,36 @@ function PopupContentHarness(props: PopupTestProps) { const renderPopup = ( ui: ReactElement, options: Parameters[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 => ({ +const makeSummaryProvider = ( + overrides: Partial = {}, +): 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() + const { queryClient } = renderPopup() 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() + 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( , @@ -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' }, } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index c5181b7b401..ee97e3b3f3b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -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 diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index 87adca1df51..0926973969f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -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, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index cc6f52072b5..c59903b7da2 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -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((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?.() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index 7177b1a1bb6..3172b4ca4d6 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -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 = ({ }), }) 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 = ({ 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 diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index 1a3db09b961..269214d2032 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -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() return { ...actual, + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), useQuery: (options: { queryKey: OperationKey< 'query', diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index a201e7b48be..06841cfc43b 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -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() diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx index 4ce20dd3d15..851ef468771 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx @@ -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() expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false') - mockProviderContextValue.isAPIKeySet = false rerender() // Assert diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index f86c32e2bfd..a5b21a1acb7 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -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([]) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 343d26aa948..44f16041351 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -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: { diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index ede5f77309b..b4d5c20bec7 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -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: [] } }, diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 14bf360cec6..51ce2238b96 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -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() diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts index dbe7acf1b26..ebb2b3b7992 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts @@ -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()) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts index 22478cb1957..c459a34cf01 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts @@ -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 @@ -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) diff --git a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx index ac1887a7688..d40fde22f90 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx @@ -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 -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) => ({ ...overrides, }) +let panelQueryClient = createConsoleQueryWrapper().queryClient + const renderPanelElement = (data?: Partial) => ( - // oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its special provider. - + - + ) const renderPanel = (data?: Partial, 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: {} } } diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index e3250985ee8..17752a76797 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -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> = ({ 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, diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx deleted file mode 100644 index 3046dcef820..00000000000 --- a/web/context/provider-context-provider.tsx +++ /dev/null @@ -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 ( - - {children} - - ) -} diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts deleted file mode 100644 index 1df749020d8..00000000000 --- a/web/context/provider-context.ts +++ /dev/null @@ -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 - isLoadingModelProviders: boolean - isSuccessModelProviders: boolean - refreshModelProviders: () => Promise -} - -const baseProviderContextValue: ProviderContextState = { - modelProviders: [], - modelProviderPlugins: {}, - isLoadingModelProviders: false, - isSuccessModelProviders: false, - refreshModelProviders: async () => {}, -} - -export const ProviderContext = createContext(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 = (selector: (state: ProviderContextState) => T): T => - useContextSelector(ProviderContext, selector)