From 1cf8db8395d74cf3b0cc76199353d9db689b007e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:03:50 +0000 Subject: [PATCH] refactor(web): clarify model selector trigger ownership (#40732) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- oxlint-suppressions.json | 34 --- .../configuration-view/index.tsx | 1 + .../__tests__/config-content.spec.tsx | 10 +- .../params-config/__tests__/index.spec.tsx | 10 +- .../params-config/config-content.tsx | 8 +- .../settings-modal/__tests__/index.spec.tsx | 4 +- .../__tests__/retrieval-section.spec.tsx | 4 +- .../dataset-config/settings-modal/index.tsx | 8 +- .../model-parameter-trigger.spec.tsx | 90 +++---- .../model-parameter-trigger.tsx | 180 ++++++++------ web/app/components/app/log/model-info.tsx | 23 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/config-param-modal.spec.tsx | 18 +- .../annotation-reply/__tests__/index.spec.tsx | 2 +- .../annotation-reply/config-param-modal.tsx | 8 +- .../__tests__/index.spec.tsx | 27 +- .../common/retrieval-param-config/index.tsx | 18 +- .../create/step-two/__tests__/index.spec.tsx | 18 +- .../__tests__/indexing-mode-section.spec.tsx | 20 +- .../components/indexing-mode-section.tsx | 11 +- .../__tests__/summary-index-setting.spec.tsx | 28 +-- .../__tests__/indexing-section.spec.tsx | 16 +- .../form/components/indexing-section.tsx | 10 +- .../settings/summary-index-setting.tsx | 28 +-- .../__tests__/settings-modal.spec.tsx | 6 +- .../__tests__/hooks.spec.ts | 81 +++--- .../derive-model-status.ts | 6 +- .../model-provider-page/hooks.ts | 39 ++- .../model-provider-page/model-badge/index.tsx | 4 +- .../model-provider-page/model-icon/index.tsx | 16 +- .../model-provider-page/model-modal/Form.tsx | 3 - .../model-modal/__tests__/Form.spec.tsx | 33 --- .../model-name/__tests__/index.spec.tsx | 6 + .../model-provider-page/model-name/index.tsx | 25 +- .../__tests__/agent-model-trigger.spec.tsx | 180 -------------- .../__tests__/index.spec.tsx | 62 +++-- .../__tests__/model-settings-trigger.spec.tsx | 31 +++ .../__tests__/status-indicators.spec.tsx | 173 ------------- .../agent-model-trigger.tsx | 152 ----------- .../configuration-button.tsx | 30 --- .../model-parameter-modal/index.tsx | 159 ++++++------ .../model-parameter-modal/model-display.tsx | 25 -- .../model-settings-trigger.tsx | 42 ++++ .../status-indicators.tsx | 153 ------------ .../model-parameter-modal/types.ts | 9 - .../model-selector/__tests__/index.spec.tsx | 126 +++++----- .../__tests__/model-selector-trigger.spec.tsx | 94 +++---- .../model-selector/__tests__/popover.spec.tsx | 55 ---- .../model-selector/__tests__/popup.spec.tsx | 114 +++++---- .../model-selector/feature-icon.tsx | 18 +- .../model-selector/index.tsx | 188 ++++++++------ .../model-selector/model-search.ts | 17 +- .../model-selector/model-selector-trigger.tsx | 235 ++++++++++-------- .../model-selector/popup-item.tsx | 15 +- .../model-selector/popup-layout.tsx | 16 +- .../model-selector/popup.tsx | 153 ++++++------ .../model-selector/types.ts | 27 +- .../__tests__/index.spec.tsx | 6 +- .../system-model-selector/index.tsx | 34 +-- .../model-provider-page/utils.ts | 2 +- .../header/account-setting/query-params.ts | 6 +- .../model-selector/__tests__/index.spec.tsx | 222 ++++++----------- .../model-selector/index.tsx | 156 ++++-------- .../nodes/_base/components/agent-strategy.tsx | 1 - .../agent/__tests__/integration.spec.tsx | 5 +- .../components/__tests__/model-bar.spec.tsx | 15 +- .../nodes/agent/components/model-bar.tsx | 22 +- .../__tests__/embedding-model.spec.tsx | 12 +- .../components/embedding-model.tsx | 10 +- .../reranking-model-selector.spec.tsx | 29 ++- .../__tests__/search-method-option.spec.tsx | 52 +--- .../reranking-model-selector.tsx | 10 +- .../nodes/llm/__tests__/node.spec.tsx | 7 +- .../components/workflow/nodes/llm/node.tsx | 10 +- .../__tests__/integration.spec.tsx | 5 +- .../__tests__/node.spec.tsx | 7 +- .../nodes/parameter-extractor/node.tsx | 10 +- .../__tests__/integration.spec.tsx | 4 +- .../__tests__/node.spec.tsx | 5 +- .../nodes/question-classifier/node.tsx | 10 +- .../__tests__/variable-trigger.spec.tsx | 17 ++ .../workflow/panel/env-panel/index.tsx | 4 +- .../components/orchestrate/index.tsx | 4 +- .../model-config/__tests__/field.spec.tsx | 52 ++++ .../orchestrate/model-config/field.tsx | 107 +++----- .../configure/model-compatibility.ts | 16 +- web/service/common.ts | 5 - web/service/use-common.ts | 12 +- web/service/use-plugins.ts | 48 +--- web/utils/tool-call.ts | 4 +- 90 files changed, 1481 insertions(+), 2299 deletions(-) delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/model-settings-trigger.spec.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-settings-trigger.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index ee5ab9e82a1..924177a84f3 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -512,11 +512,6 @@ "count": 5 } }, - "web/app/components/app/log/model-info.tsx": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/app/log/var-panel.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 2 @@ -2694,30 +2689,6 @@ "count": 5 } }, - "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - }, - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": { "eslint-react/set-state-in-effect": { "count": 1 @@ -2931,11 +2902,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx": { - "typescript/no-explicit-any": { - "count": 3 - } - }, "web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": { "typescript/no-explicit-any": { "count": 1 diff --git a/web/app/components/app/configuration/configuration-view/index.tsx b/web/app/components/app/configuration/configuration-view/index.tsx index 9412d52e134..debbb78ed15 100644 --- a/web/app/components/app/configuration/configuration-view/index.tsx +++ b/web/app/components/app/configuration/configuration-view/index.tsx @@ -139,6 +139,7 @@ const ConfigurationView: FC = ({ <> { type Props = { - defaultModel?: { provider: string; model: string } - onSelect?: (model: { provider: string; model: string }) => void + value?: { provider: string; model: string } + onValueChange?: (model: { provider: string; model: string }) => void } - const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( + const MockModelSelector = ({ value, onValueChange }: Props) => ( ) return { - default: MockModelSelector, + ModelSelector: MockModelSelector, } }) diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx index fe88910da73..c7dfea71013 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx @@ -20,21 +20,21 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { type Props = { - defaultModel?: { provider: string; model: string } - onSelect?: (model: { provider: string; model: string }) => void + value?: { provider: string; model: string } + onValueChange?: (model: { provider: string; model: string }) => void } - const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( + const MockModelSelector = ({ value, onValueChange }: Props) => ( ) return { - default: MockModelSelector, + ModelSelector: MockModelSelector, } }) diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index 767f2aa6d0e..5f94de49ed9 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -20,7 +20,7 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks' import { RerankingModeEnum } from '@/models/datasets' import { RETRIEVE_TYPE } from '@/types/app' @@ -279,13 +279,13 @@ const ConfigContent: FC = ({ {showRerankModel && (
{ + onValueChange={(v) => { onChange({ ...datasetConfigs, reranking_model: { @@ -294,7 +294,7 @@ const ConfigContent: FC = ({ }, }) }} - modelList={rerankModelList} + models={rerankModelList} />
)} 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 6f30a45fbf6..348a961d71e 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 @@ -107,9 +107,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( + ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
- {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} + {value ? `${value.provider}/${value.model}` : 'no-model'}
), })) 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 1453738ead0..16c60ea0038 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 @@ -46,9 +46,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( + ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
- {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} + {value ? `${value.provider}/${value.model}` : 'no-model'}
), })) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index ef389e99742..be646be30fd 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -20,7 +20,7 @@ import PermissionSelector from '@/app/components/datasets/settings/permission-se import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { settingsQueryParamName, settingsQueryParser, @@ -305,12 +305,12 @@ const SettingsModal: FC = ({
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 f7ee57353a7..2913542f8ec 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 @@ -20,14 +20,9 @@ import ModelParameterTrigger from '../model-parameter-trigger' const mockUseDebugConfigurationContext = vi.fn() const mockUseDebugWithMultipleModelContext = vi.fn() -const mockUseProviderContext = vi.fn() +const mockProvidersSummary = vi.fn() const mockUseCredentialPanelState = vi.fn() - -type RenderTriggerProps = { - open: boolean - currentProvider: { provider: string } | null - currentModel: { model: string; status: ModelStatusEnum } | null -} +const mockUseCurrentModel = vi.fn() let capturedModalProps: { isAdvancedMode: boolean @@ -38,7 +33,7 @@ let capturedModalProps: { setModel: (model: { modelId: string; provider: string }) => void debugWithMultipleModel: boolean onDebugWithMultipleModelChange: () => void - renderTrigger: (props: RenderTriggerProps) => ReactNode + trigger: ReactNode } | null = null vi.mock('@/context/debug-configuration', () => ({ @@ -49,9 +44,20 @@ vi.mock('../context', () => ({ useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(), })) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => mockUseProviderContext(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: ({ + select, + }: { + select?: (data: { data: ModelProviderSummaryResponse[] }) => unknown + }) => { + const data = { data: mockProvidersSummary() } + return { data: select ? select(data) : data } + }, + } +}) vi.mock( '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', @@ -60,18 +66,16 @@ vi.mock( }), ) +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: () => mockUseCurrentModel(), +})) + vi.mock( '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ default: (props: typeof capturedModalProps) => { capturedModalProps = props - // Render the trigger that the component passes - const triggerContent = props?.renderTrigger({ - open: false, - currentProvider: null, - currentModel: null, - }) - return
{triggerContent}
+ return
{props?.trigger}
}, }), ) @@ -157,7 +161,7 @@ describe('ModelParameterTrigger', () => { onMultipleModelConfigsChange: vi.fn(), onDebugWithMultipleModelChange: vi.fn(), }) - mockUseProviderContext.mockReturnValue( + mockProvidersSummary.mockReturnValue( createMockProviderContextValue({ modelProviders: [ { @@ -166,7 +170,7 @@ describe('ModelParameterTrigger', () => { plugin_id: 'langgenius/openai', } as unknown as ModelProviderSummaryResponse, ], - }), + }).modelProviders, ) mockUseCredentialPanelState.mockReturnValue({ variant: 'api-active', @@ -178,6 +182,7 @@ describe('ModelParameterTrigger', () => { credentialName: 'Primary Key', credits: 10, }) + mockUseCurrentModel.mockReturnValue({ currentProvider: null, currentModel: null }) }) describe('rendering', () => { @@ -374,10 +379,9 @@ describe('ModelParameterTrigger', () => { }) describe('trigger rendering', () => { - it('should render trigger content from renderTrigger', () => { + it('should render the trigger element', () => { renderComponent() - // The trigger is rendered via renderTrigger callback expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument() }) @@ -404,15 +408,11 @@ describe('ModelParameterTrigger', () => { }) it('should render configure required tooltip for no-configure status', async () => { - const { unmount } = renderComponent() - const triggerContent = capturedModalProps?.renderTrigger({ - open: false, + mockUseCurrentModel.mockReturnValue({ currentProvider: { provider: 'openai' }, currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure }, }) - - unmount() - render(<>{triggerContent}) + renderComponent() await userEvent.hover( screen.getByLabelText('common.modelProvider.selector.configureRequired'), @@ -423,15 +423,11 @@ describe('ModelParameterTrigger', () => { }) it('should render disabled tooltip for disabled status', async () => { - const { unmount } = renderComponent() - const triggerContent = capturedModalProps?.renderTrigger({ - open: false, + mockUseCurrentModel.mockReturnValue({ currentProvider: { provider: 'openai' }, currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.disabled }, }) - - unmount() - render(<>{triggerContent}) + renderComponent() await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.disabled')) expect(await screen.findByText('common.modelProvider.selector.disabled')).toBeInTheDocument() @@ -478,30 +474,18 @@ describe('ModelParameterTrigger', () => { }) }) - describe('renderTrigger with different states', () => { - it('should pass correct props to renderTrigger', () => { + describe('trigger element', () => { + it('should pass a trigger element to the modal', () => { renderComponent() - expect(capturedModalProps?.renderTrigger).toBeDefined() - expect(typeof capturedModalProps?.renderTrigger).toBe('function') + expect(capturedModalProps?.trigger).toBeDefined() }) it('should render trigger with provider info when available', () => { - // Mock the modal to render trigger with provider - vi.doMock( - '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', - () => ({ - default: (props: typeof capturedModalProps) => { - capturedModalProps = props - const triggerContent = props?.renderTrigger({ - open: false, - currentProvider: { provider: 'openai' }, - currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.active }, - }) - return
{triggerContent}
- }, - }), - ) + mockUseCurrentModel.mockReturnValue({ + currentProvider: { provider: 'openai' }, + currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.active }, + }) renderComponent() diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx index 4efdfa0af14..d9cd7d97572 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx @@ -1,7 +1,9 @@ -import type { FC } from 'react' +import type { ComponentPropsWithRef, FC } from 'react' import type { ModelAndParameter } from '../types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useQuery } from '@tanstack/react-query' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { @@ -9,28 +11,121 @@ import { DERIVED_MODEL_STATUS_TOOLTIP_I18N, deriveModelStatus, } from '@/app/components/header/account-setting/model-provider-page/derive-model-status' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state' import { useDebugConfigurationContext } from '@/context/debug-configuration' -import { useProviderContext } from '@/context/provider-context' +import { consoleQuery } from '@/service/client' import { useDebugWithMultipleModelContext } from './context' type ModelParameterTriggerProps = { modelAndParameter: ModelAndParameter } -const ModelParameterTrigger: FC = ({ modelAndParameter }) => { + +type DebugModelParameterTriggerProps = ComponentPropsWithRef<'button'> & ModelParameterTriggerProps + +const DebugModelParameterTrigger: FC = ({ + modelAndParameter, + className, + ...triggerProps +}) => { const { t } = useTranslation() + const { currentProvider, currentModel } = useTextGenerationCurrentProviderAndModelAndModelList({ + provider: modelAndParameter.provider, + model: modelAndParameter.model, + }) + const { data: providerMeta } = useQuery({ + ...consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions(), + enabled: !!modelAndParameter.provider, + select: ({ data }) => data.find((provider) => provider.provider === modelAndParameter.provider), + }) + const credentialPanel = useCredentialPanelState(providerMeta) + const status = deriveModelStatus( + modelAndParameter.model, + modelAndParameter.provider, + providerMeta, + currentModel, + credentialPanel, + ) + const iconProvider = currentProvider || providerMeta + const statusLabelKey = + DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] + const statusTooltipKey = + DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N] + const isEmpty = status === 'empty' + const isActive = status === 'active' + const statusTooltipLabel = + !isEmpty && !isActive && statusLabelKey + ? t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' }) + : undefined + + return ( + + + {iconProvider && !isEmpty && ( + + )} + {(!iconProvider || isEmpty) && ( + + + + )} + {currentModel && ( + + )} + {!currentModel && !isEmpty && ( + + {modelAndParameter.model} + + )} + {isEmpty && ( + + {t(($) => $['modelProvider.selectModel'], { ns: 'common' })} + + )} + + {statusTooltipLabel && ( + + )} + + } + /> + {statusTooltipLabel && {statusTooltipLabel}} + + ) +} + +const ModelParameterTrigger: FC = ({ modelAndParameter }) => { const { isAdvancedMode } = useDebugConfigurationContext() const { multipleModelConfigs, onMultipleModelConfigsChange, onDebugWithMultipleModelChange } = useDebugWithMultipleModelContext() - const { modelProviders } = useProviderContext() const index = multipleModelConfigs.findIndex((v) => v.id === modelAndParameter.id) - const providerMeta = modelProviders.find( - (provider) => provider.provider === modelAndParameter.provider, - ) - const credentialState = useCredentialPanelState(providerMeta) const handleSelectModel = ({ modelId, provider }: { modelId: string; provider: string }) => { const newModelConfigs = [...multipleModelConfigs] @@ -60,74 +155,7 @@ const ModelParameterTrigger: FC = ({ modelAndParamet setModel={handleSelectModel} debugWithMultipleModel onDebugWithMultipleModelChange={() => onDebugWithMultipleModelChange(modelAndParameter)} - renderTrigger={({ open, currentProvider, currentModel }) => { - const status = deriveModelStatus( - modelAndParameter.model, - modelAndParameter.provider, - providerMeta, - currentModel ?? undefined, - credentialState, - ) - const iconProvider = currentProvider || providerMeta - const statusLabelKey = - DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] - const statusTooltipKey = - DERIVED_MODEL_STATUS_TOOLTIP_I18N[ - status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N - ] - const isEmpty = status === 'empty' - const isActive = status === 'active' - - return ( -
- {iconProvider && !isEmpty && ( - - )} - {(!iconProvider || isEmpty) && ( -
- -
- )} - {currentModel && ( - - )} - {!currentModel && !isEmpty && ( -
- {modelAndParameter.model} -
- )} - {isEmpty && ( -
- {t(($) => $['modelProvider.selectModel'], { ns: 'common' })} -
- )} - - {!isEmpty && !isActive && statusLabelKey && ( - - $[statusTooltipKey || statusLabelKey], { ns: 'common' })} - className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" - /> - } - /> - - {t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' })} - - - )} -
- ) - }} + trigger={} /> ) } diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index ea0a36a1f8e..d33836d447b 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ModelConfig } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -19,35 +20,35 @@ const PARAM_MAP = { } type Props = Readonly<{ - model: any + model: Pick }> const ModelInfo: FC = ({ model }) => { const { t } = useTranslation() const modelName = model.name - const provideName = model.provider as any + const providerName = model.provider const { currentModel, currentProvider } = useTextGenerationCurrentProviderAndModelAndModelList({ - provider: provideName, + provider: providerName, model: modelName, }) const [open, setOpen] = React.useState(false) - const getParamValue = (param: string) => { - const value = model.completion_params?.[param] || '-' + const getParamValue = (param: keyof typeof PARAM_MAP) => { + const value = model.completion_params?.[param] ?? '-' if (param === 'stop') { if (Array.isArray(value)) return value.join(',') else return '-' } - return value + return typeof value === 'string' || typeof value === 'number' ? value : '-' } return (
- +
@@ -75,12 +76,12 @@ const ModelInfo: FC = ({ model }) => { {t(($) => $['detail.modelParams'], { ns: 'appLog' })}
- {['temperature', 'top_p', 'presence_penalty', 'max_tokens', 'stop'].map( - (param: string, index: number) => { + {(['temperature', 'top_p', 'presence_penalty', 'max_tokens', 'stop'] as const).map( + (param) => { return ( -
+
- {PARAM_MAP[param as keyof typeof PARAM_MAP]} + {PARAM_MAP[param]} {getParamValue(param)} diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index a8404eb8cda..34cc4225f3a 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -57,7 +57,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: () =>
Model Selector
, + ModelSelector: () =>
Model Selector
, })) vi.mock('@/service/use-common', () => ({ diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx index 4e36c406efe..71796e9f1c8 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx @@ -23,22 +23,18 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - defaultModel, - onSelect, + ModelSelector: ({ + value, + onValueChange, }: { - defaultModel?: { provider: string; model: string } - onSelect: (val: { provider: string; model: string }) => void + value?: { provider: string; model: string } + onValueChange: (val: { provider: string; model: string }) => void }) => ( -
+
Model Selector diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx index 3178e109f70..dce430294ec 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx @@ -82,7 +82,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: () =>
Model Selector
, + ModelSelector: () =>
Model Selector
, })) const defaultFeatures: Features = { diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx index f7d483dd96e..fb48515d053 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx @@ -9,7 +9,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { ANNOTATION_DEFAULT } from '@/config' import { Item } from './config-param' import ScoreSlider from './score-slider' @@ -115,14 +115,14 @@ const ConfigParamModal: FC = ({ >
{ + models={embeddingsModelList} + onValueChange={(val) => { setEmbeddingModel({ providerName: val.provider, modelName: val.model, diff --git a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx index f29f5db525a..a191c70bcc8 100644 --- a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx @@ -33,20 +33,17 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - onSelect, - defaultModel, + ModelSelector: ({ + onValueChange, + value, }: { - onSelect: (v: { provider: string; model: string }) => void - defaultModel?: { provider: string; model: string } + onValueChange: (v: { provider: string; model: string }) => void + value?: { provider: string; model: string } }) => ( -
+
@@ -853,8 +850,8 @@ describe('RetrievalParamConfig', () => { }) }) - describe('Model Selector Default Model', () => { - it('should pass correct default model to ModelSelector', () => { + describe('Model Selector Value', () => { + it('should pass the selected value to ModelSelector', () => { const config = createDefaultConfig({ reranking_enable: true, reranking_model: { @@ -871,9 +868,9 @@ describe('RetrievalParamConfig', () => { ) const modelSelector = screen.getByTestId('model-selector') - const defaultModel = JSON.parse(modelSelector.getAttribute('data-default-model') || '{}') - expect(defaultModel.provider).toBe('custom-provider') - expect(defaultModel.model).toBe('custom-model') + const value = JSON.parse(modelSelector.getAttribute('data-value') || '{}') + expect(value.provider).toBe('custom-provider') + expect(value.model).toBe('custom-model') }) }) }) diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx index 741d76b5b42..fffe12b9bb3 100644 --- a/web/app/components/datasets/common/retrieval-param-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx @@ -19,7 +19,7 @@ import { useCurrentProviderAndModel, useModelListAndDefaultModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import ProgressIndicator from '../../create/assets/progress-indicator.svg' @@ -139,14 +139,14 @@ const RetrievalParamConfig: FC = ({ {value.reranking_enable && ( <> { + models={rerankModelList} + onValueChange={(v) => { if (disabled) return onChange({ ...value, @@ -156,7 +156,7 @@ const RetrievalParamConfig: FC = ({ }, }) }} - readonly={disabled} + disabled={disabled} /> {showMultiModalTip && (
@@ -272,14 +272,14 @@ const RetrievalParamConfig: FC = ({ {value.reranking_mode !== RerankingModeEnum.WeightedScore && ( <> { + models={rerankModelList} + onValueChange={(v) => { if (disabled) return onChange({ ...value, @@ -289,7 +289,7 @@ const RetrievalParamConfig: FC = ({ }, }) }} - readonly={disabled} + disabled={disabled} /> {showMultiModalTip && (
diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index e7ec1c0762a..6913fa65dfc 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -227,15 +227,17 @@ vi.mock('@/app/components/datasets/settings/utils', () => ({ // Mock complex child components to avoid deep dependency chains when rendering StepTwo vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - onSelect, - readonly, + ModelSelector: ({ + onValueChange, + disabled, }: { - onSelect?: (val: Record) => void - readonly?: boolean + onValueChange?: (val: Record) => void + disabled?: boolean }) => ( -
-
@@ -2526,7 +2528,7 @@ describe('StepTwo Component', () => { render() // isModelAndRetrievalConfigDisabled should be true const modelSelector = screen.getByTestId('model-selector') - expect(modelSelector)!.toHaveAttribute('data-readonly', 'true') + expect(modelSelector)!.toHaveAttribute('data-disabled', 'true') }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx index a43a6ec2232..32d20b4c5a2 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -54,15 +54,17 @@ vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', ( })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - onSelect, - readonly, + ModelSelector: ({ + onValueChange, + disabled, }: { - onSelect?: (val: Record) => void - readonly?: boolean + onValueChange?: (val: Record) => void + disabled?: boolean }) => ( -
-
@@ -160,9 +162,9 @@ describe('IndexingModeSection', () => { expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() }) - it('should mark model selector as readonly when disabled', () => { + it('should disable model selector when disabled', () => { render() - expect(screen.getByTestId('model-selector'))!.toHaveAttribute('data-readonly', 'true') + expect(screen.getByTestId('model-selector'))!.toHaveAttribute('data-disabled', 'true') }) it('should call onEmbeddingModelChange when model selected', () => { diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index 1cf5d537516..8f0e02a4ee5 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -25,7 +25,7 @@ import { MultimodalRetrievalGuidanceLearnMore, } from '@/app/components/datasets/common/multimodal-retrieval-guidance' import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useDocLink } from '@/context/i18n' import { ChunkingMode } from '@/models/datasets' import Link from '@/next/link' @@ -207,11 +207,10 @@ export const IndexingModeSection: FC = ({ className="mb-2" /> {isModelAndRetrievalConfigDisabled && (
diff --git a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx index 48dc4f4b705..e366e4e69bc 100644 --- a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx +++ b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx @@ -19,20 +19,20 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () // Mock ModelSelector (external component from header module) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - onSelect, - readonly, - defaultModel, + ModelSelector: ({ + onValueChange, + disabled, + value, }: { - onSelect?: (val: Record) => void - readonly?: boolean - defaultModel?: { model?: string } + onValueChange?: (val: Record) => void + disabled?: boolean + value?: { model?: string } }) => ( -
- {defaultModel?.model || 'none'} +
+ {value?.model || 'none'} @@ -177,7 +177,7 @@ describe('SummaryIndexSetting', () => { }) describe('readonly mode', () => { - it('should pass readonly to model selector in knowledge-base entry', () => { + it('should disable model selector in knowledge-base entry', () => { render( { readonly />, ) - expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true') + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-disabled', 'true') }) it('should disable textarea in readonly mode', () => { @@ -202,7 +202,7 @@ describe('SummaryIndexSetting', () => { }) describe('model config derivation', () => { - it('should pass correct defaultModel when provider and model are set', () => { + it('should pass the selected value when provider and model are set', () => { render( { expect(screen.getByTestId('current-model')).toHaveTextContent('claude-3') }) - it('should pass undefined defaultModel when provider is missing', () => { + it('should pass an undefined value when provider is missing', () => { render() expect(screen.getByTestId('current-model')).toHaveTextContent('none') }) diff --git a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx index 1ad8f493e95..04349eea7a0 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx @@ -64,21 +64,21 @@ vi.mock('@/app/components/datasets/settings/index-method', () => ({ })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - defaultModel, - onSelect, + ModelSelector: ({ + value, + onValueChange, }: { - defaultModel?: DefaultModel - onSelect?: (value: DefaultModel) => void + value?: DefaultModel + onValueChange?: (value: DefaultModel) => void }) => (
diff --git a/web/app/components/datasets/settings/form/components/indexing-section.tsx b/web/app/components/datasets/settings/form/components/indexing-section.tsx index f6a70f509d2..109a88af28a 100644 --- a/web/app/components/datasets/settings/form/components/indexing-section.tsx +++ b/web/app/components/datasets/settings/form/components/indexing-section.tsx @@ -14,7 +14,7 @@ import { MultimodalRetrievalGuidanceLearnMore, } from '@/app/components/datasets/common/multimodal-retrieval-guidance' import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useDocLink } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { ChunkingMode } from '@/models/datasets' @@ -167,10 +167,10 @@ const IndexingSection = ({ className="mb-2" />
diff --git a/web/app/components/datasets/settings/summary-index-setting.tsx b/web/app/components/datasets/settings/summary-index-setting.tsx index c9f97dc8c36..8c524d4a697 100644 --- a/web/app/components/datasets/settings/summary-index-setting.tsx +++ b/web/app/components/datasets/settings/summary-index-setting.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' type SummaryIndexSettingProps = { entry?: 'knowledge-base' | 'dataset-settings' | 'create-document' @@ -87,15 +87,15 @@ const SummaryIndexSetting = ({ {t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })}
@@ -156,17 +156,16 @@ const SummaryIndexSetting = ({
@@ -215,17 +214,16 @@ const SummaryIndexSetting = ({ {t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })}
diff --git a/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx b/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx index 78c0db31679..52b22d7c5a1 100644 --- a/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx @@ -80,7 +80,7 @@ const renderSettingsModal = (searchParams = '', children?: React.ReactNode) => { } describe('SettingsModal', () => { - it('opens account settings with push and closes them with replace', async () => { + it('opens and closes account settings with shallow replace updates', async () => { const user = userEvent.setup() const { onUrlUpdate } = renderSettingsModal('', ) @@ -91,8 +91,8 @@ describe('SettingsModal', () => { ).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES) expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('settings')).toBe('preferences') expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({ - history: 'push', - shallow: false, + history: 'replace', + shallow: true, }) await user.click(screen.getByRole('button', { name: 'cancel account setting' })) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 918f4f75f6f..fdda8876663 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -10,7 +10,7 @@ import type { import { act, renderHook } from '@testing-library/react' import { useLocale } from '@/context/i18n' import { consoleQuery } from '@/service/client' -import { fetchDefaultModal, fetchModelList } from '@/service/common' +import { fetchDefaultModal } from '@/service/common' import { ConfigurationMethodEnum, CurrentSystemQuotaTypeEnum, @@ -56,19 +56,12 @@ vi.mock('@/service/common', () => ({ vi.mock('@/service/use-common', () => ({ commonQueryKeys: { - modelList: (type: string) => ['model-list', type], modelProviders: ['model-providers'], modelProviderDetails: ['model-provider-details'], defaultModel: (type: string) => ['default-model', type], }, })) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(() => ({ - textGenerationModelList: [], - })), -})) - vi.mock('@/context/modal-context', () => ({ useModalContextSelector: vi.fn((selector) => { const state = { setShowModelModal: vi.fn() } @@ -94,12 +87,20 @@ vi.mock('../atoms', () => ({ })) const { useQuery, useQueryClient } = await import('@tanstack/react-query') -const { useProviderContext } = await import('@/context/provider-context') const { useModalContextSelector } = await import('@/context/modal-context') const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks') const { useExpandModelProviderList } = await import('../atoms') +const getModelListQueryKey = (modelType: ModelTypeEnum) => + consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ + input: { + params: { + model_type: modelType, + }, + }, + }) + describe('hooks', () => { beforeEach(() => { vi.clearAllMocks() @@ -259,7 +260,7 @@ describe('hooks', () => { { provider: 'anthropic', models: [{ model: 'claude-3' }] }, ] - it('should fetch model list successfully', async () => { + it('should use the generated model list key and expose the result', () => { const refetch = vi.fn() ;(useQuery as Mock).mockReturnValue({ data: { data: mockModelData }, @@ -271,15 +272,11 @@ describe('hooks', () => { expect(result.current.data).toEqual(mockModelData) expect(result.current.isLoading).toBe(false) - - // Coverage for queryFn - const queryCall = (useQuery as Mock).mock.calls.find( - (call) => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'model-list', + expect(useQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), + }), ) - if (queryCall) { - await queryCall[0].queryFn() - expect(fetchModelList).toHaveBeenCalled() - } }) it('should return empty array when data is undefined', () => { @@ -306,7 +303,7 @@ describe('hooks', () => { expect(useQuery).toHaveBeenCalledWith( expect.objectContaining({ enabled: false, - queryKey: ['model-list', ModelTypeEnum.textEmbedding], + queryKey: getModelListQueryKey(ModelTypeEnum.textEmbedding), }), ) }) @@ -548,8 +545,10 @@ describe('hooks', () => { it('should return all text generation model lists', () => { const modelList = createModelList() - ;(useProviderContext as Mock).mockReturnValue({ - textGenerationModelList: modelList, + ;(useQuery as Mock).mockReturnValue({ + data: { data: modelList }, + isPending: false, + refetch: vi.fn(), }) const defaultModel = { provider: 'openai', model: 'gpt-4' } @@ -564,8 +563,10 @@ describe('hooks', () => { it('should filter active models correctly', () => { const modelList = createModelList() - ;(useProviderContext as Mock).mockReturnValue({ - textGenerationModelList: modelList, + ;(useQuery as Mock).mockReturnValue({ + data: { data: modelList }, + isPending: false, + refetch: vi.fn(), }) const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList()) @@ -576,8 +577,10 @@ describe('hooks', () => { it('should find current provider and model', () => { const modelList = createModelList() - ;(useProviderContext as Mock).mockReturnValue({ - textGenerationModelList: modelList, + ;(useQuery as Mock).mockReturnValue({ + data: { data: modelList }, + isPending: false, + refetch: vi.fn(), }) const defaultModel = { provider: 'openai', model: 'gpt-4' } @@ -590,8 +593,10 @@ describe('hooks', () => { }) it('should handle empty model list', () => { - ;(useProviderContext as Mock).mockReturnValue({ - textGenerationModelList: [], + ;(useQuery as Mock).mockReturnValue({ + data: { data: [] }, + isPending: false, + refetch: vi.fn(), }) const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList()) @@ -708,7 +713,7 @@ describe('hooks', () => { }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textGeneration], + queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), }) }) @@ -1081,10 +1086,10 @@ describe('hooks', () => { queryKey: ['model-provider-details'], }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textGeneration], + queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textEmbedding], + queryKey: getModelListQueryKey(ModelTypeEnum.textEmbedding), }) }) @@ -1121,7 +1126,7 @@ describe('hooks', () => { refetchType: 'active', }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textGeneration], + queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), }) }) @@ -1200,11 +1205,11 @@ describe('hooks', () => { result.current.handleRefreshModel(provider, customFields, true) }) - // When __model_type is undefined, all supported model types are invalidated. - const modelListCalls = invalidateQueries.mock.calls.filter( - (call) => call[0]?.queryKey?.[0] === 'model-list', - ) - expect(modelListCalls).toHaveLength(provider.supported_model_types.length) + provider.supported_model_types.forEach((modelType) => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: getModelListQueryKey(modelType), + }) + }) }) it('should handle provider with single model type', () => { @@ -1229,10 +1234,10 @@ describe('hooks', () => { queryKey: ['model-provider-details'], }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textGeneration], + queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), }) expect(invalidateQueries).not.toHaveBeenCalledWith({ - queryKey: ['model-list', ModelTypeEnum.textEmbedding], + queryKey: getModelListQueryKey(ModelTypeEnum.textEmbedding), }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts b/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts index 874eaefb8fa..91b7782a812 100644 --- a/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts +++ b/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts @@ -1,5 +1,3 @@ -import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' -import type { Model, ModelItem, ModelProvider } from './declarations' import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state' import { ModelStatusEnum } from './declarations' @@ -29,8 +27,8 @@ export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = { export const deriveModelStatus = ( modelId: string | undefined, providerName: string | undefined, - currentModelProvider: ModelProvider | ModelProviderSummaryResponse | Model | undefined, - currentModel: ModelItem | undefined, + currentModelProvider: object | undefined, + currentModel: { status: string } | undefined, credentialState: CredentialPanelState, ): DerivedModelStatus => { if (!modelId || !providerName) return 'empty' diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 5edc03781ec..cc6255a768c 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -9,7 +9,6 @@ import type { Model, ModelModalModeEnum, ModelProvider, - ModelTypeEnum, } from './declarations' import type { ModelModalType } from '@/context/modal-context' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -21,12 +20,11 @@ import { import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useLocale } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' -import { useProviderContext } from '@/context/provider-context' import { consoleQuery } from '@/service/client' import { fetchDefaultModal, fetchModelList } from '@/service/common' import { commonQueryKeys, modelProviderDetailsQueryOptions } from '@/service/use-common' import { useExpandModelProviderList } from './atoms' -import { CustomConfigurationStatusEnum, ModelStatusEnum } from './declarations' +import { CustomConfigurationStatusEnum, ModelStatusEnum, ModelTypeEnum } from './declarations' type UseDefaultModelAndModelList = ( defaultModel: DefaultModelResponse | undefined, @@ -83,7 +81,13 @@ type UseModelListOptions = { export const useModelList = (type: ModelTypeEnum, { enabled = true }: UseModelListOptions = {}) => { const { data, refetch, isPending } = useQuery({ - queryKey: commonQueryKeys.modelList(type), + queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ + input: { + params: { + model_type: type, + }, + }, + }), queryFn: () => fetchModelList(`/workspaces/current/models/model-types/${type}`), enabled, }) @@ -108,9 +112,20 @@ export const useDefaultModel = (type: ModelTypeEnum) => { } } -export const getCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => { +type ModelFromProvider = TProvider extends { models: Array } + ? TModel + : never + +export const getCurrentProviderAndModel = < + TProvider extends { models: Array<{ model: string }>; provider: string }, +>( + modelList: TProvider[], + defaultModel?: DefaultModel, +) => { const currentProvider = modelList.find((provider) => provider.provider === defaultModel?.provider) - const currentModel = currentProvider?.models.find((model) => model.model === defaultModel?.model) + const currentModel = currentProvider?.models.find( + (model) => model.model === defaultModel?.model, + ) as ModelFromProvider | undefined return { currentProvider, @@ -123,7 +138,7 @@ export { getCurrentProviderAndModel as useCurrentProviderAndModel } export const useTextGenerationCurrentProviderAndModelAndModelList = ( defaultModel?: DefaultModel, ) => { - const { textGenerationModelList } = useProviderContext() + const { data: textGenerationModelList } = useModelList(ModelTypeEnum.textGeneration) const activeTextGenerationModelList = textGenerationModelList.filter( (model) => model.status === ModelStatusEnum.active, ) @@ -170,7 +185,15 @@ export const useUpdateModelList = () => { const updateModelList = useCallback( (type: ModelTypeEnum | ModelType) => { - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelList(type) }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ + input: { + params: { + model_type: type, + }, + }, + }), + }) }, [queryClient], ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx index df01108bf1c..647d089dd8e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx @@ -7,14 +7,14 @@ type ModelBadgeProps = { } const ModelBadge: FC = ({ className, children }) => { return ( -
{children} -
+ ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx index c1f2c76cbc8..5302f0aaa8b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx @@ -37,14 +37,14 @@ const ModelIcon: FC = ({ modelName?.startsWith('o') ) return ( -
+ -
+ ) if (iconUrl) { return ( -
= ({ src={iconUrl} width={20} /> -
+ ) } return ( -
-
+ -
-
+ + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index d08c98eafff..df28ecac295 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -65,7 +65,6 @@ type FormProps< validatedSuccess?: boolean showOnVariableMap: Record isEditMode: boolean - isAgentStrategy?: boolean readonly?: boolean inputClassName?: string isShowDefaultValue?: boolean @@ -100,7 +99,6 @@ function Form< validatedSuccess, showOnVariableMap, isEditMode, - isAgentStrategy = false, readonly, inputClassName, isShowDefaultValue = false, @@ -449,7 +447,6 @@ function Form< popupClassName="w-[387px]!" isAdvancedMode isInWorkflow - isAgentStrategy={isAgentStrategy} value={value[variable]} setModel={(model) => handleModelChanged(variable, model)} readonly={readonly} diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx index 408b0b7cebf..95800ce8d65 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx @@ -40,7 +40,6 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ default: (props: { setModel: (model: { model: string; model_type: string }) => void - isAgentStrategy?: boolean readonly?: boolean }) => { modelSelectorPropsSpy(props) @@ -1666,38 +1665,6 @@ describe('Form', () => { ) }) - it('should pass isAgentStrategy to modelSelector', () => { - modelSelectorPropsSpy.mockClear() - const formSchemas: AnyFormSchema[] = [ - createTextSchema({ - variable: 'model_sel', - type: FormTypeEnum.modelSelector, - label: createI18n('Model Selector'), - }), - ] - const value: FormValue = { model_sel: '' } - - render( -
, - ) - - expect(screen.getByText('Select Model'))!.toBeInTheDocument() - expect(modelSelectorPropsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - isAgentStrategy: true, - }), - ) - }) - it('should use empty array fallback for multiToolSelector when value is null', () => { // Arrange const formSchemas: AnyFormSchema[] = [ diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx index c7d996ffcb9..4bde5625748 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx @@ -40,6 +40,12 @@ describe('ModelName', () => { }) describe('rendering', () => { + it('should render nothing while the model is unavailable', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + it('should render the localized model label when translation exists', () => { mockLocale = 'zh-Hans' const modelItem = createModelItem() diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx index cb702309c4a..2616ac7787f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx @@ -1,13 +1,14 @@ import type { FC, PropsWithChildren } from 'react' -import type { ModelItem } from '../declarations' +import type { ModelSelectorModel } from '../model-selector/types' import { cn } from '@langgenius/dify-ui/cn' +import { renderI18nObject } from '@/i18n-config' import { useLanguage } from '../hooks' import ModelBadge from '../model-badge' import FeatureIcon from '../model-selector/feature-icon' import { modelTypeFormat, sizeFormat } from '../utils' type ModelNameProps = PropsWithChildren<{ - modelItem: ModelItem + modelItem?: ModelSelectorModel className?: string nameClassName?: string showModelType?: boolean @@ -36,20 +37,20 @@ const ModelName: FC = ({ const language = useLanguage() if (!modelItem) return null + + const label = renderI18nObject(modelItem.label, language) + return ( -
-
- {modelItem.label[language] || modelItem.label.en_US} -
-
+ + {label} + + {!!(showModelType && modelItem.model_type) && ( {modelTypeFormat(modelItem.model_type)} @@ -72,9 +73,9 @@ const ModelName: FC = ({ showFeaturesLabel={showFeaturesLabel} /> ))} -
+ {children} -
+ ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx deleted file mode 100644 index 556739641d3..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { MouseEvent } from 'react' -import type { ModelProvider } from '../../declarations' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { vi } from 'vitest' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { - CurrentSystemQuotaTypeEnum, - CustomConfigurationStatusEnum, - ModelTypeEnum, - QuotaUnitEnum, -} from '../../declarations' -import AgentModelTrigger from '../agent-model-trigger' - -let modelProviders: ModelProvider[] = [] -let pluginInfo: { latest_package_identifier: string } | null = null -let pluginLoading = false -let inModelList = true -const invalidateInstalledPluginList = vi.fn() -const handleOpenModal = vi.fn() -const updateModelProviders = vi.fn() -const updateModelList = vi.fn() -const loadProviderDetail = vi.fn() -let isLoadingProviderDetail = false - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - modelProviders, - }), -})) - -vi.mock('@/service/use-plugins', () => ({ - useInvalidateInstalledPluginList: () => invalidateInstalledPluginList, - useModelInList: () => ({ data: inModelList }), - usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }), -})) - -vi.mock('../../hooks', () => ({ - useLazyModelProviderDetail: () => ({ - loadProviderDetail, - isLoadingProviderDetail, - }), - useModelModalHandler: () => handleOpenModal, - useUpdateModelList: () => updateModelList, - useUpdateModelProviders: () => updateModelProviders, -})) - -vi.mock('../../model-icon', () => ({ - default: () =>
Icon
, -})) - -vi.mock('../model-display', () => ({ - default: () =>
ModelDisplay
, -})) - -vi.mock('../status-indicators', () => ({ - default: () =>
StatusIndicators
, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ - InstallPluginButton: ({ - onClick, - onSuccess, - }: { - onClick: (event: MouseEvent) => void - onSuccess: () => void - }) => ( - - ), -})) - -describe('AgentModelTrigger', () => { - beforeEach(() => { - vi.clearAllMocks() - modelProviders = [] - pluginInfo = null - pluginLoading = false - inModelList = true - isLoadingProviderDetail = false - }) - - it('should render loading state when plugin info is still fetching', () => { - pluginLoading = true - render() - expect(screen.getByRole('status')).toBeInTheDocument() - }) - - it('should render model actions for configured provider', () => { - modelProviders = [ - { - provider: 'openai', - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: true, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [ - { - quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_unit: QuotaUnitEnum.times, - quota_limit: 10, - quota_used: 1, - last_used: 1, - is_valid: true, - }, - ], - }, - }, - ] as unknown as ModelProvider[] - render() - expect(screen.getByText('ModelDisplay')).toBeInTheDocument() - expect(screen.getByText('StatusIndicators')).toBeInTheDocument() - }) - - it('should support plugin installation flow when provider is missing', () => { - pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' } - render( - , - ) - - fireEvent.click(screen.getByText('Install Plugin')) - expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) - expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts) - expect(updateModelProviders).toHaveBeenCalledTimes(1) - expect(invalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model) - }) - - it('should not render the install action when the plugin has no package identifier', () => { - pluginInfo = { latest_package_identifier: '' } - - render() - - expect(screen.queryByText('Install Plugin')).not.toBeInTheDocument() - }) - - it('should show configuration action when provider requires setup', () => { - modelProviders = [ - { - provider: 'openai', - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [], - }, - }, - ] as unknown as ModelProvider[] - - render() - - expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument() - }) - - it('should load complete provider detail before opening configuration', async () => { - const providerDetail = { provider: 'openai' } as ModelProvider - modelProviders = [{ provider: 'openai', is_configured: false }] as unknown as ModelProvider[] - loadProviderDetail.mockResolvedValue(providerDetail) - - render() - fireEvent.click(screen.getByRole('button', { name: /notAuthorized/ })) - - await waitFor(() => { - expect(handleOpenModal).toHaveBeenCalledWith(providerDetail, 'predefined-model', undefined) - }) - }) - - it('should render unconfigured state when model is not selected', () => { - render() - expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument() - }) -}) 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 4fcd4417a8d..3be0d8210e0 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 @@ -92,22 +92,35 @@ vi.mock('../parameter-item', () => ({ ), })) -vi.mock('../../model-selector', () => ({ - default: ({ +vi.mock('../../model-selector', () => { + const ModelSelector = ({ onHide, - onSelect, + onValueChange, }: { - onHide: () => void - onSelect: (value: { provider: string; model: string }) => void + onHide?: () => void + onValueChange: (value: { provider: string; model: string; plugin_id?: string }) => void }) => (
- - + {onHide && }
- ), -})) + ) + + return { + ModelSelector, + SplitModelSelector: ModelSelector, + } +}) vi.mock('../presets-parameter', () => ({ default: ({ @@ -218,6 +231,7 @@ describe('ModelParameterModal', () => { expect(defaultProps.setModel).toHaveBeenCalledWith({ modelId: 'gpt-4.1', provider: 'openai', + plugin_id: 'langgenius/openai', mode: 'chat', features: ['vision', 'tool-call'], }) @@ -235,6 +249,21 @@ describe('ModelParameterModal', () => { expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled() }) + it('should disable model settings for an incompatible model without disabling selection', () => { + render( false} />) + + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled() + }) + + it('should disable model settings when the selected model is inactive', () => { + currentModel = { ...currentModel!, status: 'disabled' } + render() + + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled() + }) + it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => { render() openSettings() @@ -291,6 +320,7 @@ describe('ModelParameterModal', () => { expect(defaultProps.setModel).toHaveBeenCalledWith({ modelId: 'gpt-4.1', provider: 'openai', + plugin_id: 'langgenius/openai', mode: 'chat', features: ['vision', 'tool-call'], }) @@ -356,23 +386,22 @@ describe('ModelParameterModal', () => { expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true') }) - it('should support custom triggers, workflow mode, and missing default model values', async () => { + it('should support a custom trigger element and missing default model values', async () => { render( {open ? 'Custom Open' : 'Custom Closed'}} + trigger={} />, ) - const trigger = screen.getByText('Custom Closed').closest('button') + const trigger = screen.getByText('Custom Trigger').closest('button') expect(trigger).not.toHaveAttribute('data-popup-open') - fireEvent.click(screen.getByText('Custom Closed')) + fireEvent.click(screen.getByText('Custom Trigger')) - expect(screen.getByText('Custom Open')).toBeInTheDocument() expect(trigger).toHaveAttribute('data-popup-open', '') expect(screen.getByTestId('model-selector')).toBeInTheDocument() @@ -408,13 +437,12 @@ describe('ModelParameterModal', () => { render( {open ? 'Popup Open' : 'Popup Closed'}} + trigger={} />, ) - fireEvent.click(screen.getByText('Popup Closed')) + fireEvent.click(screen.getByText('Custom Trigger')) - expect(screen.getByText('Popup Open')).toBeInTheDocument() expect(screen.getByTestId('model-selector')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/model-settings-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/model-settings-trigger.spec.tsx new file mode 100644 index 00000000000..d6b9a1dcff1 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/model-settings-trigger.spec.tsx @@ -0,0 +1,31 @@ +import { Popover } from '@langgenius/dify-ui/popover' +import { render, screen } from '@testing-library/react' +import { ModelSettingsTrigger } from '../model-settings-trigger' + +describe('ModelSettingsTrigger', () => { + it('should compose the popover behavior onto the final icon button', () => { + render( + + + , + ) + + const trigger = screen.getByRole('button', { + name: 'common.modelProvider.modelSettings', + }) + + expect(trigger.querySelector('button')).not.toBeInTheDocument() + }) + + it('should put the disabled state on the final icon button', () => { + render( + + + , + ) + + expect( + screen.getByRole('button', { name: 'common.modelProvider.modelSettings' }), + ).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx deleted file mode 100644 index 0ad3eae95a7..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { vi } from 'vitest' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { withSelectorKey } from '@/test/i18n-mock' -import StatusIndicators from '../status-indicators' - -let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }] -const mockUseInstalledPluginList = vi.fn((_options: unknown) => ({ - data: { plugins: installedPlugins }, -})) - -vi.mock('@/service/use-plugins', () => ({ - useInstalledPluginList: (options: unknown) => mockUseInstalledPluginList(options), -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ - SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => ( -
{`SwitchVersion:${uniqueIdentifier}`}
- ), -})) - -const t = withSelectorKey((key: string) => key, 'workflow') - -describe('StatusIndicators', () => { - beforeEach(() => { - vi.clearAllMocks() - installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }] - mockUseInstalledPluginList.mockReturnValue({ data: { plugins: installedPlugins } }) - }) - - const getPopoverTrigger = (name: string) => { - const trigger = screen.getByRole('button', { name }) - expect(trigger).toBeInTheDocument() - return trigger - } - - it('should render nothing when model is available and enabled', () => { - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({ - category: PluginCategoryEnum.model, - enabled: false, - }) - }) - - it('should render deprecated tooltip when provider model is disabled and in model list', async () => { - const user = userEvent.setup() - render( - , - ) - expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({ - category: PluginCategoryEnum.model, - enabled: false, - }) - - await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated')) - - expect( - await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated'), - ).toBeInTheDocument() - }) - - it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => { - const user = userEvent.setup() - render( - , - ) - expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({ - category: PluginCategoryEnum.model, - enabled: false, - }) - - await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title')) - - expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument() - }) - - it('should render switch plugin version when pluginInfo exists for disabled unsupported model', () => { - render( - , - ) - - expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument() - expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({ - category: PluginCategoryEnum.model, - enabled: true, - }) - }) - - it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => { - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({ - category: PluginCategoryEnum.model, - enabled: false, - }) - }) - - it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => { - installedPlugins = [] - - render( - , - ) - - expect(screen.getByText('SwitchVersion:')).toBeInTheDocument() - }) - - it('should render marketplace warning tooltip when provider is unavailable', async () => { - const user = userEvent.setup() - render( - , - ) - - await user.hover(getPopoverTrigger('nodes.agent.modelNotInMarketplace.title')) - - expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx deleted file mode 100644 index 44efcbc92c3..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import type { FC } from 'react' -import type { ModelItem, ModelProvider } from '../declarations' -import type { WorkflowTranslate } from './status-indicators' -import { cn } from '@langgenius/dify-ui/cn' -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' -import { useProviderContext } from '@/context/provider-context' -import { - useInvalidateInstalledPluginList, - useModelInList, - usePluginInfo, -} from '@/service/use-plugins' -import { ConfigurationMethodEnum, ModelTypeEnum } from '../declarations' -import { - useLazyModelProviderDetail, - useModelModalHandler, - useUpdateModelList, - useUpdateModelProviders, -} from '../hooks' -import ModelIcon from '../model-icon' -import ConfigurationButton from './configuration-button' -import ModelDisplay from './model-display' -import StatusIndicators from './status-indicators' - -type AgentModelTriggerProps = { - open?: boolean - disabled?: boolean - currentProvider?: ModelProvider - currentModel?: ModelItem - providerName?: string - modelId?: string - hasDeprecated?: boolean - scope?: string -} - -const AgentModelTrigger: FC = ({ - disabled, - currentProvider, - currentModel, - providerName, - modelId, - hasDeprecated, - scope, -}) => { - const { t } = useTranslation() - const translateWorkflow: WorkflowTranslate = (selector, options) => t(selector, options) - const { modelProviders } = useProviderContext() - const updateModelProviders = useUpdateModelProviders() - const updateModelList = useUpdateModelList() - const { modelProvider, needsConfiguration } = useMemo(() => { - const modelProvider = modelProviders.find((item) => item.provider === providerName) - const needsConfiguration = modelProvider ? !modelProvider.is_configured : false - return { - modelProvider, - needsConfiguration, - } - }, [modelProviders, providerName]) - const [installed, setInstalled] = useState(false) - const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - const handleOpenModal = useModelModalHandler() - const { loadProviderDetail, isLoadingProviderDetail } = useLazyModelProviderDetail( - providerName ?? '', - ) - - const { data: inModelList = false } = useModelInList(currentProvider, modelId) - const { data: pluginInfo, isLoading: isPluginLoading } = usePluginInfo(providerName) - - const handleConfigure = async () => { - if (!providerName) return - - const providerDetail = await loadProviderDetail() - if (!providerDetail) return - - handleOpenModal(providerDetail, ConfigurationMethodEnum.predefinedModel, undefined) - } - - if (modelId && isPluginLoading) return - - return ( -
- {modelId ? ( - <> - - - {needsConfiguration && ( - - )} - - {!installed && !modelProvider && pluginInfo?.latest_package_identifier && ( - e.stopPropagation()} - size="small" - uniqueIdentifier={pluginInfo.latest_package_identifier} - onSuccess={() => { - ;[ - ModelTypeEnum.textGeneration, - ModelTypeEnum.textEmbedding, - ModelTypeEnum.rerank, - ModelTypeEnum.moderation, - ModelTypeEnum.speech2text, - ModelTypeEnum.tts, - ].forEach((type: ModelTypeEnum) => { - if (scope?.includes(type)) updateModelList(type) - }) - updateModelProviders() - invalidateInstalledPluginList(PluginCategoryEnum.model) - setInstalled(true) - }} - /> - )} - {modelProvider && !disabled && !needsConfiguration && ( -
- -
- )} - - ) : ( - <> -
- - {t(($) => $['nodes.agent.configureModel'], { ns: 'workflow' })} - -
-
- -
- - )} -
- ) -} - -export default AgentModelTrigger diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx deleted file mode 100644 index efc2d519aac..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from '@langgenius/dify-ui/button' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { useTranslation } from 'react-i18next' -type ConfigurationButtonProps = { - loading: boolean - onConfigure: () => void -} - -const ConfigurationButton = ({ loading, onConfigure }: ConfigurationButtonProps) => { - const { t } = useTranslation() - return ( - - ) -} - -export default ConfigurationButton diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 35782ef4c0f..4e7e4e1984b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -1,9 +1,15 @@ -import type { FC, ReactNode } from 'react' -import type { DefaultModel, FormValue, Model, ModelParameterRule } from '../declarations' +import type { Placement } from '@langgenius/dify-ui/popover' +import type { ComponentPropsWithRef, FC, ReactElement } from 'react' +import type { FormValue, ModelParameterRule } from '../declarations' +import type { + ModelSelectorModelPredicate, + ModelSelectorProvider, + ModelSelectorValue, +} from '../model-selector/types' import type { ParameterValue } from './parameter-item' -import type { TriggerProps } from './types' import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { IconButton } from '@langgenius/dify-ui/icon-button' import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -11,40 +17,51 @@ import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arr import Loading from '@/app/components/base/loading' import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config' import { useModelParameterRules } from '@/service/use-common' +import { ModelStatusEnum } from '../declarations' import { useTextGenerationCurrentProviderAndModelAndModelList } from '../hooks' -import ModelSelector from '../model-selector' +import { ModelSelector, SplitModelSelector } from '../model-selector' +import { ModelSettingsTrigger } from './model-settings-trigger' import ParameterItem from './parameter-item' import PresetsParameter from './presets-parameter' import { getSupportedPresetConfig } from './presets-parameter-utils' export type ModelParameterModalProps = { + trigger?: ReactElement> popupClassName?: string + modelSelectorPopupClassName?: string + placement?: Placement isAdvancedMode: boolean modelId: string provider: string - setModel: (model: { - modelId: string - provider: string - mode?: string - features?: string[] - }) => void + setModel: ( + model: Omit & { + modelId: ModelSelectorValue['model'] + mode?: string + features?: string[] + }, + ) => void completionParams: FormValue onCompletionParamsChange: (newParams: FormValue) => void hideDebugWithMultipleModel?: boolean debugWithMultipleModel?: boolean onDebugWithMultipleModelChange?: () => void - renderTrigger?: (v: TriggerProps) => ReactNode readonly?: boolean modelSelectorReadonly?: boolean isInWorkflow?: boolean scope?: string nodesOutputVars?: NodeOutPutVar[] availableNodes?: Node[] - modelList?: Model[] + modelList?: ModelSelectorProvider[] + showModelMeta?: boolean + modelPredicate?: ModelSelectorModelPredicate + modelSuggestionPredicate?: ModelSelectorModelPredicate } const ModelParameterModal: FC = ({ + trigger, popupClassName, + modelSelectorPopupClassName, + placement, isAdvancedMode, modelId, provider, @@ -54,13 +71,15 @@ const ModelParameterModal: FC = ({ hideDebugWithMultipleModel, debugWithMultipleModel, onDebugWithMultipleModelChange, - renderTrigger, readonly, modelSelectorReadonly, isInWorkflow, nodesOutputVars, availableNodes, modelList, + showModelMeta, + modelPredicate, + modelSuggestionPredicate, }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -84,14 +103,15 @@ const ModelParameterModal: FC = ({ }) } - const handleChangeModel = ({ provider, model }: DefaultModel) => { + const handleChangeModel = ({ provider, model, plugin_id }: ModelSelectorValue) => { const targetProvider = selectableModelList.find((modelItem) => modelItem.provider === provider) const targetModelItem = targetProvider?.models.find((modelItem) => modelItem.model === model) setModel({ modelId: model, provider, + plugin_id, mode: targetModelItem?.model_properties.mode as string, - features: targetModelItem?.features || [], + features: [...(targetModelItem?.features ?? [])], }) } @@ -118,66 +138,45 @@ const ModelParameterModal: FC = ({ } const hasSelectedModel = !!provider && !!modelId + const canConfigureModelSettings = + !readonly && + hasSelectedModel && + !!currentProvider && + !!currentModel && + currentModel.status === ModelStatusEnum.active && + (modelPredicate?.(currentProvider, currentModel) ?? true) return ( { - if (readonly) return + if (readonly && newOpen) return setOpen(newOpen) }} > - {renderTrigger ? ( - ( - - )} - /> + {trigger ? ( + ) : ( -
-
- -
- $['modelProvider.modelSettings'], { ns: 'common' })} - disabled={readonly || !hasSelectedModel} - className={cn( - 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', - isInWorkflow && - 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - > - - +
+ +
)} @@ -185,18 +184,27 @@ const ModelParameterModal: FC = ({
{t(($) => $['modelProvider.modelSettings'], { ns: 'common' })}
- - - + $['operation.close'], { ns: 'common' })} + className="absolute top-2.5 right-2.5" + size="lg" + variant="default" + > + + + } + />
- {renderTrigger && ( + {trigger && (
setOpen(false)} />
@@ -205,7 +213,7 @@ const ModelParameterModal: FC = ({
@@ -250,15 +258,16 @@ const ModelParameterModal: FC = ({ )}
{!hideDebugWithMultipleModel && ( -
onDebugWithMultipleModelChange?.()} > {debugWithMultipleModel ? t(($) => $.debugAsSingleModel, { ns: 'appDebug' }) : t(($) => $.debugAsMultipleModel, { ns: 'appDebug' })} - -
+ + )} diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx deleted file mode 100644 index 2977f30c348..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import ModelName from '../model-name' - -type ModelDisplayProps = { - currentModel: any - modelId: string -} - -const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => { - return currentModel ? ( - - ) : ( -
-
- {modelId} -
-
- ) -} - -export default ModelDisplay diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-settings-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-settings-trigger.tsx new file mode 100644 index 00000000000..57d15d5a39c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-settings-trigger.tsx @@ -0,0 +1,42 @@ +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' + +type ModelSettingsTriggerProps = { + disabled?: boolean + surface?: 'default' | 'workflow' +} + +function ModelSettingsTrigger({ disabled, surface = 'default' }: ModelSettingsTriggerProps) { + const { t } = useTranslation() + const label = t(($) => $['modelProvider.modelSettings'], { ns: 'common' }) + + return ( + + + + + } + /> + } + /> + {label} + + ) +} + +export { ModelSettingsTrigger } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx deleted file mode 100644 index 79cbb036e53..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import type { SelectorParam } from 'i18next' -import type { ReactNode } from 'react' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' -import Link from '@/next/link' -import { useInstalledPluginList } from '@/service/use-plugins' - -export type WorkflowTranslate = ( - selector: SelectorParam<'workflow'>, - options: { ns: 'workflow' }, -) => string - -type StatusIndicatorsProps = { - needsConfiguration: boolean - modelProvider: boolean - inModelList: boolean - disabled: boolean - pluginInfo: any - t: WorkflowTranslate -} - -type StatusPopoverProps = { - ariaLabel: string - content: ReactNode - children: ReactNode -} - -const StatusPopover = ({ ariaLabel, content, children }: StatusPopoverProps) => ( - - e.stopPropagation()} - > - {children} - - - {content} - - -) - -const StatusIndicators = ({ - needsConfiguration, - modelProvider, - inModelList, - disabled, - pluginInfo, - t, -}: StatusIndicatorsProps) => { - const shouldLoadInstalledModelPlugins = - !needsConfiguration && modelProvider && disabled && !inModelList && !!pluginInfo - const { data: pluginList } = useInstalledPluginList({ - category: PluginCategoryEnum.model, - enabled: shouldLoadInstalledModelPlugins, - }) - const renderTooltipContent = ( - title: string, - description?: string, - linkText?: string, - linkHref?: string, - ) => { - return ( -
e.stopPropagation()} - > -
{title}
- {description && ( -
{description}
- )} - {linkText && linkHref && ( -
- { - e.stopPropagation() - }} - > - {linkText} - -
- )} -
- ) - } - // const installedPluginUniqueIdentifier = pluginList?.plugins.find(plugin => plugin.name === pluginInfo.name)?.plugin_unique_identifier - return ( - <> - {/* plugin installed and model is in model list but disabled */} - {/* plugin installed from github/local and model is not in model list */} - {!needsConfiguration && modelProvider && disabled && ( - <> - {inModelList ? ( - $['nodes.agent.modelSelectorTooltips.deprecated'], { - ns: 'workflow', - })} - content={t(($) => $['nodes.agent.modelSelectorTooltips.deprecated'], { - ns: 'workflow', - })} - > - - - ) : !pluginInfo ? ( - $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' })} - content={renderTooltipContent( - t(($) => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), - t(($) => $['nodes.agent.modelNotSupport.desc'], { ns: 'workflow' }), - t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), - '/plugins', - )} - > - - - ) : ( - $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), - t(($) => $['nodes.agent.modelNotSupport.descForVersionSwitch'], { ns: 'workflow' }), - )} - uniqueIdentifier={ - pluginList?.plugins.find((plugin) => plugin.name === pluginInfo.name) - ?.plugin_unique_identifier ?? '' - } - /> - )} - - )} - {!modelProvider && !pluginInfo && ( - $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' })} - content={renderTooltipContent( - t(($) => $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' }), - t(($) => $['nodes.agent.modelNotInMarketplace.desc'], { ns: 'workflow' }), - t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), - '/plugins', - )} - > - - - )} - - ) -} - -export default StatusIndicators diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts deleted file mode 100644 index 8bcc97b7cc5..00000000000 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Model, ModelItem, ModelProvider } from '../declarations' - -export type TriggerProps = { - open?: boolean - currentProvider?: ModelProvider | Model - currentModel?: ModelItem - providerName?: string - modelId?: string -} 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 30090bc8685..29aa27c4379 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 @@ -1,26 +1,36 @@ import type { ReactNode } from 'react' -import type { DefaultModel, Model, ModelItem } from '../../declarations' +import type { Model, ModelItem } from '../../declarations' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations' -import ModelSelector from '../index' +import { ModelSelector, SplitModelSelector } from '../index' -vi.mock('../model-selector-trigger', () => ({ - default: ({ - currentProvider, - currentModel, - defaultModel, - }: { - currentProvider?: Model - currentModel?: ModelItem - defaultModel?: DefaultModel - }) => { - if (currentProvider && currentModel) return
model-trigger
+const mockModelProviders = vi.hoisted(() => ({ current: [] as Model[] })) +const mockSetSettingsDestination = vi.hoisted(() => vi.fn()) - if (defaultModel) return
{`deprecated:${defaultModel.model}`}
+vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryState: () => [null, mockSetSettingsDestination], + } +}) - return
empty-trigger
- }, +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ modelProviders: mockModelProviders.current }), +})) +vi.mock('../../provider-added-card/use-credential-panel-state', () => ({ + useCredentialPanelState: () => ({ + variant: 'credits-active', + priority: 'credits', + supportsCredits: true, + showPrioritySwitcher: true, + hasCredentials: false, + isCreditsExhausted: false, + credentialName: undefined, + credits: 100, + }), })) vi.mock('../popup', async () => { @@ -32,15 +42,22 @@ vi.mock('../popup', async () => { default: ({ onConfigureEmptyState, onHide, + onOpenProviderSettings, }: { onConfigureEmptyState?: () => void onHide: () => void + onOpenProviderSettings?: () => void }) => ( <> select + {onOpenProviderSettings && ( + + )} {onConfigureEmptyState && ( -
- ), -})) - -describe('ModelSelector combobox branches', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should open and close through combobox trigger when editable', () => { - const onHide = vi.fn() - render() - - fireEvent.click(screen.getByRole('combobox')) - - expect(screen.getByText('open-editable')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'hide-popup' })) - - expect(screen.getByText('closed-editable')).toBeInTheDocument() - expect(onHide).toHaveBeenCalledTimes(1) - }) - - it('should ignore combobox open requests when readonly', () => { - render() - - fireEvent.click(screen.getByRole('combobox')) - - expect(screen.getByText('closed-readonly')).toBeInTheDocument() - }) -}) 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 dd3a9c372cb..46a4fb10564 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 @@ -2,7 +2,7 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/w import type { ReactElement } from 'react' import type { Model, ModelItem } from '../../declarations' import type { PopupProps } from '../popup' -import { Combobox } from '@langgenius/dify-ui/combobox' +import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' @@ -17,21 +17,6 @@ import Popup from '../popup' let mockLanguage = 'en_US' -const mockSearchParams = vi.hoisted(() => ({ - current: new URLSearchParams(), -})) -const mockSetSettingsDestination = vi.hoisted(() => vi.fn()) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: () => [mockSearchParams.current.get('settings'), mockSetSettingsDestination], - } -}) -vi.mock('@/next/navigation', () => ({ - useSearchParams: () => mockSearchParams.current, -})) - vi.mock( '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ @@ -108,6 +93,19 @@ function PopupHarness(props: PopupTestProps) { ) } +function PopupContentHarness(props: PopupTestProps) { + const [inputValue, setInputValue] = useState('') + + return ( + + Selected model + + + + + ) +} + const renderPopup = ( ui: ReactElement, options: Parameters[1] = {}, @@ -250,7 +248,6 @@ describe('Popup', () => { mockContextModelProviders.current = [] mockContextModelProviderPlugins.current = {} mockTrialModels.current = ['test-openai', 'test-anthropic'] - mockSearchParams.current = new URLSearchParams() Object.assign(mockTrialCredits, { credits: 200, totalCredits: 200, @@ -888,19 +885,20 @@ describe('Popup', () => { , ) const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' }) const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel') - const settingsButton = screen.getByRole('button', { + const settingsAction = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/, }) expect(scrollRegion)!.toBeInTheDocument() expect(scrollRegion).not.toContainElement(searchInput) - expect(scrollRegion).not.toContainElement(settingsButton) + expect(scrollRegion).not.toContainElement(settingsAction) expect(scrollRegion).toContainElement( screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'), ) @@ -1098,19 +1096,7 @@ describe('Popup', () => { expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument() }) - it('should open provider settings when clicking footer link', () => { - const onHide = vi.fn() - renderPopup() - - fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings')) - - expect(onHide).toHaveBeenCalled() - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') - }) - - it('should hide provider settings footer when provider settings are already open', () => { - mockSearchParams.current = new URLSearchParams('settings=provider') - + it('should hide the provider settings action when requested by the owner', () => { renderPopup() expect( @@ -1118,19 +1104,55 @@ describe('Popup', () => { ).not.toBeInTheDocument() }) - it('should hide provider settings footer when requested by the caller', () => { + it('should keep popup actions outside the model listbox and reachable by Tab', async () => { + const user = userEvent.setup() + const onConfigureEmptyState = vi.fn() + const onOpenProviderSettings = vi.fn() + renderPopup( - , + , + { + systemFeatures: { enable_marketplace: false }, + }, ) - expect( - screen.queryByText('common.modelProvider.selector.modelProviderSettings'), - ).not.toBeInTheDocument() + expect(await screen.findByRole('dialog', { name: 'Model selector' })).toBeInTheDocument() + + const searchInput = screen.getByRole('combobox', { + name: 'datasetSettings.form.searchModel', + }) + const configureButton = screen.getByRole('button', { + name: /modelProvider\.selector\.configure/, + }) + const providerSettingsButton = screen.getByRole('button', { + name: /common\.modelProvider\.selector\.modelProviderSettings/, + }) + const listbox = screen.getByRole('listbox') + + expect(listbox).not.toContainElement(configureButton) + expect(listbox).not.toContainElement(providerSettingsButton) + + await user.click(searchInput) + await user.tab() + expect(configureButton).toHaveFocus() + await user.tab() + expect(providerSettingsButton).toHaveFocus() }) it('should open provider settings from empty state when no providers are configured', () => { - const onHide = vi.fn() - renderPopup() + const onConfigureEmptyState = vi.fn() + renderPopup( + , + ) expect( screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/), @@ -1140,19 +1162,7 @@ describe('Popup', () => { )!.toBeInTheDocument() fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/)) - expect(onHide).toHaveBeenCalled() - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') - }) - - it('should only close the empty state selector when provider settings are already open', () => { - mockSearchParams.current = new URLSearchParams('settings=provider') - const onHide = vi.fn() - renderPopup() - - fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/)) - - expect(onHide).toHaveBeenCalled() - expect(mockSetSettingsDestination).not.toHaveBeenCalled() + expect(onConfigureEmptyState).toHaveBeenCalledTimes(1) }) it('should render marketplace providers that are not installed', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx index b1051ed1922..38005bba7e8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx @@ -5,7 +5,7 @@ import { ModelFeatureEnum, ModelFeatureTextEnum } from '../declarations' import ModelBadge from '../model-badge' type FeatureIconProps = { - feature: ModelFeatureEnum + feature: string className?: string showFeaturesLabel?: boolean } @@ -26,11 +26,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps + -
+ } /> @@ -57,11 +57,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps + -
+ } /> @@ -88,11 +88,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps + -
+ } /> @@ -119,11 +119,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps + -
+ } /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index 987ca1bdf5f..2554e4a3772 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -1,13 +1,21 @@ import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox' -import type { DefaultModel, Model, ModelFeatureEnum, ModelItem } from '../declarations' -import type { ModelSelectorModelPredicate, ModelSelectorValue } from './types' +import type { + ModelSelectorModel, + ModelSelectorModelPredicate, + ModelSelectorProvider, + ModelSelectorValue, +} from './types' import { cn } from '@langgenius/dify-ui/cn' -import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox' +import { Combobox, ComboboxContent } from '@langgenius/dify-ui/combobox' +import { useQueryState } from 'nuqs' import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { + settingsQueryParamName, + settingsQueryParser, +} from '@/app/components/header/account-setting/query-params' import { ModelStatusEnum } from '../declarations' import { getCurrentProviderAndModel } from '../hooks' -import ModelSelectorTrigger from './model-selector-trigger' +import { ModelSelectorTrigger } from './model-selector-trigger' import Popup from './popup' import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types' @@ -19,16 +27,15 @@ const getModelProviderPluginId = (provider: string) => { return provider ? `langgenius/${provider}` : '' } -type ModelSelectorProps = { - defaultModel?: DefaultModel - modelList: Model[] - triggerClassName?: string +type ModelSelectorBaseProps = { + value?: ModelSelectorValue + models: ModelSelectorProvider[] + className?: string popupClassName?: string - onSelect?: (model: DefaultModel) => void + onValueChange?: (model: ModelSelectorValue) => void onHide?: () => void - readonly?: boolean - scopeFeatures?: ModelFeatureEnum[] - deprecatedClassName?: string + disabled?: boolean + scopeFeatures?: readonly string[] showDeprecatedWarnIcon?: boolean hideProviderSettingsFooter?: boolean onConfigureEmptyState?: () => void @@ -37,16 +44,26 @@ type ModelSelectorProps = { modelPredicate?: ModelSelectorModelPredicate modelSuggestionPredicate?: ModelSelectorModelPredicate } -function ModelSelector({ - defaultModel, - modelList, - triggerClassName, +type ModelSelectorProps = ModelSelectorBaseProps & { + size?: 'small' | 'medium' + surface?: 'default' | 'workflow' +} +type SplitModelSelectorProps = ModelSelectorBaseProps & { + surface?: 'default' | 'workflow' +} + +function ModelSelectorRoot({ + value, + models, + className, popupClassName, - onSelect, + onValueChange, onHide, - readonly, + disabled, + size, + surface, + shape, scopeFeatures = [], - deprecatedClassName, showDeprecatedWarnIcon = true, hideProviderSettingsFooter, onConfigureEmptyState, @@ -54,11 +71,18 @@ function ModelSelector({ showModelMeta, modelPredicate, modelSuggestionPredicate, -}: ModelSelectorProps) { - const { t } = useTranslation() +}: ModelSelectorBaseProps & { + size: 'small' | 'medium' + surface: 'default' | 'workflow' + shape: 'standalone' | 'split' +}) { const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState('') - const { currentProvider, currentModel } = getCurrentProviderAndModel(modelList, defaultModel) + const [settingsDestination, setSettingsDestination] = useQueryState( + settingsQueryParamName, + settingsQueryParser, + ) + const { currentProvider, currentModel } = getCurrentProviderAndModel(models, value) const currentValue = useMemo(() => { if (!currentProvider || !currentModel) return null @@ -70,35 +94,46 @@ function ModelSelector({ const handleOpenChange = useCallback( (newOpen: boolean) => { - if (readonly) return + if (disabled && newOpen) return setOpen(newOpen) if (!newOpen) setInputValue('') }, - [readonly], + [disabled], ) const handleSelect = useCallback( - (provider: string, model: ModelItem) => { + (provider: string, model: ModelSelectorModel) => { setOpen(false) setInputValue('') - if (onSelect) { - onSelect({ + if (onValueChange) { + onValueChange({ provider, model: model.model, plugin_id: getModelProviderPluginId(provider), }) } }, - [onSelect], + [onValueChange], ) + const handleHide = useCallback(() => { + setOpen(false) + setInputValue('') + onHide?.() + }, [onHide]) + + const handleOpenSettings = useCallback(() => { + handleHide() + setSettingsDestination('provider') + }, [handleHide, setSettingsDestination]) + const handleValueChange = useCallback( (value: ModelSelectorValue | null) => { if (!value) return - const provider = modelList.find((model) => model.provider === value.provider) + const provider = models.find((model) => model.provider === value.provider) const model = provider?.models.find((model) => model.model === value.model) if (!provider || !model) return @@ -106,7 +141,7 @@ function ModelSelector({ handleSelect(provider.provider, model) }, - [handleSelect, modelList], + [handleSelect, models], ) const handleInputValueChange = useCallback( @@ -116,19 +151,23 @@ function ModelSelector({ [], ) - const handleHide = useCallback(() => { - setOpen(false) - setInputValue('') - onHide?.() - }, [onHide]) const handleConfigureEmptyState = useCallback(() => { - setOpen(false) - setInputValue('') - onConfigureEmptyState?.() - }, [onConfigureEmptyState]) + if (onConfigureEmptyState) { + handleHide() + onConfigureEmptyState() + return + } + if (settingsDestination === 'provider') { + handleHide() + return + } + + handleOpenSettings() + }, [handleHide, handleOpenSettings, onConfigureEmptyState, settingsDestination]) return ( + disabled={disabled} filter={null} inputValue={inputValue} isItemEqualToValue={isSameModelSelectorValue} @@ -139,43 +178,42 @@ function ModelSelector({ onOpenChange={handleOpenChange} onValueChange={handleValueChange} > - $['detailPanel.configureModel'], { ns: 'plugin' })} - icon={false} - className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent data-popup-open:bg-transparent" - disabled={readonly} - > - - + +} + +function SplitModelSelector({ surface = 'default', ...props }: SplitModelSelectorProps) { + return +} + +export { ModelSelector, SplitModelSelector } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts index 10e643352ea..4487fcdb2a5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts @@ -1,6 +1,10 @@ import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen' -import type { DefaultModel, ModelItem } from '../declarations' -import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types' +import type { + ModelSelectorModel, + ModelSelectorModelPredicate, + ModelSelectorProvider, + ModelSelectorValue, +} from './types' import Fuse from 'fuse.js' import { supportFunctionCall } from '@/utils/tool-call' import { ModelFeatureEnum } from '../declarations' @@ -28,11 +32,11 @@ type ModelSelectorSearchIndex = { type FilterModelSelectorModelsParams = { aiCreditVisibleProviders: Set - defaultModel?: DefaultModel + defaultModel?: ModelSelectorValue inputValue: string installedModelList: ModelSelectorProvider[] modelPredicate?: ModelSelectorModelPredicate - scopeFeatures: ModelFeatureEnum[] + scopeFeatures: readonly string[] searchIndex: ModelSelectorSearchIndex } @@ -79,7 +83,10 @@ const getProviderKeySearchValues = (provider: string) => { const createModelSearchKey = (provider: string, model: string) => `${provider}/${model}` -const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFeatureEnum[]) => { +const modelSupportsScopeFeatures = ( + modelItem: ModelSelectorModel, + scopeFeatures: readonly string[], +) => { if (scopeFeatures.length === 0) return true return scopeFeatures.every((feature) => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx index 2fe1494c55e..c7e55f5a7ff 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx @@ -1,8 +1,10 @@ -import type { DefaultModel, Model, ModelItem } from '../declarations' +import type { ModelSelectorModel, ModelSelectorProvider, ModelSelectorValue } from './types' import { cn } from '@langgenius/dify-ui/cn' +import { ComboboxTrigger } from '@langgenius/dify-ui/combobox' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useProviderContext } from '@/context/provider-context' +import { consoleQuery } from '@/service/client' import { DERIVED_MODEL_STATUS_BADGE_I18N, DERIVED_MODEL_STATUS_TOOLTIP_I18N, @@ -13,13 +15,14 @@ import ModelName from '../model-name' import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state' type ModelSelectorTriggerProps = { - currentProvider?: Model - currentModel?: ModelItem - defaultModel?: DefaultModel - open?: boolean - readonly?: boolean + currentProvider?: ModelSelectorProvider + currentModel?: ModelSelectorModel + defaultModel?: ModelSelectorValue + disabled?: boolean + size?: 'small' | 'medium' + surface?: 'default' | 'workflow' + shape?: 'standalone' | 'split' className?: string - deprecatedClassName?: string showDeprecatedWarnIcon?: boolean showModelMeta?: boolean isModelCompatible?: boolean @@ -29,39 +32,37 @@ function ModelSelectorTrigger({ currentProvider, currentModel, defaultModel, - open, - readonly, + disabled, + size = 'medium', + surface = 'default', + shape = 'standalone', className, - deprecatedClassName, showDeprecatedWarnIcon = true, showModelMeta = true, isModelCompatible = true, }: ModelSelectorTriggerProps) { const { t } = useTranslation() - const { modelProviders } = useProviderContext() const isSelected = !!currentProvider && !!currentModel const isDeprecated = !isSelected && !!defaultModel const isEmpty = !isSelected && !defaultModel - const selectedProvider = isSelected - ? modelProviders.find((provider) => provider.provider === currentProvider.provider) - : undefined - const deprecatedProvider = isDeprecated - ? modelProviders.find((p) => p.provider === defaultModel.provider) - : undefined - const resolvedProvider = isSelected ? selectedProvider : deprecatedProvider - const selectedProviderState = useCredentialPanelState(resolvedProvider) + const providerId = isSelected ? currentProvider.provider : defaultModel?.provider + const { data: resolvedProvider } = useQuery({ + ...consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions(), + enabled: !!providerId, + select: ({ data }) => data.find((provider) => provider.provider === providerId), + }) + const credentialPanel = useCredentialPanelState(resolvedProvider) const status = deriveModelStatus( isSelected ? currentModel?.model : defaultModel?.model, isSelected ? currentProvider?.provider : defaultModel?.provider, resolvedProvider, currentModel, - selectedProviderState, + credentialPanel, ) const isActive = status === 'active' - const isDisabled = status !== 'active' && status !== 'empty' const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] const tooltipI18nKey = @@ -80,101 +81,119 @@ function ModelSelectorTrigger({ statusLabel || t(($) => $['modelProvider.selector.incompatible'], { ns: 'common' }) const deprecatedTooltipLabel = tooltipLabel || t(($) => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }) + const triggerTooltipLabel = + isDeprecated && showDeprecatedWarnIcon + ? deprecatedTooltipLabel + : isSelected && ((!isActive && statusI18nKey) || !isModelCompatible) + ? tooltipLabel + : undefined return ( -
- {isEmpty ? ( -
-
- -
-
- ) : ( - - )} - -
- {isSelected && ( - - )} - {isDeprecated && ( -
- {defaultModel.model} -
- )} - {isEmpty && ( -
- {t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })} -
- )} - - {isSelected && !readonly && ((!isActive && statusI18nKey) || !isModelCompatible) && ( - - + $['detailPanel.configureModel'], { ns: 'plugin' })} + data-deprecated={isDeprecated ? '' : undefined} + data-model-status={status} + data-shape={shape} + data-size={size} + data-surface={surface} + disabled={disabled} + icon={!disabled && shape !== 'split' && (isActive || isEmpty) ? undefined : false} + size={size} + className={cn( + 'data-[size=small]:h-6 data-[size=small]:gap-px data-[size=small]:rounded-md data-[size=small]:p-0.5', + 'data-[size=medium]:h-8 data-[size=medium]:gap-0.5 data-[size=medium]:rounded-lg data-[size=medium]:p-1', + 'data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg data-[surface=workflow]:data-popup-open:bg-workflow-block-parma-bg', + 'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled data-[model-status=configure-required]:bg-components-input-bg-disabled data-[model-status=credits-exhausted]:bg-components-input-bg-disabled data-[model-status=disabled]:bg-components-input-bg-disabled data-[model-status=incompatible]:bg-components-input-bg-disabled', + 'data-disabled:data-[model-status=active]:bg-components-input-bg-normal! data-disabled:data-[model-status=empty]:bg-components-input-bg-normal! data-disabled:data-[surface=workflow]:bg-workflow-block-parma-bg!', + 'data-[shape=split]:relative data-[shape=split]:min-w-0 data-[shape=split]:flex-1 data-[shape=split]:rounded-l-lg! data-[shape=split]:rounded-r-none! data-[shape=split]:focus-visible:z-1', + 'data-[surface=workflow]:data-deprecated:[&>span]:opacity-50', + className, + )} + > + + {isEmpty ? ( + - - - {statusLabel} + + -
- } - /> - {tooltipLabel && {tooltipLabel}} - - )} + + ) : ( + + )} - {isDeprecated && showDeprecatedWarnIcon && ( - - - - - {deprecatedStatusLabel} + + {isSelected && ( + + )} + {isDeprecated && ( + + {defaultModel.model} -
- } - /> - {deprecatedTooltipLabel} - - )} + )} + {isEmpty && ( + + {t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })} + + )} - {!readonly && (isActive || isEmpty) && ( - - )} -
-
+ {isSelected && + !disabled && + ((!isActive && statusI18nKey) || !isModelCompatible) && ( + + + + {statusLabel} + + + )} + + {isDeprecated && showDeprecatedWarnIcon && ( + + + + {deprecatedStatusLabel} + + + )} + + + + } + /> + {triggerTooltipLabel && ( + {triggerTooltipLabel} + )} + ) } -export default ModelSelectorTrigger +export { ModelSelectorTrigger } 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 1445e4edaab..c51acca8702 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 @@ -1,6 +1,11 @@ +import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen' import type { ComponentProps } from 'react' -import type { DefaultModel, ModelItem } from '../declarations' -import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types' +import type { + ModelSelectorModel, + ModelSelectorModelPredicate, + ModelSelectorProvider, + ModelSelectorValue, +} from './types' import { cn } from '@langgenius/dify-ui/cn' import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -28,13 +33,13 @@ import { useCredentialPanelState as useCredentialPanelInfo } from '../provider-a export type ModelSelectorPreviewPayload = { provider: ModelSelectorProvider - modelItem: ModelItem + modelItem: ModelSelectorModel } type PreviewCardHandle = NonNullable['handle']> type PopupItemProps = { - defaultModel?: DefaultModel + defaultModel?: ModelSelectorValue model: ModelSelectorProvider modelPredicate?: ModelSelectorModelPredicate modelSuggestionPredicate?: ModelSelectorModelPredicate @@ -80,7 +85,7 @@ function PopupItem({ const modelType = model.models[0]!.model_type - if (modelType) updateModelList(modelType) + if (modelType) updateModelList(modelType as ModelType) }, }) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx index f28b0888909..b58d0c2cd6b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx @@ -10,18 +10,6 @@ import { } from '@langgenius/dify-ui/scroll-area' import { useTranslation } from 'react-i18next' -type ModelSelectorPopupFrameProps = { - children: ReactNode -} - -export function ModelSelectorPopupFrame({ children }: ModelSelectorPopupFrameProps) { - return ( -
- {children} -
- ) -} - type ModelSelectorSearchHeaderProps = { inputValue: string onInputValueChange: (value: string) => void @@ -138,10 +126,10 @@ export function ModelProviderSettingsFooter({ onOpenSettings }: ModelProviderSet
{props.onConfigureEmptyState && ( diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index 74b4453e1fc..efef573c9cb 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -19,7 +19,7 @@ import { useSystemDefaultModelAndModelList, useUpdateModelList, } from '../hooks' -import ModelSelector from '../model-selector' +import { ModelSelector } from '../model-selector' type SystemModelSelectorProps = { className?: string @@ -226,13 +226,13 @@ const SystemModel: FC = ({ )}
setOpen(false)} showModelMeta={false} - onSelect={(model) => + onValueChange={(model) => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model) } /> @@ -245,13 +245,13 @@ const SystemModel: FC = ({ )}
setOpen(false)} showModelMeta={false} - onSelect={(model) => + onValueChange={(model) => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model) } /> @@ -264,13 +264,15 @@ const SystemModel: FC = ({ )}
setOpen(false)} showModelMeta={false} - onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)} + onValueChange={(model) => + handleChangeDefaultModel(ModelTypeEnum.rerank, model) + } />
@@ -281,13 +283,13 @@ const SystemModel: FC = ({ )}
setOpen(false)} showModelMeta={false} - onSelect={(model) => + onValueChange={(model) => handleChangeDefaultModel(ModelTypeEnum.speech2text, model) } /> @@ -297,13 +299,13 @@ const SystemModel: FC = ({ {renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
setOpen(false)} showModelMeta={false} - onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)} + onValueChange={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)} />
diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index af2f2405849..05a9f849ddc 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -81,7 +81,7 @@ export const sizeFormat = (size: number) => { else return `${remainder}K` } -export const modelTypeFormat = (modelType: ModelTypeEnum | ModelType) => { +export const modelTypeFormat = (modelType: string) => { if (modelType === ModelTypeEnum.textEmbedding) return 'TEXT EMBEDDING' return modelType.toLocaleUpperCase() diff --git a/web/app/components/header/account-setting/query-params.ts b/web/app/components/header/account-setting/query-params.ts index 51e028ff710..f838faa806c 100644 --- a/web/app/components/header/account-setting/query-params.ts +++ b/web/app/components/header/account-setting/query-params.ts @@ -5,13 +5,11 @@ import { ACCOUNT_SETTING_TAB_VALUES } from './constants' export const settingsQueryParamName = 'settings' -// Opening the full-screen settings surface is the common write. It creates a history entry and -// opts into a Next.js navigation so browser Back updates both the URL and the nuqs snapshot. -// Closing and switching destinations stay shallow and replace history at the modal owner. +// Settings is a client-owned full-screen surface, so nuqs' shallow replace defaults are enough. export const settingsQueryParser = parseAsStringLiteral([ ...ACCOUNT_SETTING_TAB_VALUES, ...INTEGRATION_SECTION_VALUES, -] as const).withOptions({ history: 'push', shallow: false }) +] as const) export type SettingsDestination = inferParserType 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 456f3495364..937fbd60bfa 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 @@ -3,6 +3,7 @@ import type { ModelItem, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationMethodEnum, @@ -77,91 +78,58 @@ vi.mock('@/utils/completion-params', () => ({ })) // Mock child components -vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - defaultModel, - modelList, +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { + const ModelSelector = ({ + value, + models, scopeFeatures, - triggerClassName, - readonly, - onSelect, + surface, + disabled, + onValueChange, }: { - defaultModel?: { provider?: string; model?: string } - modelList?: Model[] + value?: { provider?: string; model?: string } + models?: Model[] scopeFeatures?: string[] - triggerClassName?: string - readonly?: boolean - onSelect?: (model: { provider: string; model: string }) => void + surface?: 'default' | 'workflow' + disabled?: boolean + onValueChange?: (model: { provider: string; model: string }) => void }) => { - const currentProvider = modelList?.find((model) => model.provider === defaultModel?.provider) - const currentModel = currentProvider?.models.find( - (model) => model.model === defaultModel?.model, - ) - const hasDeprecated = !!defaultModel && (!currentProvider || !currentModel) + const currentProvider = models?.find((model) => model.provider === value?.provider) + const currentModel = currentProvider?.models.find((model) => model.model === value?.model) + const hasDeprecated = !!value && (!currentProvider || !currentModel) const modelDisabled = currentModel?.status !== ModelStatusEnum.active return (
) - }, -})) + } -vi.mock( - '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', - () => ({ - default: ({ - disabled, - hasDeprecated, - currentProvider, - currentModel, - providerName, - modelId, - scope, - }: { - disabled?: boolean - hasDeprecated?: boolean - currentProvider?: Model - currentModel?: ModelItem - providerName?: string - modelId?: string - scope?: string - }) => ( -
- Agent Model Trigger -
- ), - }), -) + return { + ModelSelector, + SplitModelSelector: ModelSelector, + } +}) vi.mock('../llm-params-panel', () => ({ default: ({ @@ -318,64 +286,6 @@ describe('ModelParameterModal', () => { expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled() }) - it('should render agent model trigger when isAgentStrategy is true', () => { - // Arrange - const props = createDefaultProps({ isAgentStrategy: true }) - - // Act - render() - - // Assert - expect(screen.getByTestId('agent-model-trigger')).toBeInTheDocument() - expect(screen.queryByTestId('trigger')).not.toBeInTheDocument() - }) - - it('should render custom trigger when renderTrigger is provided', () => { - // Arrange - const renderTrigger = vi.fn().mockReturnValue(
Custom
) - const props = createDefaultProps({ renderTrigger }) - - // Act - render() - - // Assert - expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() - expect(screen.queryByTestId('trigger')).not.toBeInTheDocument() - }) - - it('should call renderTrigger with the actual popover state', async () => { - // Arrange - const renderTrigger = vi.fn().mockReturnValue(
Custom
) - const value = { provider: 'openai', model: 'gpt-4' } - const props = createDefaultProps({ renderTrigger, value }) - - // Act - render() - - const trigger = screen.getByText('Custom').closest('button') - expect(trigger).not.toHaveAttribute('data-popup-open') - expect(renderTrigger).toHaveBeenCalledWith( - expect.objectContaining({ - open: false, - providerName: 'openai', - modelId: 'gpt-4', - }), - ) - - fireEvent.click(screen.getByText('Custom')) - - await waitFor(() => { - expect(renderTrigger).toHaveBeenLastCalledWith( - expect.objectContaining({ - open: true, - providerName: 'openai', - modelId: 'gpt-4', - }), - ) - }) - expect(trigger).toHaveAttribute('data-popup-open', '') - }) - it('should not render portal content when closed', () => { // Arrange const props = createDefaultProps() @@ -420,17 +330,6 @@ describe('ModelParameterModal', () => { expect(screen.getByTestId('trigger')).toHaveAttribute('data-in-workflow', 'true') }) - it('should pass scope to agent model trigger', () => { - // Arrange - const props = createDefaultProps({ isAgentStrategy: true, scope: 'llm&vision' }) - - // Act - render() - - // Assert - expect(screen.getByTestId('agent-model-trigger')).toHaveAttribute('data-scope', 'llm&vision') - }) - it('should default scope to textGeneration', () => { // Arrange const textGenModel = createModel({ provider: 'openai' }) @@ -773,6 +672,39 @@ 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: [ + createModelItem({ + model: 'tts-1', + model_type: ModelTypeEnum.tts, + status: ModelStatusEnum.active, + }), + ], + }) + setupModelLists({ tts: [ttsModel] }) + + render( + , + ) + + const settingsButton = screen.getByRole('button', { + name: /modelProvider\.modelSettings/i, + }) + expect(settingsButton).toBeEnabled() + + await user.click(settingsButton) + + expect(await screen.findByTestId('tts-params-panel')).toBeInTheDocument() + }) + it('should keep model selection available when isAPIKeySet is false', () => { // Arrange mockProviderContextValue.isAPIKeySet = false @@ -1202,7 +1134,7 @@ describe('ModelParameterModal', () => { }) }) - it('should handle value with only provider', () => { + it('should not pass a partial value with only provider', () => { // Arrange const model = createModel({ provider: 'openai' }) setupModelLists({ textGeneration: [model] }) @@ -1212,10 +1144,10 @@ describe('ModelParameterModal', () => { render() // Assert - expect(screen.getByTestId('trigger')).toHaveAttribute('data-provider', 'openai') + expect(screen.getByTestId('trigger')).not.toHaveAttribute('data-provider') }) - it('should handle value with only model', () => { + it('should not pass a partial value with only model', () => { // Arrange const props = createDefaultProps({ value: { model: 'gpt-4' } }) @@ -1223,7 +1155,7 @@ describe('ModelParameterModal', () => { render() // Assert - expect(screen.getByTestId('trigger')).toHaveAttribute('data-model', 'gpt-4') + expect(screen.getByTestId('trigger')).not.toHaveAttribute('data-model') }) it('should handle complex scope with multiple features', async () => { @@ -1304,9 +1236,9 @@ describe('ModelParameterModal', () => { }) }) - // ==================== Model Selector Default Model ==================== - describe('Model Selector Default Model', () => { - it('should pass defaultModel to ModelSelector when provider and model exist', async () => { + // ==================== Model Selector Value ==================== + describe('Model Selector Value', () => { + it('should pass value to ModelSelector when provider and model exist', async () => { // Arrange const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) @@ -1322,37 +1254,29 @@ describe('ModelParameterModal', () => { }) }) - it('should pass partial defaultModel when provider is missing', async () => { - // Arrange - component creates defaultModel when either provider or model exists + it('should pass no value when provider is missing', async () => { const props = createDefaultProps({ value: { model: 'gpt-4' } }) // Act render() openSettings() - // Assert - defaultModel is created with undefined provider await waitFor(() => { const selector = screen.getByTestId('model-selector') - const defaultModel = JSON.parse(selector.getAttribute('data-default-model') || '{}') - expect(defaultModel.model).toBe('gpt-4') - expect(defaultModel.provider).toBeUndefined() + expect(selector.getAttribute('data-default-model')).toBeNull() }) }) - it('should pass partial defaultModel when model is missing', async () => { - // Arrange - component creates defaultModel when either provider or model exists + it('should pass no value when model is missing', async () => { const props = createDefaultProps({ value: { provider: 'openai' } }) // Act render() openSettings() - // Assert - defaultModel is created with undefined model await waitFor(() => { const selector = screen.getByTestId('model-selector') - const defaultModel = JSON.parse(selector.getAttribute('data-default-model') || '{}') - expect(defaultModel.provider).toBe('openai') - expect(defaultModel.model).toBeUndefined() + expect(selector.getAttribute('data-default-model')).toBeNull() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx index 13e460071f4..5685e97cc5c 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx @@ -1,12 +1,11 @@ -import type { FC, ReactNode } from 'react' +import type { FC } from 'react' import type { - DefaultModel, FormValue, ModelFeatureEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/types' +import type { ModelSelectorValue } from '@/app/components/header/account-setting/model-provider-page/model-selector/types' import { cn } from '@langgenius/dify-ui/cn' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -15,9 +14,8 @@ import { ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import AgentModelTrigger from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -import { useProviderContext } from '@/context/provider-context' +import { ModelSettingsTrigger } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-settings-trigger' +import { SplitModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' import LLMParamsPanel from './llm-params-panel' import TTSParamsPanel from './tts-params-panel' @@ -25,28 +23,33 @@ import TTSParamsPanel from './tts-params-panel' type ModelParameterModalProps = { popupClassName?: string isAdvancedMode: boolean - value: any - setModel: (model: any) => void - renderTrigger?: (v: TriggerProps) => ReactNode + value?: PluginModelValue | null + setModel: (model: PluginModelValue) => void readonly?: boolean isInWorkflow?: boolean - isAgentStrategy?: boolean scope?: string } +type PluginModelValue = Record & { + completion_params?: FormValue + completionParams?: FormValue + language?: string + model?: string + model_type?: string + provider?: string + voice?: string +} + const ModelParameterModal: FC = ({ popupClassName, isAdvancedMode, value, setModel, - renderTrigger, readonly, isInWorkflow, - isAgentStrategy, scope = ModelTypeEnum.textGeneration, }) => { const { t } = useTranslation() - const { isAPIKeySet } = useProviderContext() const [open, setOpen] = useState(false) const scopeArray = scope.split('&') const scopeFeatures = useMemo((): ModelFeatureEnum[] => { @@ -74,7 +77,6 @@ const ModelParameterModal: FC = ({ const { data: ttsList } = useModelList(ModelTypeEnum.tts) const scopedModelList = useMemo(() => { - const resultList: any[] = [] if (scopeArray.includes('all')) { return [ ...textGenerationList, @@ -91,7 +93,7 @@ const ModelParameterModal: FC = ({ if (scopeArray.includes(ModelTypeEnum.moderation)) return moderationList if (scopeArray.includes(ModelTypeEnum.speech2text)) return sttList if (scopeArray.includes(ModelTypeEnum.tts)) return ttsList - return resultList + return [] }, [ scopeArray, textGenerationList, @@ -114,9 +116,9 @@ const ModelParameterModal: FC = ({ }, [scopedModelList, value?.provider, value?.model]) const hasDeprecated = !currentProvider || !currentModel - const disabled = !isAPIKeySet || hasDeprecated || currentModel?.status !== ModelStatusEnum.active + const modelSettingsDisabled = hasDeprecated || currentModel?.status !== ModelStatusEnum.active - const handleChangeModel = async ({ provider, model }: DefaultModel) => { + const handleChangeModel = async ({ provider, model }: ModelSelectorValue) => { const targetProvider = scopedModelList.find((modelItem) => modelItem.provider === provider) const targetModelItem = targetProvider?.models.find( (modelItem: { model: string }) => modelItem.model === model, @@ -178,124 +180,52 @@ const ModelParameterModal: FC = ({ }) } - const hasSelectedModel = !!value?.provider && !!value?.model - const isSplitTrigger = !renderTrigger && !isAgentStrategy - + const selectedModel = + value?.provider && value.model ? { provider: value.provider, model: value.model } : undefined + const hasSelectedModel = !!selectedModel return ( { - if (readonly) return + if (readonly && newOpen) return setOpen(newOpen) }} >
- {isSplitTrigger ? ( -
-
- -
- $['modelProvider.modelSettings'], { ns: 'common' })} - disabled={readonly || !hasSelectedModel} - className={cn( - 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', - isInWorkflow && - 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - > - - -
- ) : ( - ( - - )} +
+ - )} + +
- {!isSplitTrigger && ( -
-
- {t(($) => $['modelProvider.model'], { ns: 'common' }).toLocaleUpperCase()} -
- -
- )} - {!isSplitTrigger && - (currentModel?.model_type === ModelTypeEnum.textGeneration || - currentModel?.model_type === ModelTypeEnum.tts) && ( -
- )} - {currentModel?.model_type === ModelTypeEnum.textGeneration && ( + {currentModel?.model_type === ModelTypeEnum.textGeneration && selectedModel && ( )} - {currentModel?.model_type === ModelTypeEnum.tts && ( + {currentModel?.model_type === ModelTypeEnum.tts && selectedModel && ( )} diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index ae02d297098..44f09e37ff4 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -255,7 +255,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { validating={false} showOnVariableMap={{}} isEditMode={true} - isAgentStrategy={true} fieldLabelClassName="uppercase" customRenderField={renderField} override={override} diff --git a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx index e6811bfb691..c1ea25e716a 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx @@ -52,10 +52,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel, modelList }: any) => ( + ModelSelector: ({ value, models }: any) => (
- {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}: - {modelList.length} + {value ? `${value.provider}/${value.model}` : 'no-model'}:{models.length}
), })) diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx index 86f9b500b04..46f450684db 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx @@ -16,16 +16,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ - defaultModel, - modelList, + ModelSelector: ({ + value, + models, }: { - defaultModel?: { provider: string; model: string } - modelList: ModelProviderItem[] + value?: { provider: string; model: string } + models: ModelProviderItem[] }) => (
- {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}: - {modelList.length} + {value ? `${value.provider}/${value.model}` : 'no-model'}:{models.length}
), })) @@ -48,7 +47,7 @@ describe('agent/model-bar', () => { mockModelLists.set('tts' as ModelTypeEnum, []) }) - it('should render an empty readonly selector with a warning when no model is selected', () => { + it('should render an empty disabled selector with a warning when no model is selected', () => { render() const emptySelector = screen.getByText((_, element) => element?.textContent === 'no-model:0') diff --git a/web/app/components/workflow/nodes/agent/components/model-bar.tsx b/web/app/components/workflow/nodes/agent/components/model-bar.tsx index 3d962163c04..b82e1847af1 100644 --- a/web/app/components/workflow/nodes/agent/components/model-bar.tsx +++ b/web/app/components/workflow/nodes/agent/components/model-bar.tsx @@ -5,7 +5,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' type ModelBarProps = | { @@ -49,12 +49,12 @@ export const ModelBar: FC = (props) => { render={
@@ -78,15 +78,15 @@ export const ModelBar: FC = (props) => { const modelSelector = (
{showWarn && }
diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx index 863ca4291c7..9f3bd5dc12d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: mockModelSelector, + ModelSelector: mockModelSelector, })) describe('EmbeddingModel', () => { @@ -53,24 +53,24 @@ describe('EmbeddingModel', () => { expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding) expect(mockModelSelector).toHaveBeenCalledWith( expect.objectContaining({ - defaultModel: { + value: { provider: 'openai', model: 'text-embedding-3-large', }, - modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }], - readonly: false, + models: [{ provider: 'openai', model: 'text-embedding-3-large' }], + disabled: false, showDeprecatedWarnIcon: true, }), undefined, ) }) - it('should pass an undefined default model when the embedding model is incomplete', () => { + it('should pass an undefined value when the embedding model is incomplete', () => { render() expect(mockModelSelector).toHaveBeenCalledWith( expect.objectContaining({ - defaultModel: undefined, + value: undefined, }), undefined, ) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx index 2ef7ff51b09..bf7b514d41d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx @@ -7,7 +7,7 @@ import { } from '@/app/components/datasets/common/multimodal-retrieval-guidance' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' type EmbeddingModelProps = { @@ -68,15 +68,15 @@ const EmbeddingModel = ({ className="mb-2" /> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx index 5a4a7787196..911fd759e6c 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx @@ -11,9 +11,9 @@ import { import RerankingModelSelector from '../reranking-model-selector' type MockModelSelectorProps = { - defaultModel?: DefaultModel - modelList: Model[] - onSelect?: (model: DefaultModel) => void + value?: DefaultModel + models: Model[] + onValueChange?: (model: DefaultModel) => void } const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn()) @@ -23,13 +23,16 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel, modelList, onSelect }: MockModelSelectorProps) => ( + ModelSelector: ({ value, models, onValueChange }: MockModelSelectorProps) => (
-
- {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-default-model'} +
+ {value ? `${value.provider}/${value.model}` : 'no-model-value'}
-
{modelList.length}
-
@@ -59,7 +62,7 @@ describe('RerankingModelSelector', () => { // Rendering behavior for mapped rerank model state. describe('Rendering', () => { - it('should not pass a default model when reranking model fields are empty strings', () => { + it('should not pass a value when reranking model fields are empty strings', () => { render( { />, ) - expect(screen.getByTestId('default-model')).toHaveTextContent('no-default-model') - expect(screen.getByTestId('model-list-count')).toHaveTextContent('1') + expect(screen.getByTestId('model-value')).toHaveTextContent('no-model-value') + expect(screen.getByTestId('models-count')).toHaveTextContent('1') }) - it('should map reranking model to default model when both fields exist', () => { + it('should map reranking model to the selector value when both fields exist', () => { render( { />, ) - expect(screen.getByTestId('default-model')).toHaveTextContent('cohere/rerank-v3') + expect(screen.getByTestId('model-value')).toHaveTextContent('cohere/rerank-v3') }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx index 0ec55133477..baac470ab40 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx @@ -6,37 +6,10 @@ import { fireEvent, render, screen } from '@testing-library/react' import { HybridSearchModeEnum, RetrievalSearchMethodEnum, WeightedScoreEnum } from '../../../types' import { SearchMethodOption } from '../search-method-option' -const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn()) -const mockUseProviderContext = vi.hoisted(() => vi.fn()) -const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) - -vi.mock( - '@/app/components/header/account-setting/model-provider-page/hooks', - async (importOriginal) => { - const actual = - await importOriginal< - typeof import('@/app/components/header/account-setting/model-provider-page/hooks') - >() - return { - ...actual, - useModelListAndDefaultModel: ( - ...args: Parameters - ) => mockUseModelListAndDefaultModel(...args), - } - }, -) - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => mockUseProviderContext(), +vi.mock('../reranking-model-selector', () => ({ + default: () => , })) -vi.mock( - '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', - () => ({ - useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args), - }), -) - const SearchIcon: ComponentType> = (props) => ( ) @@ -128,27 +101,6 @@ function renderSearchMethodOption(props: ReturnType) { } describe('SearchMethodOption', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseModelListAndDefaultModel.mockReturnValue({ - modelList: [], - defaultModel: undefined, - }) - mockUseProviderContext.mockReturnValue({ - modelProviders: [], - }) - mockUseCredentialPanelState.mockReturnValue({ - variant: 'api-active', - priority: 'apiKeyOnly', - supportsCredits: false, - showPrioritySwitcher: false, - hasCredentials: true, - isCreditsExhausted: false, - credentialName: undefined, - credits: 0, - }) - }) - it('should render semantic search controls and notify retrieval and reranking changes', () => { const props = createProps() diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx index 79f1063f966..401ef4d0720 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx @@ -3,7 +3,7 @@ import type { DefaultModel } from '@/app/components/header/account-setting/model import { memo, useMemo } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' export type RerankingModelSelectorProps = { rerankingModel?: RerankingModel @@ -35,10 +35,10 @@ const RerankingModelSelector = ({ return ( ) diff --git a/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx index 26bc4e909b8..9d44b155feb 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx @@ -10,9 +10,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( -
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}
+ ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => ( +
{value ? `${value.provider}:${value.model}` : 'no-model'}
), })) @@ -47,7 +46,7 @@ describe('llm/node', () => { } as unknown as ReturnType) }) - it('renders the readonly model selector when a model is configured', () => { + it('renders the disabled model selector when a model is configured', () => { render() expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/llm/node.tsx b/web/app/components/workflow/nodes/llm/node.tsx index e64c8fe02d7..88d923442e8 100644 --- a/web/app/components/workflow/nodes/llm/node.tsx +++ b/web/app/components/workflow/nodes/llm/node.tsx @@ -3,7 +3,7 @@ import type { LLMNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' const Node: FC> = ({ data }) => { const { provider, name: modelId } = data.model || {} @@ -16,10 +16,10 @@ const Node: FC> = ({ data }) => {
{hasSetModel && ( )}
diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx index 0b9b5c91004..562cf04b74c 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx @@ -35,9 +35,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( -
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}
+ ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => ( +
{value ? `${value.provider}:${value.model}` : 'no-model'}
), })) diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx index 960e6a707e3..fb924bc8bd8 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx @@ -11,9 +11,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( -
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}
+ ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => ( +
{value ? `${value.provider}:${value.model}` : 'no-model'}
), })) @@ -49,7 +48,7 @@ describe('parameter-extractor/node', () => { } as unknown as ReturnType) }) - it('renders the readonly model selector when a model is configured', () => { + it('renders the disabled model selector when a model is configured', () => { render() expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/parameter-extractor/node.tsx b/web/app/components/workflow/nodes/parameter-extractor/node.tsx index 00eee0ce006..f2e27334ddf 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/node.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/node.tsx @@ -3,7 +3,7 @@ import type { ParameterExtractorNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' const Node: FC> = ({ data }) => { const { provider, name: modelId } = data.model || {} @@ -13,10 +13,10 @@ const Node: FC> = ({ data }) => {
{hasSetModel && ( )}
diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx index 2e518462cd9..bdfb59d2310 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx @@ -64,9 +64,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: any) => ( + ModelSelector: ({ value }: any) => (
- {defaultModel.provider}:{defaultModel.model} + {value.provider}:{value.model}
), })) diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx index 54bb161f5e2..21283f39b7e 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx @@ -10,9 +10,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( -
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}
+ ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => ( +
{value ? `${value.provider}:${value.model}` : 'no-model'}
), })) diff --git a/web/app/components/workflow/nodes/question-classifier/node.tsx b/web/app/components/workflow/nodes/question-classifier/node.tsx index 17471b7d5d1..9b4f9d7c030 100644 --- a/web/app/components/workflow/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/nodes/question-classifier/node.tsx @@ -6,7 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import * as React from 'react' import { useTranslation } from 'react-i18next' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector' import { NodeSourceHandle } from '../_base/components/node-handle' import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var' import { getDisplayClassLabel } from './components/class-label-utils' @@ -74,10 +74,10 @@ const Node: FC> = (props) => {
{hasSetModel && ( )} {!!topics.length && ( diff --git a/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx index c20212cd424..97b6cf838be 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx @@ -8,6 +8,23 @@ import { WorkflowContext } from '@/app/components/workflow/context' import { createWorkflowStore } from '@/app/components/workflow/store/workflow' import VariableTrigger from '../variable-trigger' +vi.mock( + '@/app/components/header/account-setting/model-provider-page/hooks', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@/app/components/header/account-setting/model-provider-page/hooks') + >() + + return { + ...actual, + useTextGenerationCurrentProviderAndModelAndModelList: () => ({ + activeTextGenerationModelList: [], + }), + } + }, +) + const createEnv = (overrides: Partial = {}): EnvironmentVariable => ({ id: 'env-1', name: 'api_key', diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index ad934428dc0..94ed5257791 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -1,4 +1,4 @@ -import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelSelectorProvider } from '@/app/components/header/account-setting/model-provider-page/model-selector/types' import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { EnvironmentVariable, @@ -166,7 +166,7 @@ const useEnvPanelActions = ({ updateEnvList: (envList: EnvironmentVariable[]) => void setEnvSecrets: (envSecrets: Record) => void setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void - activeTextGenerationModelList: Model[] + activeTextGenerationModelList: ModelSelectorProvider[] }) => { const emitVarsAndFeaturesUpdate = useCallback( async (syncWorkflowDraft = false) => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index cbe53ff163c..5bb718d1eab 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -6,7 +6,7 @@ import type { } from '@dify/contracts/api/console/agent/types.gen' import type { ReactNode } from 'react' import type { AgentBuildDraftChangedKey } from './build-draft-changes-context' -import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelSelectorProvider } from '@/app/components/header/account-setting/model-provider-page/model-selector/types' import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state' import { cn } from '@langgenius/dify-ui/cn' import { @@ -43,7 +43,7 @@ type AgentOrchestratePanelProps = { agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot'] agentName?: string | null currentModel?: AgentComposerModel - textGenerationModelList: Model[] + textGenerationModelList: ModelSelectorProvider[] isPublishing?: boolean className?: string readOnly?: boolean diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx new file mode 100644 index 00000000000..25cba35390c --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx @@ -0,0 +1,52 @@ +import type { ComponentProps } from 'react' +import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import { fireEvent, render, screen } from '@testing-library/react' +import { AgentModelField } from '../field' + +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ setModel }: Pick) => ( + + ), + }), +) + +describe('AgentModelField', () => { + it('replaces model-owned fields when selecting a model from another provider', () => { + const onSelect = vi.fn() + const currentModel: ComponentProps['currentModel'] = { + provider: 'langgenius/openai/openai', + model: 'gpt-4.1', + plugin_id: 'langgenius/openai', + model_settings: { temperature: 0.7 }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Select Anthropic' })) + + expect(onSelect).toHaveBeenCalledWith({ + provider: 'langgenius/anthropic/anthropic', + model: 'claude-sonnet-4-5', + plugin_id: 'langgenius/anthropic', + }) + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx index 0c33929f08b..9d072f1791d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx @@ -1,21 +1,17 @@ 'use client' -import type { - FormValue, - Model, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelSelectorProvider } from '@/app/components/header/account-setting/model-provider-page/model-selector/types' import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state' import { Field, FieldLabel } from '@langgenius/dify-ui/field' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { isAgentCompatibleModel, isAgentSuggestedModel } from '../../../model-compatibility' import { useAgentOrchestrateReadOnly } from '../read-only-context' type AgentModelFieldProps = { currentModel?: AgentComposerModel - textGenerationModelList: Model[] + textGenerationModelList: ModelSelectorProvider[] onSelect: (model: AgentComposerModel) => void } @@ -25,79 +21,46 @@ export function AgentModelField({ onSelect, }: AgentModelFieldProps) { const { t } = useTranslation('agentV2') - const { t: tCommon } = useTranslation('common') const readOnly = useAgentOrchestrateReadOnly() - const canConfigureModelSettings = !readOnly && !!currentModel?.provider && !!currentModel.model return ( {t(($) => $['agentDetail.configure.model.label'])} -
- {readOnly ? ( -
- {currentModel?.model} -
- ) : ( -
- -
- { - onSelect({ - ...currentModel, - provider, - model: modelId, - }) - }} - onCompletionParamsChange={(modelSettings) => { - if (!currentModel) return + {readOnly ? ( +
+ {currentModel?.model} +
+ ) : ( + { + onSelect({ + provider, + model: modelId, + plugin_id, + }) + }} + onCompletionParamsChange={(modelSettings) => { + if (!currentModel) return - onSelect({ - ...currentModel, - model_settings: modelSettings, - }) - }} - renderTrigger={() => ( - - - - {tCommon(($) => $['modelProvider.modelSettings'])} - - - - } - /> - - {tCommon(($) => $['modelProvider.modelSettings'])} - - - )} - /> -
-
- )} -
+ onSelect({ + ...currentModel, + model_settings: modelSettings, + }) + }} + /> + )}
) } diff --git a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts index 7c926d80e8e..4d52d7ecdbc 100644 --- a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts +++ b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts @@ -1,5 +1,7 @@ -import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { ModelSelectorProvider } from '@/app/components/header/account-setting/model-provider-page/model-selector/types' +import type { + ModelSelectorModel, + ModelSelectorProvider, +} from '@/app/components/header/account-setting/model-provider-page/model-selector/types' const agentIncompatibleModelPatterns: RegExp[] = [ // openai @@ -92,10 +94,16 @@ const agentSuggestedModelPatterns: RegExp[] = [ /^glm[ .-]5\.1$/i, ] -export function isAgentCompatibleModel(_provider: ModelSelectorProvider, modelItem: ModelItem) { +export function isAgentCompatibleModel( + _provider: ModelSelectorProvider, + modelItem: ModelSelectorModel, +) { return !agentIncompatibleModelPatterns.some((pattern) => pattern.test(modelItem.label.en_US)) } -export function isAgentSuggestedModel(_provider: ModelSelectorProvider, modelItem: ModelItem) { +export function isAgentSuggestedModel( + _provider: ModelSelectorProvider, + modelItem: ModelSelectorModel, +) { return agentSuggestedModelPatterns.some((pattern) => pattern.test(modelItem.label.en_US)) } diff --git a/web/service/common.ts b/web/service/common.ts index b4b30cea436..2191ca8a605 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -5,7 +5,6 @@ import type { import type { DefaultModelResponse, Model, - ModelItem, ModelParameterRule, ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -153,10 +152,6 @@ export const activateMember = ({ return post(url, { body }) } -export const fetchModelProviderModelList = (url: string): Promise<{ data: ModelItem[] }> => { - return get<{ data: ModelItem[] }>(url) -} - export const fetchModelList = (url: string): Promise<{ data: Model[] }> => { return get<{ data: Model[] }>(url) } diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 2ee9e0e233c..95564c5fbea 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -19,6 +19,7 @@ import type { RETRIEVE_METHOD } from '@/types/app' import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' // oxlint-disable-next-line no-restricted-imports import { get, post } from './base' +import { consoleQuery } from './client' const NAME_SPACE = 'common' @@ -29,7 +30,6 @@ export const commonQueryKeys = { schemaDefinitions: [NAME_SPACE, 'schema-type-definitions'] as const, modelProviders: [NAME_SPACE, 'model-providers'] as const, modelProviderDetails: [NAME_SPACE, 'model-provider-details'] as const, - modelList: (type: ModelTypeEnum | ModelType) => [NAME_SPACE, 'model-list', type] as const, defaultModel: (type: ModelTypeEnum) => [NAME_SPACE, 'default-model', type] as const, retrievalMethods: [NAME_SPACE, 'support-retrieval-methods'] as const, accountIntegrates: [NAME_SPACE, 'account-integrates'] as const, @@ -211,9 +211,15 @@ export const useModelProviderDetails = (enabled = true) => { }) } -export const useModelListByType = (type: ModelTypeEnum, enabled = true) => { +export const useModelListByType = (type: ModelTypeEnum | ModelType, enabled = true) => { return useQuery<{ data: Model[] }>({ - queryKey: commonQueryKeys.modelList(type), + queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ + input: { + params: { + model_type: type, + }, + }, + }), queryFn: () => get<{ data: Model[] }>(`/workspaces/current/models/model-types/${type}`), enabled, }) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index aeb7a2f9b8b..3ccb6a5283c 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -12,10 +12,7 @@ import type { PluginsFromMarketplaceResponse, } from '@dify/contracts/marketplace' import type { InfiniteData, MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query' -import type { - FormOption, - ModelProvider, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import type { DebugInfo as DebugInfoTypes, @@ -46,8 +43,7 @@ import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/ import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { fetchModelProviderModelList } from '@/service/common' -import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins' +import { uninstallPlugin } from '@/service/plugins' import { hasPermission } from '@/utils/permission' // oxlint-disable-next-line no-restricted-imports import { get, getMarketplace, post, postMarketplace } from './base' @@ -1509,46 +1505,6 @@ export const useMutationCheckDependencies = () => { }) } -export const useModelInList = (currentProvider?: ModelProvider, modelId?: string) => { - const provider = currentProvider?.provider - return useQuery({ - queryKey: ['modelInList', provider, modelId], - queryFn: async () => { - if (!modelId || !provider) return false - try { - const modelsData = await fetchModelProviderModelList( - `/workspaces/current/model-providers/${provider}/models`, - ) - return !!modelId && modelsData.data.some((item) => item.model === modelId) - } catch { - return false - } - }, - enabled: !!modelId && !!provider, - }) -} - -export const usePluginInfo = (providerName?: string) => { - return useQuery({ - queryKey: ['pluginInfo', providerName], - queryFn: async () => { - if (!providerName) return null - const parts = providerName.split('/') - const org = parts[0] - const name = parts[1] - try { - const response = await fetchPluginInfoFromMarketPlace({ org: org!, name: name! }) - return response.data.plugin.category === PluginCategoryEnum.model - ? response.data.plugin - : null - } catch { - return null - } - }, - enabled: !!providerName, - }) -} - export const useFetchDynamicOptions = ( plugin_id: string, provider: string, diff --git a/web/utils/tool-call.ts b/web/utils/tool-call.ts index 61a099b1acd..82f3effa3bb 100644 --- a/web/utils/tool-call.ts +++ b/web/utils/tool-call.ts @@ -1,12 +1,12 @@ import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -export const supportFunctionCall = (features: ModelFeatureEnum[] = []): boolean => { +export const supportFunctionCall = (features: readonly string[] | null = []): boolean => { if (!features || !features.length) return false return features.some((feature) => [ ModelFeatureEnum.toolCall, ModelFeatureEnum.multiToolCall, ModelFeatureEnum.streamToolCall, - ].includes(feature), + ].some((supportedFeature) => supportedFeature === feature), ) }