refactor(web): clarify model selector trigger ownership (#40732)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-08-14 01:03:50 +00:00 committed by GitHub
parent c1bde25bcc
commit 1cf8db8395
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 1481 additions and 2299 deletions

View File

@ -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

View File

@ -139,6 +139,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
<>
<ModelParameterModal
isAdvancedMode={isAdvancedMode}
modelSelectorPopupClassName="w-108"
provider={modelConfig.provider}
completionParams={contextValue.completionParams}
modelId={modelConfig.model_id}

View File

@ -22,21 +22,21 @@ import ConfigContent from '../config-content'
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) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
onClick={() => onValueChange?.(value ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
default: MockModelSelector,
ModelSelector: MockModelSelector,
}
})

View File

@ -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) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
onClick={() => onValueChange?.(value ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
default: MockModelSelector,
ModelSelector: MockModelSelector,
}
})

View File

@ -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<Props> = ({
{showRerankModel && (
<div>
<ModelSelector
defaultModel={
value={
rerankModel && {
provider: rerankModel?.provider_name,
model: rerankModel?.model_name,
}
}
onSelect={(v) => {
onValueChange={(v) => {
onChange({
...datasetConfigs,
reranking_model: {
@ -294,7 +294,7 @@ const ConfigContent: FC<Props> = ({
},
})
}}
modelList={rerankModelList}
models={rerankModelList}
/>
</div>
)}

View File

@ -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 } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
{value ? `${value.provider}/${value.model}` : 'no-model'}
</div>
),
}))

View File

@ -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 } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
{value ? `${value.provider}/${value.model}` : 'no-model'}
</div>
),
}))

View File

@ -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<SettingsModalProps> = ({
<div className="w-full">
<div className="h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60">
<ModelSelector
readonly
defaultModel={{
disabled
value={{
provider: localeCurrentDataset.embedding_model_provider,
model: localeCurrentDataset.embedding_model,
}}
modelList={embeddingModelList}
models={embeddingModelList}
/>
</div>
<div className="mt-2 w-full text-xs/6 text-text-tertiary">

View File

@ -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<typeof import('@tanstack/react-query')>()
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 <div data-testid="model-parameter-modal">{triggerContent}</div>
return <div data-testid="model-parameter-modal">{props?.trigger}</div>
},
}),
)
@ -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 <div data-testid="model-parameter-modal">{triggerContent}</div>
},
}),
)
mockUseCurrentModel.mockReturnValue({
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.active },
})
renderComponent()

View File

@ -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<ModelParameterTriggerProps> = ({ modelAndParameter }) => {
type DebugModelParameterTriggerProps = ComponentPropsWithRef<'button'> & ModelParameterTriggerProps
const DebugModelParameterTrigger: FC<DebugModelParameterTriggerProps> = ({
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 (
<Tooltip>
<TooltipTrigger
disabled={!statusTooltipLabel}
render={
<button
{...triggerProps}
type="button"
className={cn(
'flex h-8 max-w-50 cursor-pointer items-center rounded-lg px-2 data-popup-open:bg-state-base-hover',
!isEmpty && !isActive && 'bg-[#FFFAEB]!',
className,
)}
>
{iconProvider && !isEmpty && (
<ModelIcon
className="mr-1 size-4!"
provider={iconProvider}
modelName={currentModel?.model || modelAndParameter.model}
/>
)}
{(!iconProvider || isEmpty) && (
<span className="mr-1 flex size-4 items-center justify-center rounded-sm">
<span
aria-hidden
className="i-custom-vender-line-shapes-cube-outline size-4 text-text-accent"
/>
</span>
)}
{currentModel && (
<ModelName className="mr-0.5 text-text-secondary" modelItem={currentModel} />
)}
{!currentModel && !isEmpty && (
<span className="mr-0.5 truncate text-[13px] font-medium text-text-secondary">
{modelAndParameter.model}
</span>
)}
{isEmpty && (
<span className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t(($) => $['modelProvider.selectModel'], { ns: 'common' })}
</span>
)}
<span
aria-hidden
className={`i-ri-arrow-down-s-line size-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`}
/>
{statusTooltipLabel && (
<span
aria-label={statusTooltipLabel}
className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]"
/>
)}
</button>
}
/>
{statusTooltipLabel && <TooltipContent>{statusTooltipLabel}</TooltipContent>}
</Tooltip>
)
}
const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ 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<ModelParameterTriggerProps> = ({ 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 (
<div
className={`flex h-8 max-w-50 cursor-pointer items-center rounded-lg px-2 ${open && 'bg-state-base-hover'} ${!isEmpty && !isActive && 'bg-[#FFFAEB]!'} `}
>
{iconProvider && !isEmpty && (
<ModelIcon
className="mr-1 size-4!"
provider={iconProvider}
modelName={currentModel?.model || modelAndParameter.model}
/>
)}
{(!iconProvider || isEmpty) && (
<div className="mr-1 flex size-4 items-center justify-center rounded-sm">
<span className="i-custom-vender-line-shapes-cube-outline size-4 text-text-accent" />
</div>
)}
{currentModel && (
<ModelName className="mr-0.5 text-text-secondary" modelItem={currentModel} />
)}
{!currentModel && !isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary">
{modelAndParameter.model}
</div>
)}
{isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t(($) => $['modelProvider.selectModel'], { ns: 'common' })}
</div>
)}
<span
className={`i-ri-arrow-down-s-line size-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`}
/>
{!isEmpty && !isActive && statusLabelKey && (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' })}
className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]"
/>
}
/>
<TooltipContent>
{t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}
</div>
)
}}
trigger={<DebugModelParameterTrigger modelAndParameter={modelAndParameter} />}
/>
)
}

View File

@ -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<ModelConfig, 'completion_params' | 'name' | 'provider'>
}>
const ModelInfo: FC<Props> = ({ 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 (
<div className={cn('flex items-center rounded-lg')}>
<div className="mr-px flex h-8 shrink-0 items-center gap-1 rounded-l-lg bg-components-input-bg-normal pr-2 pl-1.5">
<ModelIcon className="size-5!" provider={currentProvider} modelName={currentModel?.model} />
<ModelName modelItem={currentModel!} showMode />
<ModelName modelItem={currentModel} showMode />
</div>
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
@ -75,12 +76,12 @@ const ModelInfo: FC<Props> = ({ model }) => {
{t(($) => $['detail.modelParams'], { ns: 'appLog' })}
</div>
<div className="py-1">
{['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 (
<div className="flex justify-between py-1.5" key={index}>
<div className="flex justify-between py-1.5" key={param}>
<span className="system-xs-medium-uppercase text-text-tertiary">
{PARAM_MAP[param as keyof typeof PARAM_MAP]}
{PARAM_MAP[param]}
</span>
<span className="system-xs-medium-uppercase text-text-secondary">
{getParamValue(param)}

View File

@ -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: () => <div data-testid="model-selector">Model Selector</div>,
ModelSelector: () => <div data-testid="model-selector">Model Selector</div>,
}))
vi.mock('@/service/use-common', () => ({

View File

@ -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
}) => (
<div
data-testid="model-selector"
data-provider={defaultModel?.provider}
data-model={defaultModel?.model}
>
<div data-testid="model-selector" data-provider={value?.provider} data-model={value?.model}>
Model Selector
<button
data-testid="select-model"
onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}
onClick={() => onValueChange({ provider: 'cohere', model: 'embed-english' })}
>
Select
</button>

View File

@ -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: () => <div data-testid="model-selector">Model Selector</div>,
ModelSelector: () => <div data-testid="model-selector">Model Selector</div>,
}))
const defaultFeatures: Features = {

View File

@ -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<Props> = ({
>
<div className="pt-1">
<ModelSelector
defaultModel={
value={
embeddingModel && {
provider: embeddingModel.providerName,
model: embeddingModel.modelName,
}
}
modelList={embeddingsModelList}
onSelect={(val) => {
models={embeddingsModelList}
onValueChange={(val) => {
setEmbeddingModel({
providerName: val.provider,
modelName: val.model,

View File

@ -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 }
}) => (
<div
data-testid="model-selector"
data-default-model={defaultModel ? JSON.stringify(defaultModel) : ''}
>
<div data-testid="model-selector" data-value={value ? JSON.stringify(value) : ''}>
<button
data-testid="select-model-btn"
onClick={() => onSelect({ provider: 'new-provider', model: 'new-model' })}
onClick={() => onValueChange({ provider: 'new-provider', model: 'new-model' })}
>
Select Model
</button>
@ -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')
})
})
})

View File

@ -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<Props> = ({
{value.reranking_enable && (
<>
<ModelSelector
defaultModel={
value={
rerankModel && {
provider: rerankModel.provider_name,
model: rerankModel.model_name,
}
}
modelList={rerankModelList}
onSelect={(v) => {
models={rerankModelList}
onValueChange={(v) => {
if (disabled) return
onChange({
...value,
@ -156,7 +156,7 @@ const RetrievalParamConfig: FC<Props> = ({
},
})
}}
readonly={disabled}
disabled={disabled}
/>
{showMultiModalTip && (
<div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">
@ -272,14 +272,14 @@ const RetrievalParamConfig: FC<Props> = ({
{value.reranking_mode !== RerankingModeEnum.WeightedScore && (
<>
<ModelSelector
defaultModel={
value={
rerankModel && {
provider: rerankModel.provider_name,
model: rerankModel.model_name,
}
}
modelList={rerankModelList}
onSelect={(v) => {
models={rerankModelList}
onValueChange={(v) => {
if (disabled) return
onChange({
...value,
@ -289,7 +289,7 @@ const RetrievalParamConfig: FC<Props> = ({
},
})
}}
readonly={disabled}
disabled={disabled}
/>
{showMultiModalTip && (
<div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">

View File

@ -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<string, string>) => void
readonly?: boolean
onValueChange?: (val: Record<string, string>) => void
disabled?: boolean
}) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>
<div data-testid="model-selector" data-disabled={disabled}>
<button
onClick={() => onValueChange?.({ provider: 'openai', model: 'text-embedding-3-small' })}
>
Select Model
</button>
</div>
@ -2526,7 +2528,7 @@ describe('StepTwo Component', () => {
render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />)
// isModelAndRetrievalConfigDisabled should be true
const modelSelector = screen.getByTestId('model-selector')
expect(modelSelector)!.toHaveAttribute('data-readonly', 'true')
expect(modelSelector)!.toHaveAttribute('data-disabled', 'true')
})
})

View File

@ -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<string, string>) => void
readonly?: boolean
onValueChange?: (val: Record<string, string>) => void
disabled?: boolean
}) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>
<div data-testid="model-selector" data-disabled={disabled}>
<button
onClick={() => onValueChange?.({ provider: 'openai', model: 'text-embedding-3-small' })}
>
Select Model
</button>
</div>
@ -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(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled />)
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', () => {

View File

@ -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<IndexingModeSectionProps> = ({
className="mb-2"
/>
<ModelSelector
readonly={isModelAndRetrievalConfigDisabled}
triggerClassName={isModelAndRetrievalConfigDisabled ? 'opacity-50' : ''}
defaultModel={embeddingModel}
modelList={embeddingModelList ?? []}
onSelect={onEmbeddingModelChange}
disabled={isModelAndRetrievalConfigDisabled}
value={embeddingModel}
models={embeddingModelList ?? []}
onValueChange={onEmbeddingModelChange}
/>
{isModelAndRetrievalConfigDisabled && (
<div className="mt-2 system-xs-medium text-text-tertiary">

View File

@ -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<string, string>) => void
readonly?: boolean
defaultModel?: { model?: string }
onValueChange?: (val: Record<string, string>) => void
disabled?: boolean
value?: { model?: string }
}) => (
<div data-testid="model-selector" data-readonly={readonly}>
<span data-testid="current-model">{defaultModel?.model || 'none'}</span>
<div data-testid="model-selector" data-disabled={disabled}>
<span data-testid="current-model">{value?.model || 'none'}</span>
<button
data-testid="select-model-btn"
onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })}
onClick={() => onValueChange?.({ provider: 'openai', model: 'gpt-4' })}
>
Select
</button>
@ -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(
<SummaryIndexSetting
entry="knowledge-base"
@ -185,7 +185,7 @@ describe('SummaryIndexSetting', () => {
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(
<SummaryIndexSetting
entry="knowledge-base"
@ -216,7 +216,7 @@ describe('SummaryIndexSetting', () => {
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(<SummaryIndexSetting entry="knowledge-base" summaryIndexSetting={{ enable: true }} />)
expect(screen.getByTestId('current-model')).toHaveTextContent('none')
})

View File

@ -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
}) => (
<div
data-testid="model-selector"
data-model={defaultModel?.model ?? ''}
data-provider={defaultModel?.provider ?? ''}
data-model={value?.model ?? ''}
data-provider={value?.provider ?? ''}
>
<button
type="button"
onClick={() => onSelect?.({ provider: 'cohere', model: 'embed-english-v3.0' })}
onClick={() => onValueChange?.({ provider: 'cohere', model: 'embed-english-v3.0' })}
>
select-model
</button>

View File

@ -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"
/>
<ModelSelector
defaultModel={embeddingModel}
modelList={embeddingModelList}
onSelect={setEmbeddingModel}
readonly={readonly}
value={embeddingModel}
models={embeddingModelList}
onValueChange={setEmbeddingModel}
disabled={readonly}
/>
</div>
</div>

View File

@ -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' })}
</div>
<ModelSelector
defaultModel={
value={
summaryIndexModelConfig && {
provider: summaryIndexModelConfig.providerName,
model: summaryIndexModelConfig.modelName,
}
}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
models={textGenerationModelList}
onValueChange={handleSummaryIndexModelChange}
disabled={readonly}
showDeprecatedWarnIcon
/>
<div className="mt-3 flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
@ -156,17 +156,16 @@ const SummaryIndexSetting = ({
</div>
<div className="grow">
<ModelSelector
defaultModel={
value={
summaryIndexModelConfig && {
provider: summaryIndexModelConfig.providerName,
model: summaryIndexModelConfig.modelName,
}
}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
models={textGenerationModelList}
onValueChange={handleSummaryIndexModelChange}
disabled={readonly}
showDeprecatedWarnIcon
triggerClassName="h-8"
/>
</div>
</div>
@ -215,17 +214,16 @@ const SummaryIndexSetting = ({
{t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })}
</div>
<ModelSelector
defaultModel={
value={
summaryIndexModelConfig && {
provider: summaryIndexModelConfig.providerName,
model: summaryIndexModelConfig.modelName,
}
}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
models={textGenerationModelList}
onValueChange={handleSummaryIndexModelChange}
disabled={readonly}
showDeprecatedWarnIcon
triggerClassName="h-8"
/>
</div>
<div>

View File

@ -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('', <PreferencesOpener />)
@ -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' }))

View File

@ -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),
})
})
})

View File

@ -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'

View File

@ -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> = TProvider extends { models: Array<infer TModel> }
? 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<TProvider> | 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],
)

View File

@ -7,14 +7,14 @@ type ModelBadgeProps = {
}
const ModelBadge: FC<ModelBadgeProps> = ({ className, children }) => {
return (
<div
<span
className={cn(
'inline-flex h-4.5 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary',
className,
)}
>
{children}
</div>
</span>
)
}

View File

@ -37,14 +37,14 @@ const ModelIcon: FC<ModelIconProps> = ({
modelName?.startsWith('o')
)
return (
<div className="flex items-center justify-center">
<span className="flex items-center justify-center">
<OpenaiYellow className={cn('size-5', className)} />
</div>
</span>
)
if (iconUrl) {
return (
<div
<span
className={cn(
'flex size-5 items-center justify-center',
isDeprecated && 'opacity-50',
@ -60,21 +60,21 @@ const ModelIcon: FC<ModelIconProps> = ({
src={iconUrl}
width={20}
/>
</div>
</span>
)
}
return (
<div
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle',
className,
)}
>
<div className={cn('flex size-5 items-center justify-center opacity-35', iconClassName)}>
<span className={cn('flex size-5 items-center justify-center opacity-35', iconClassName)}>
<span aria-hidden className="i-custom-vender-other-group size-3 text-text-tertiary" />
</div>
</div>
</span>
</span>
)
}

View File

@ -65,7 +65,6 @@ type FormProps<
validatedSuccess?: boolean
showOnVariableMap: Record<string, string[]>
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}

View File

@ -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(
<Form
value={value}
onChange={vi.fn()}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
isAgentStrategy
/>,
)
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[] = [

View File

@ -40,6 +40,12 @@ describe('ModelName', () => {
})
describe('rendering', () => {
it('should render nothing while the model is unavailable', () => {
const { container } = render(<ModelName modelItem={undefined} />)
expect(container).toBeEmptyDOMElement()
})
it('should render the localized model label when translation exists', () => {
mockLocale = 'zh-Hans'
const modelItem = createModelItem()

View File

@ -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<ModelNameProps> = ({
const language = useLanguage()
if (!modelItem) return null
const label = renderI18nObject(modelItem.label, language)
return (
<div
<span
className={cn(
'flex items-center gap-0.5 truncate overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled',
className,
)}
>
<div
className={cn('truncate', nameClassName)}
title={modelItem.label[language] || modelItem.label.en_US}
>
{modelItem.label[language] || modelItem.label.en_US}
</div>
<div className="flex items-center gap-0.5">
<span className={cn('truncate', nameClassName)} title={label}>
{label}
</span>
<span className="flex items-center gap-0.5">
{!!(showModelType && modelItem.model_type) && (
<ModelBadge className={modelTypeClassName}>
{modelTypeFormat(modelItem.model_type)}
@ -72,9 +73,9 @@ const ModelName: FC<ModelNameProps> = ({
showFeaturesLabel={showFeaturesLabel}
/>
))}
</div>
</span>
{children}
</div>
</span>
)
}

View File

@ -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: () => <div>Icon</div>,
}))
vi.mock('../model-display', () => ({
default: () => <div>ModelDisplay</div>,
}))
vi.mock('../status-indicators', () => ({
default: () => <div>StatusIndicators</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({
onClick,
onSuccess,
}: {
onClick: (event: MouseEvent<HTMLButtonElement>) => void
onSuccess: () => void
}) => (
<button
onClick={(event) => {
onClick(event)
onSuccess()
}}
>
Install Plugin
</button>
),
}))
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(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
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(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
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(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
scope={`${ModelTypeEnum.textGeneration},${ModelTypeEnum.tts}`}
/>,
)
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(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
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(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
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(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
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(<AgentModelTrigger />)
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
})
})

View File

@ -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
}) => (
<div data-testid="model-selector">
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>
<button
onClick={() =>
onValueChange({
provider: 'openai',
model: 'gpt-4.1',
plugin_id: 'langgenius/openai',
})
}
>
Select GPT-4.1
</button>
<button onClick={onHide}>hide</button>
{onHide && <button onClick={onHide}>hide</button>}
</div>
),
}))
)
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(<ModelParameterModal {...defaultProps} modelPredicate={() => 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(<ModelParameterModal {...defaultProps} />)
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(<ModelParameterModal {...defaultProps} />)
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(
<ModelParameterModal
{...defaultProps}
provider=""
modelId=""
isInWorkflow
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
trigger={<button type="button">Custom Trigger</button>}
/>,
)
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(
<ModelParameterModal
{...defaultProps}
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
trigger={<button type="button">Custom Trigger</button>}
/>,
)
fireEvent.click(screen.getByText('Popup Closed'))
fireEvent.click(screen.getByText('Custom Trigger'))
expect(screen.getByText('Popup Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@ -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(
<Popover>
<ModelSettingsTrigger />
</Popover>,
)
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(
<Popover>
<ModelSettingsTrigger disabled />
</Popover>,
)
expect(
screen.getByRole('button', { name: 'common.modelProvider.modelSettings' }),
).toBeDisabled()
})
})

View File

@ -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 }) => (
<div>{`SwitchVersion:${uniqueIdentifier}`}</div>
),
}))
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(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
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(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
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(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
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(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>,
)
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(
<StatusIndicators
needsConfiguration={true}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
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(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'missing-plugin' }}
t={t}
/>,
)
expect(screen.getByText('SwitchVersion:')).toBeInTheDocument()
})
it('should render marketplace warning tooltip when provider is unavailable', async () => {
const user = userEvent.setup()
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
inModelList={false}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
await user.hover(getPopoverTrigger('nodes.agent.modelNotInMarketplace.title'))
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@ -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<AgentModelTriggerProps> = ({
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 <Loading />
return (
<div
className={cn(
'group relative flex grow cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-state-base-hover-alt',
)}
>
{modelId ? (
<>
<ModelIcon
className="p-0.5"
provider={currentProvider || modelProvider}
modelName={currentModel?.model || modelId}
isDeprecated={hasDeprecated}
/>
<ModelDisplay currentModel={currentModel} modelId={modelId} />
{needsConfiguration && (
<ConfigurationButton loading={isLoadingProviderDetail} onConfigure={handleConfigure} />
)}
<StatusIndicators
needsConfiguration={needsConfiguration}
modelProvider={!!modelProvider}
inModelList={inModelList}
disabled={!!disabled}
pluginInfo={pluginInfo}
t={translateWorkflow}
/>
{!installed && !modelProvider && pluginInfo?.latest_package_identifier && (
<InstallPluginButton
onClick={(e) => 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 && (
<div className="flex items-center pr-1">
<span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" />
</div>
)}
</>
) : (
<>
<div className="flex grow items-center gap-1 p-1 pl-2">
<span className="truncate system-sm-regular text-components-input-text-placeholder">
{t(($) => $['nodes.agent.configureModel'], { ns: 'workflow' })}
</span>
</div>
<div className="flex items-center pr-1">
<span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" />
</div>
</>
)}
</div>
)
}
export default AgentModelTrigger

View File

@ -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 (
<Button
size="small"
loading={loading}
onClick={(e) => {
e.stopPropagation()
onConfigure()
}}
>
<div className="flex items-center justify-center gap-1">
{t(($) => $['nodes.agent.notAuthorized'], { ns: 'workflow' })}
</div>
<div className="flex h-3.5 w-3.5 items-center justify-center">
<StatusDot status="warning" />
</div>
</Button>
)
}
export default ConfigurationButton

View File

@ -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<ComponentPropsWithRef<'button'>>
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<ModelSelectorValue, 'model'> & {
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<ModelParameterModalProps> = ({
trigger,
popupClassName,
modelSelectorPopupClassName,
placement,
isAdvancedMode,
modelId,
provider,
@ -54,13 +71,15 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
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<ModelParameterModalProps> = ({
})
}
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<ModelParameterModalProps> = ({
}
const hasSelectedModel = !!provider && !!modelId
const canConfigureModelSettings =
!readonly &&
hasSelectedModel &&
!!currentProvider &&
!!currentModel &&
currentModel.status === ModelStatusEnum.active &&
(modelPredicate?.(currentProvider, currentModel) ?? true)
return (
<Popover
open={open}
onOpenChange={(newOpen) => {
if (readonly) return
if (readonly && newOpen) return
setOpen(newOpen)
}}
>
{renderTrigger ? (
<PopoverTrigger
render={(props, state) => (
<button
{...props}
type="button"
className={cn(
'block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]',
props.className,
)}
>
{renderTrigger({
open: state.open,
currentProvider,
currentModel,
providerName: provider,
modelId,
})}
</button>
)}
/>
{trigger ? (
<PopoverTrigger render={trigger} />
) : (
<div className="flex h-8 min-w-74 items-center gap-px overflow-hidden rounded-lg">
<div className="min-w-0 flex-1">
<ModelSelector
defaultModel={provider || modelId ? { provider, model: modelId } : undefined}
modelList={selectableModelList}
readonly={readonly || modelSelectorReadonly}
triggerClassName={cn(
'h-8! w-full rounded-r-none!',
isInWorkflow &&
'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg',
)}
onSelect={handleChangeModel}
/>
</div>
<PopoverTrigger
aria-label={t(($) => $['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',
)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</PopoverTrigger>
<div className="isolate flex h-8 min-w-74 items-center gap-px rounded-lg">
<SplitModelSelector
value={hasSelectedModel ? { provider, model: modelId } : undefined}
models={selectableModelList}
popupClassName={modelSelectorPopupClassName}
disabled={readonly || modelSelectorReadonly}
showModelMeta={showModelMeta}
surface={isInWorkflow ? 'workflow' : 'default'}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
onValueChange={handleChangeModel}
/>
<ModelSettingsTrigger
disabled={!canConfigureModelSettings}
surface={isInWorkflow ? 'workflow' : 'default'}
/>
</div>
)}
<PopoverContent
placement={isInWorkflow ? 'left' : renderTrigger ? 'bottom-end' : 'left-start'}
placement={placement ?? (isInWorkflow ? 'left' : trigger ? 'bottom-end' : 'left-start')}
sideOffset={4}
popupClassName={cn(popupClassName, 'w-100 rounded-2xl')}
>
@ -185,18 +184,27 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div className="pr-8 pl-1 system-xl-semibold text-text-primary">
{t(($) => $['modelProvider.modelSettings'], { ns: 'common' })}
</div>
<PopoverClose className="absolute top-2.5 right-2.5 flex items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover">
<span className="i-ri-close-line size-4 text-text-tertiary" />
</PopoverClose>
<PopoverClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-2.5 right-2.5"
size="lg"
variant="default"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
</div>
<div className="max-h-105 overflow-y-auto">
{renderTrigger && (
{trigger && (
<div className="px-4 pt-2 pb-4">
<ModelSelector
defaultModel={hasSelectedModel ? { provider, model: modelId } : undefined}
modelList={selectableModelList}
readonly={modelSelectorReadonly}
onSelect={handleChangeModel}
value={hasSelectedModel ? { provider, model: modelId } : undefined}
models={selectableModelList}
disabled={modelSelectorReadonly}
onValueChange={handleChangeModel}
onHide={() => setOpen(false)}
/>
</div>
@ -205,7 +213,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div
className={cn(
'flex flex-col gap-2 px-4 pt-3 pb-4',
renderTrigger && 'border-t border-divider-subtle',
trigger && 'border-t border-divider-subtle',
)}
>
<div className="flex items-center gap-1">
@ -250,15 +258,16 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
)}
</div>
{!hideDebugWithMultipleModel && (
<div
<button
type="button"
className="flex h-12.5 cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 system-sm-regular text-text-accent"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{debugWithMultipleModel
? t(($) => $.debugAsSingleModel, { ns: 'appDebug' })
: t(($) => $.debugAsMultipleModel, { ns: 'appDebug' })}
<ArrowNarrowLeft className="size-3 rotate-180" />
</div>
<ArrowNarrowLeft aria-hidden className="size-3 rotate-180" />
</button>
)}
</PopoverContent>
</Popover>

View File

@ -1,25 +0,0 @@
import ModelName from '../model-name'
type ModelDisplayProps = {
currentModel: any
modelId: string
}
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
return currentModel ? (
<ModelName
className="flex grow items-center gap-1 px-1 py-0.75"
modelItem={currentModel}
showMode
showFeatures
/>
) : (
<div className="flex grow items-center gap-1 truncate px-1 py-0.75 opacity-50">
<div className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled">
{modelId}
</div>
</div>
)
}
export default ModelDisplay

View File

@ -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 (
<Tooltip>
<TooltipTrigger
disabled={disabled}
render={
<PopoverTrigger
disabled={disabled}
render={
<IconButton
aria-label={label}
className="relative shrink-0 rounded-l-none! rounded-r-lg! focus-visible:z-1 data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg"
data-surface={surface}
disabled={disabled}
size="lg"
variant="tertiary"
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</IconButton>
}
/>
}
/>
<TooltipContent placement="top">{label}</TooltipContent>
</Tooltip>
)
}
export { ModelSettingsTrigger }

View File

@ -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) => (
<Popover>
<PopoverTrigger
openOnHover
aria-label={ariaLabel}
className="inline-flex border-0 bg-transparent p-0"
onClick={(e) => e.stopPropagation()}
>
{children}
</PopoverTrigger>
<PopoverContent
placement="top"
popupClassName="rounded-md px-3 py-2 system-xs-regular text-text-tertiary"
>
{content}
</PopoverContent>
</Popover>
)
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 (
<div
className="flex w-60 max-w-60 flex-col gap-1 px-1 py-1.5"
onClick={(e) => e.stopPropagation()}
>
<div className="title-xs-semi-bold text-text-primary">{title}</div>
{description && (
<div className="min-w-50 body-xs-regular text-text-secondary">{description}</div>
)}
{linkText && linkHref && (
<div className="cursor-pointer body-xs-regular text-text-accent">
<Link
href={linkHref}
onClick={(e) => {
e.stopPropagation()
}}
>
{linkText}
</Link>
</div>
)}
</div>
)
}
// 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 ? (
<StatusPopover
ariaLabel={t(($) => $['nodes.agent.modelSelectorTooltips.deprecated'], {
ns: 'workflow',
})}
content={t(($) => $['nodes.agent.modelSelectorTooltips.deprecated'], {
ns: 'workflow',
})}
>
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
) : !pluginInfo ? (
<StatusPopover
ariaLabel={t(($) => $['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',
)}
>
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
) : (
<SwitchPluginVersion
tooltip={renderTooltipContent(
t(($) => $['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 && (
<StatusPopover
ariaLabel={t(($) => $['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',
)}
>
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
)}
</>
)
}
export default StatusIndicators

View File

@ -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
}

View File

@ -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 <div>model-trigger</div>
const mockModelProviders = vi.hoisted(() => ({ current: [] as Model[] }))
const mockSetSettingsDestination = vi.hoisted(() => vi.fn())
if (defaultModel) return <div>{`deprecated:${defaultModel.model}`}</div>
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [null, mockSetSettingsDestination],
}
})
return <div>empty-trigger</div>
},
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
}) => (
<>
<ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}>select</ComboboxItem>
<button type="button" onClick={onHide}>
hide
</button>
{onOpenProviderSettings && (
<button type="button" onClick={onOpenProviderSettings}>
provider-settings
</button>
)}
{onConfigureEmptyState && (
<button type="button" onClick={onConfigureEmptyState}>
configure-empty-state
@ -87,10 +104,11 @@ const renderWithQueryClient = (node: ReactNode) => {
describe('ModelSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelProviders.current = [makeModel()]
})
it('should toggle popup and close it after selecting a model', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
const triggerButton = screen.getByRole('combobox')
@ -102,14 +120,14 @@ describe('ModelSelector', () => {
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should call onSelect when provided', () => {
const onSelect = vi.fn()
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} onSelect={onSelect} />)
it('should call onValueChange when provided', () => {
const onValueChange = vi.fn()
renderWithQueryClient(<ModelSelector models={[makeModel()]} onValueChange={onValueChange} />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('select'))
expect(onSelect).toHaveBeenCalledWith({
expect(onValueChange).toHaveBeenCalledWith({
provider: 'openai',
model: 'gpt-4',
plugin_id: 'langgenius/openai',
@ -117,7 +135,7 @@ describe('ModelSelector', () => {
})
it('should close popup when popup requests hide', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
const triggerButton = screen.getByRole('combobox')
fireEvent.click(triggerButton)
@ -131,7 +149,7 @@ describe('ModelSelector', () => {
it('should close popup before running the empty-state configure action', () => {
const onConfigureEmptyState = vi.fn()
renderWithQueryClient(
<ModelSelector modelList={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />,
<ModelSelector models={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />,
)
const triggerButton = screen.getByRole('combobox')
@ -144,54 +162,50 @@ describe('ModelSelector', () => {
expect(onConfigureEmptyState).toHaveBeenCalledTimes(1)
})
it('should use the default model settings popup width when the trigger is narrow', () => {
renderWithQueryClient(
<div className="w-88.75">
<ModelSelector modelList={[makeModel()]} />
</div>,
)
it('should close the popup before opening provider settings', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
renderWithQueryClient(<ModelSelector models={[makeModel()]} onHide={onHide} />)
fireEvent.click(screen.getByRole('combobox'))
const triggerButton = screen.getByRole('combobox')
await user.click(triggerButton)
await user.click(screen.getByRole('button', { name: 'provider-settings' }))
expect(
Array.from(document.body.querySelectorAll('[class]')).some(
(element) => element.className.includes('w-108') && element.className.includes('max-w-108'),
),
).toBe(true)
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
expect(onHide).toHaveBeenCalledTimes(1)
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should not open popup when readonly', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} readonly />)
it('should not open popup when disabled', () => {
renderWithQueryClient(<ModelSelector models={[makeModel()]} disabled />)
fireEvent.click(screen.getByText('empty-trigger'))
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByText('select')).not.toBeInTheDocument()
})
it('should render deprecated trigger when defaultModel is not in list', () => {
const { unmount } = renderWithQueryClient(
<ModelSelector
defaultModel={{ provider: 'openai', model: 'missing-model' }}
modelList={[makeModel()]}
/>,
)
it('should let the split trigger own the combobox interaction', () => {
renderWithQueryClient(<SplitModelSelector models={[makeModel()]} />)
expect(screen.getByText('deprecated:missing-model')).toBeInTheDocument()
unmount()
renderWithQueryClient(
<ModelSelector defaultModel={{ provider: '', model: '' }} modelList={[makeModel()]} />,
)
expect(screen.getByText('deprecated:')).toBeInTheDocument()
const trigger = screen.getByRole('combobox')
expect(trigger).toHaveAttribute('data-shape', 'split')
})
it('should render model trigger when defaultModel matches', () => {
it('should render deprecated trigger when value is not in list', () => {
renderWithQueryClient(
<ModelSelector
defaultModel={{ provider: 'openai', model: 'gpt-4' }}
modelList={[makeModel()]}
value={{ provider: 'openai', model: 'missing-model' }}
models={[makeModel()]}
/>,
)
expect(screen.getByText('model-trigger')).toBeInTheDocument()
expect(screen.getByText('missing-model')).toBeInTheDocument()
})
it('should render model trigger when value matches', () => {
renderWithQueryClient(
<ModelSelector value={{ provider: 'openai', model: 'gpt-4' }} models={[makeModel()]} />,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
import type { Model, ModelItem } from '../../declarations'
import { render, screen } from '@testing-library/react'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { render as renderComponent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
@ -7,12 +9,14 @@ import {
ModelStatusEnum,
ModelTypeEnum,
} from '../../declarations'
import ModelSelectorTrigger from '../model-selector-trigger'
import { ModelSelectorTrigger } from '../model-selector-trigger'
const mockUseProviderContext = vi.hoisted(() => vi.fn())
const render = (node: ReactNode) => renderComponent(<Combobox>{node}</Combobox>)
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
vi.mock('@tanstack/react-query', () => ({
useQuery: mockUseQuery,
}))
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockUseCredentialPanelState,
@ -49,9 +53,7 @@ const createModel = (overrides: Partial<Model> = {}): Model => ({
describe('ModelSelectorTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [createModel()],
})
mockUseQuery.mockReturnValue({ data: createModel() })
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-active',
priority: 'credits',
@ -66,52 +68,46 @@ describe('ModelSelectorTrigger', () => {
describe('Rendering', () => {
it('should render empty state when no model is selected', () => {
const { container } = render(<ModelSelectorTrigger />)
render(<ModelSelectorTrigger />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
expect(screen.getByRole('combobox')).toBeEnabled()
})
it('should render selected model details when model is active', () => {
const currentProvider = createModel()
const currentModel = createModelItem()
const { container } = render(
<ModelSelectorTrigger currentProvider={currentProvider} currentModel={currentModel} />,
)
render(<ModelSelectorTrigger currentProvider={currentProvider} currentModel={currentModel} />)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
expect(screen.getByRole('combobox')).toBeEnabled()
})
it('should render deprecated default model and disabled style when selection is missing', () => {
const { container } = render(
<ModelSelectorTrigger defaultModel={{ provider: 'openai', model: 'legacy-model' }} />,
)
render(<ModelSelectorTrigger defaultModel={{ provider: 'openai', model: 'legacy-model' }} />)
expect(screen.getByText('legacy-model')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should hide the expand arrow when readonly is true', () => {
const { container } = render(
it('should disable the combobox trigger', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
readonly
disabled
/>,
)
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeDisabled()
})
})
describe('Status Handling', () => {
it('should show status badge when selected model is not active and not readonly', () => {
it('should show status badge when selected model is not active and enabled', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
@ -122,9 +118,16 @@ describe('ModelSelectorTrigger', () => {
expect(
screen.getByText('common.modelProvider.selector.configureRequired'),
).toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveAttribute(
'data-model-status',
'configure-required',
)
expect(screen.getByRole('combobox')).toHaveClass(
'data-[model-status=configure-required]:bg-components-input-bg-disabled',
)
})
it('should apply credits exhausted badge style when model quota is exceeded', () => {
it('should show credits exhausted state when model quota is exceeded', () => {
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-exhausted',
priority: 'credits',
@ -140,6 +143,7 @@ describe('ModelSelectorTrigger', () => {
<ModelSelectorTrigger currentProvider={createModel()} currentModel={createModelItem()} />,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
@ -163,6 +167,9 @@ describe('ModelSelectorTrigger', () => {
screen.getByText('common.modelProvider.selector.apiKeyUnavailable'),
).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveClass(
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled',
)
})
it('should show disabled badge when selected model is disabled', () => {
@ -177,23 +184,12 @@ describe('ModelSelectorTrigger', () => {
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
it('should strike through deprecated selected model name', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ deprecated: true })}
/>,
)
expect(screen.getByText('GPT-4')).toHaveClass('line-through')
})
it('should not show status badge when selected model is readonly', () => {
it('should not show status badge when selected model is disabled', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
readonly
disabled
/>,
)
@ -212,6 +208,9 @@ describe('ModelSelectorTrigger', () => {
)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveClass(
'data-[model-status=incompatible]:bg-components-input-bg-disabled',
)
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
expect(
@ -220,7 +219,7 @@ describe('ModelSelectorTrigger', () => {
})
it('should show incompatible badge when selected model fails the compatibility predicate', () => {
const { container } = render(
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
@ -230,7 +229,6 @@ describe('ModelSelectorTrigger', () => {
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
})
@ -272,19 +270,5 @@ describe('ModelSelectorTrigger', () => {
await screen.findByText('common.modelProvider.selector.creditsExhaustedTip'),
).toBeInTheDocument()
})
it('should render fallback icon when deprecated provider is not found', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [],
})
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'unknown-provider', model: 'legacy-model' }}
/>,
)
expect(container.querySelector('img[alt="model-icon"]')).not.toBeInTheDocument()
expect(container.querySelector('.i-custom-vender-other-group')).toBeInTheDocument()
})
})
})

View File

@ -1,55 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModelSelector from '../index'
vi.mock('../../hooks', () => ({
getCurrentProviderAndModel: () => ({
currentProvider: undefined,
currentModel: undefined,
}),
}))
vi.mock('../model-selector-trigger', () => ({
default: ({ open, readonly }: { open: boolean; readonly?: boolean }) => (
<span>
{open ? 'open' : 'closed'}-{readonly ? 'readonly' : 'editable'}
</span>
),
}))
vi.mock('../popup', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="popup">
<button type="button" onClick={onHide}>
hide-popup
</button>
</div>
),
}))
describe('ModelSelector combobox branches', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open and close through combobox trigger when editable', () => {
const onHide = vi.fn()
render(<ModelSelector modelList={[]} onHide={onHide} />)
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(<ModelSelector modelList={[]} readonly />)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
})
})

View File

@ -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<typeof import('nuqs')>()
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 (
<Combobox filter={null} inputValue={inputValue} open>
<ComboboxTrigger aria-label="Selected model">Selected model</ComboboxTrigger>
<ComboboxContent popupProps={{ 'aria-label': 'Model selector' }}>
<Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} />
</ComboboxContent>
</Combobox>
)
}
const renderPopup = (
ui: ReactElement<PopupTestProps>,
options: Parameters<typeof renderWithConsoleQuery>[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', () => {
<PopupHarness
modelList={[makeModel()]}
onHide={vi.fn()}
onOpenProviderSettings={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
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(<PopupHarness modelList={[makeModel()]} onHide={onHide} />)
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(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
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(
<PopupHarness hideProviderSettingsFooter modelList={[makeModel()]} onHide={vi.fn()} />,
<PopupContentHarness
modelList={[]}
onConfigureEmptyState={onConfigureEmptyState}
onOpenProviderSettings={onOpenProviderSettings}
onHide={vi.fn()}
/>,
{
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(<PopupHarness modelList={[]} onHide={onHide} />)
const onConfigureEmptyState = vi.fn()
renderPopup(
<PopupHarness
modelList={[]}
onConfigureEmptyState={onConfigureEmptyState}
onHide={vi.fn()}
/>,
)
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(<PopupHarness modelList={[]} onHide={onHide} />)
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', () => {

View File

@ -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
<Tooltip>
<TooltipTrigger
render={
<div className="inline-block cursor-help">
<span className="inline-block cursor-help">
<ModelBadge className={cn('w-4.5 justify-center px-0!', className)}>
<span className="i-ri-image-circle-ai-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
</span>
}
/>
<TooltipContent>
@ -57,11 +57,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps
<Tooltip>
<TooltipTrigger
render={
<div className="inline-block cursor-help">
<span className="inline-block cursor-help">
<ModelBadge className={cn('w-4.5 justify-center px-0!', className)}>
<span className="i-ri-file-text-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
</span>
}
/>
<TooltipContent>
@ -88,11 +88,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps
<Tooltip>
<TooltipTrigger
render={
<div className="inline-block cursor-help">
<span className="inline-block cursor-help">
<ModelBadge className={cn('w-4.5 justify-center px-0!', className)}>
<span className="i-ri-voice-ai-fill size-3" aria-hidden="true" />
</ModelBadge>
</div>
</span>
}
/>
<TooltipContent>
@ -119,11 +119,11 @@ function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps
<Tooltip>
<TooltipTrigger
render={
<div className="inline-block cursor-help">
<span className="inline-block cursor-help">
<ModelBadge className={cn('w-4.5 justify-center px-0!', className)}>
<span className="i-ri-film-ai-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
</span>
}
/>
<TooltipContent>

View File

@ -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<ModelSelectorValue | null>(() => {
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 (
<Combobox<ModelSelectorValue>
disabled={disabled}
filter={null}
inputValue={inputValue}
isItemEqualToValue={isSameModelSelectorValue}
@ -139,43 +178,42 @@ function ModelSelector({
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t(($) => $['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}
>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
defaultModel={defaultModel}
open={open}
readonly={readonly}
className={triggerClassName}
deprecatedClassName={deprecatedClassName}
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
showModelMeta={showModelMeta}
isModelCompatible={
currentProvider && currentModel
? modelPredicate?.(currentProvider, currentModel)
: undefined
}
/>
</ComboboxTrigger>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
defaultModel={value}
disabled={disabled}
size={size}
surface={surface}
shape={shape}
className={className}
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
showModelMeta={showModelMeta}
isModelCompatible={
currentProvider && currentModel
? modelPredicate?.(currentProvider, currentModel)
: undefined
}
/>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName={cn('w-108 max-w-108 overflow-hidden rounded-xl', popupClassName)}
popupClassName={cn(
'flex max-h-[min(624px,var(--available-height,624px))] flex-col',
popupClassName,
)}
>
<Popup
defaultModel={defaultModel}
defaultModel={value}
inputValue={inputValue}
modelList={modelList}
modelList={models}
scopeFeatures={scopeFeatures}
hideProviderSettingsFooter={hideProviderSettingsFooter}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}
onOpenProviderSettings={
!hideProviderSettingsFooter && settingsDestination !== 'provider'
? handleOpenSettings
: undefined
}
onConfigureEmptyState={handleConfigureEmptyState}
onOpenMarketplace={onOpenMarketplace}
onInputValueChange={setInputValue}
onHide={handleHide}
@ -185,4 +223,12 @@ function ModelSelector({
)
}
export default ModelSelector
function ModelSelector({ size = 'medium', surface = 'default', ...props }: ModelSelectorProps) {
return <ModelSelectorRoot {...props} shape="standalone" size={size} surface={surface} />
}
function SplitModelSelector({ surface = 'default', ...props }: SplitModelSelectorProps) {
return <ModelSelectorRoot {...props} shape="split" size="medium" surface={surface} />
}
export { ModelSelector, SplitModelSelector }

View File

@ -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<string>
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) => {

View File

@ -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 (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg p-1',
isDisabled ? 'bg-components-input-bg-disabled' : 'bg-components-input-bg-normal',
!readonly && !isDisabled && 'cursor-pointer hover:bg-components-input-bg-hover',
open && !isDisabled && 'bg-components-input-bg-hover',
className,
)}
>
{isEmpty ? (
<div className="flex size-6 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line size-3.5 text-text-quaternary" />
</div>
</div>
) : (
<ModelIcon
className="p-0.5"
provider={isSelected ? currentProvider : deprecatedProvider}
modelName={isSelected ? currentModel.model : defaultModel?.model}
/>
)}
<div
className={cn(
'flex grow items-center gap-1 truncate px-1 py-0.75',
isDeprecated && deprecatedClassName,
)}
>
{isSelected && (
<ModelName
className="grow"
modelItem={currentModel}
nameClassName={currentModel?.deprecated ? 'line-through' : undefined}
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)}
{isDeprecated && (
<div className="grow truncate system-sm-regular text-components-input-text-filled line-through">
{defaultModel.model}
</div>
)}
{isEmpty && (
<div className="grow truncate text-[13px] text-components-input-text-placeholder">
{t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
</div>
)}
{isSelected && !readonly && ((!isActive && statusI18nKey) || !isModelCompatible) && (
<Tooltip>
<TooltipTrigger
disabled={!tooltipLabel}
render={
<div
<Tooltip>
<TooltipTrigger
disabled={!triggerTooltipLabel || disabled}
render={
<ComboboxTrigger
aria-label={t(($) => $['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,
)}
>
<span className="flex min-w-0 grow items-center gap-0.5">
{isEmpty ? (
<span
className={cn(
'flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning px-1.25 py-0.5',
isCreditsExhausted && 'min-w-5 justify-center bg-components-badge-bg-dimm',
'flex items-center justify-center',
size === 'small' ? 'size-5' : 'size-6',
)}
>
<span className="i-ri-alert-fill size-3 text-text-warning" />
<span className="system-xs-medium whitespace-nowrap text-text-warning">
{statusLabel}
<span className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line size-3.5 text-text-quaternary" />
</span>
</div>
}
/>
{tooltipLabel && <TooltipContent placement="top">{tooltipLabel}</TooltipContent>}
</Tooltip>
)}
</span>
) : (
<ModelIcon
className="p-0.5"
provider={isSelected ? currentProvider : resolvedProvider}
modelName={isSelected ? currentModel.model : defaultModel?.model}
/>
)}
{isDeprecated && showDeprecatedWarnIcon && (
<Tooltip>
<TooltipTrigger
render={
<div className="flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning bg-components-badge-bg-dimm px-1.25 py-0.5">
<span className="i-ri-alert-fill size-3 text-text-warning" />
<span className="system-xs-medium whitespace-nowrap text-text-warning">
{deprecatedStatusLabel}
<span
className={cn(
'flex grow items-center gap-1 truncate',
size === 'small' ? 'px-0.5' : 'px-1 py-0.75',
)}
>
{isSelected && (
<ModelName
className="grow"
modelItem={currentModel}
nameClassName={currentModel?.deprecated ? 'line-through' : undefined}
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)}
{isDeprecated && (
<span className="grow truncate system-sm-regular text-components-input-text-filled line-through">
{defaultModel.model}
</span>
</div>
}
/>
<TooltipContent placement="top">{deprecatedTooltipLabel}</TooltipContent>
</Tooltip>
)}
)}
{isEmpty && (
<span className="grow truncate text-[13px] text-components-input-text-placeholder">
{t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
</span>
)}
{!readonly && (isActive || isEmpty) && (
<span className="i-ri-arrow-down-s-line size-3.5 shrink-0 text-text-tertiary" />
)}
</div>
</div>
{isSelected &&
!disabled &&
((!isActive && statusI18nKey) || !isModelCompatible) && (
<span
className={cn(
'flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning px-1.25 py-0.5',
isCreditsExhausted && 'min-w-5 justify-center bg-components-badge-bg-dimm',
)}
>
<span aria-hidden className="i-ri-alert-fill size-3 text-text-warning" />
<span className="system-xs-medium whitespace-nowrap text-text-warning">
{statusLabel}
</span>
</span>
)}
{isDeprecated && showDeprecatedWarnIcon && (
<span className="flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning bg-components-badge-bg-dimm px-1.25 py-0.5">
<span aria-hidden className="i-ri-alert-fill size-3 text-text-warning" />
<span className="system-xs-medium whitespace-nowrap text-text-warning">
{deprecatedStatusLabel}
</span>
</span>
)}
</span>
</span>
</ComboboxTrigger>
}
/>
{triggerTooltipLabel && (
<TooltipContent placement="top">{triggerTooltipLabel}</TooltipContent>
)}
</Tooltip>
)
}
export default ModelSelectorTrigger
export { ModelSelectorTrigger }

View File

@ -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<ComponentProps<typeof PreviewCardTrigger>['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)
},
})
}

View File

@ -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 (
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
{children}
</div>
)
}
type ModelSelectorSearchHeaderProps = {
inputValue: string
onInputValueChange: (value: string) => void
@ -138,10 +126,10 @@ export function ModelProviderSettingsFooter({ onOpenSettings }: ModelProviderSet
<div className="shrink-0 border-t border-divider-subtle p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={onOpenSettings}
>
<span className="i-ri-equalizer-2-line size-4 shrink-0" />
<span aria-hidden className="i-ri-equalizer-2-line size-4 shrink-0" />
<span className="system-xs-medium">
{t(($) => $['modelProvider.selector.modelProviderSettings'], { ns: 'common' })}
</span>

View File

@ -1,6 +1,9 @@
import type { DefaultModel, Model } from '../declarations'
import type { ModelSelectorPreviewPayload } from './popup-item'
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
import type {
ModelSelectorModelPredicate,
ModelSelectorProvider,
ModelSelectorValue,
} from './types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import {
@ -10,19 +13,15 @@ import {
} from '@langgenius/dify-ui/preview-card'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
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 checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { renderI18nObject } from '@/i18n-config'
import { consoleQuery } from '@/service/client'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
@ -47,18 +46,17 @@ import PopupItem from './popup-item'
import {
CompatibleModelsNotice,
ModelProviderSettingsFooter,
ModelSelectorPopupFrame,
ModelSelectorScrollBody,
ModelSelectorSearchHeader,
ShowIncompatibleModelsButton,
} from './popup-layout'
export type PopupProps = {
defaultModel?: DefaultModel
defaultModel?: ModelSelectorValue
inputValue: string
modelList: Model[]
scopeFeatures?: ModelFeatureEnum[]
hideProviderSettingsFooter?: boolean
modelList: ModelSelectorProvider[]
scopeFeatures?: readonly string[]
onOpenProviderSettings?: () => void
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
onConfigureEmptyState?: () => void
@ -71,7 +69,7 @@ function Popup({
inputValue,
modelList,
scopeFeatures = [],
hideProviderSettingsFooter,
onOpenProviderSettings,
modelPredicate,
modelSuggestionPredicate,
onConfigureEmptyState,
@ -80,10 +78,6 @@ function Popup({
onHide,
}: PopupProps) {
const { t } = useTranslation()
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(
@ -245,73 +239,71 @@ function Popup({
)
}, [enableMarketplace, modelProviderPlugins])
const handleOpenSettings = useCallback(() => {
onHide()
setSettingsDestination('provider')
}, [onHide, setSettingsDestination])
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
const isProviderSettingsCurrentPage = settingsDestination === 'provider'
const handleConfigureEmptyState =
onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings)
return (
<ModelSelectorPopupFrame>
<>
<ModelSelectorSearchHeader inputValue={inputValue} onInputValueChange={onInputValueChange} />
<ModelSelectorScrollBody label={t(($) => $['modelProvider.models'], { ns: 'common' })}>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ComboboxList className="max-h-none overflow-visible p-0">
<div className="pb-1">
{filteredModelList.map((model) => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onHide={onHide}
/>
))}
</div>
</ComboboxList>
<div className="pb-1">
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState onConfigure={handleConfigureEmptyState} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ModelSelectorScrollBody label={t(($) => $['modelProvider.models'], { ns: 'common' })}>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary">
{t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
ns: 'common',
query: inputValue,
})}
<ComboboxList className="max-h-none overflow-visible p-0">
<div className="pb-1">
{filteredModelList.map((model) => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onHide={onHide}
/>
))}
</div>
)}
{scopeFeatures.length > 0 && <CompatibleModelsNotice />}
{shouldShowModelPredicateReveal && (
<ShowIncompatibleModelsButton
showIncompatibleModels={showIncompatibleModels}
onClick={() => setShowIncompatibleModels((value) => !value)}
/>
)}
{enableMarketplace && (
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
canInstallPlugin={canInstallPlugin}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
/>
)}
</div>
</ModelSelectorScrollBody>
</ComboboxList>
<div className="pb-1">
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState onConfigure={onConfigureEmptyState ?? onHide} />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary">
{t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
ns: 'common',
query: inputValue,
})}
</div>
)}
{scopeFeatures.length > 0 && <CompatibleModelsNotice />}
{shouldShowModelPredicateReveal && (
<ShowIncompatibleModelsButton
showIncompatibleModels={showIncompatibleModels}
onClick={() => setShowIncompatibleModels((value) => !value)}
/>
)}
{enableMarketplace && (
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
canInstallPlugin={canInstallPlugin}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
/>
)}
</div>
</ModelSelectorScrollBody>
{onOpenProviderSettings && (
<ModelProviderSettingsFooter onOpenSettings={onOpenProviderSettings} />
)}
</div>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<ModelSelectorPreviewCard
@ -321,10 +313,7 @@ function Popup({
/>
)}
</PreviewCard>
{!hideProviderSettingsFooter && !isProviderSettingsCurrentPage && (
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
)}
</ModelSelectorPopupFrame>
</>
)
}
@ -352,7 +341,7 @@ function ModelSelectorPreviewCard({
<div className="flex flex-col items-start gap-2">
<ModelIcon className="size-5 shrink-0" provider={provider} modelName={modelItem.model} />
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">
{modelItem.label[language] || modelItem.label.en_US}
{renderI18nObject(modelItem.label, language)}
</div>
</div>
<div className="flex flex-wrap gap-1">
@ -377,7 +366,7 @@ function ModelSelectorPreviewCard({
ModelFeatureEnum.audio,
ModelFeatureEnum.video,
ModelFeatureEnum.document,
].includes(feature),
].some((supportedFeature) => supportedFeature === feature),
) && (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">

View File

@ -1,9 +1,26 @@
import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelItem } from '../declarations'
import type {
I18nObject,
ProviderModelWithStatusEntity,
} from '@dify/contracts/api/console/workspaces/types.gen'
export type ModelSelectorValue = {
provider: string
model: string
plugin_id?: string
}
export type ModelSelectorModel = Pick<
ProviderModelWithStatusEntity,
| 'deprecated'
| 'has_invalid_load_balancing_configs'
| 'label'
| 'load_balancing_enabled'
| 'model'
| 'model_properties'
> & {
features?: readonly string[] | null
model_type: string
status: string
}
export type ModelSelectorProvider = {
@ -11,12 +28,12 @@ export type ModelSelectorProvider = {
icon_small?: I18nObject | null
icon_small_dark?: I18nObject | null
label: I18nObject
models: ModelItem[]
models: ModelSelectorModel[]
}
export type ModelSelectorModelPredicate = (
provider: ModelSelectorProvider,
modelItem: ModelItem,
modelItem: ModelSelectorModel,
) => boolean
export const isSameModelSelectorValue = (
@ -24,4 +41,4 @@ export const isSameModelSelectorValue = (
value: ModelSelectorValue,
) => itemValue.provider === value.provider && itemValue.model === value.model
export const getModelSelectorValueLabel = (itemValue: ModelSelectorValue) => itemValue.model
export const getModelSelectorValueLabel = (value: ModelSelectorValue) => value.model

View File

@ -87,16 +87,16 @@ vi.mock('@/service/common', () => ({
}))
vi.mock('../../model-selector', () => ({
default: (props: {
ModelSelector: (props: {
hideProviderSettingsFooter?: boolean
onConfigureEmptyState?: () => void
showModelMeta?: boolean
onSelect: (model: { model: string; provider: string }) => void
onValueChange: (model: { model: string; provider: string }) => void
}) => {
mockModelSelectorProps.push(props)
return (
<div>
<button onClick={() => props.onSelect({ model: 'test', provider: 'test' })}>
<button onClick={() => props.onValueChange({ model: 'test', provider: 'test' })}>
Mock Model Selector
</button>
{props.onConfigureEmptyState && (

View File

@ -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<SystemModelSelectorProps> = ({
)}
<div>
<ModelSelector
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
value={currentTextGenerationDefaultModel}
models={textGenerationModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
onValueChange={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
}
/>
@ -245,13 +245,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
)}
<div>
<ModelSelector
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
value={currentEmbeddingsDefaultModel}
models={embeddingModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
onValueChange={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
}
/>
@ -264,13 +264,15 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
)}
<div>
<ModelSelector
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
value={currentRerankDefaultModel}
models={rerankModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
onValueChange={(model) =>
handleChangeDefaultModel(ModelTypeEnum.rerank, model)
}
/>
</div>
</div>
@ -281,13 +283,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
)}
<div>
<ModelSelector
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
value={currentSpeech2textDefaultModel}
models={speech2textModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
onValueChange={(model) =>
handleChangeDefaultModel(ModelTypeEnum.speech2text, model)
}
/>
@ -297,13 +299,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
<div>
<ModelSelector
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
value={currentTTSDefaultModel}
models={ttsModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
onValueChange={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
/>
</div>
</div>

View File

@ -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()

View File

@ -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<typeof settingsQueryParser>

View File

@ -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 (
<div
data-testid="trigger"
data-disabled={readonly || hasDeprecated || modelDisabled}
data-disabled={disabled || hasDeprecated || modelDisabled}
data-has-deprecated={hasDeprecated}
data-model-disabled={modelDisabled}
data-provider={defaultModel?.provider}
data-model={defaultModel?.model}
data-in-workflow={triggerClassName?.includes('workflow-block-parma-bg')}
data-provider={value?.provider}
data-model={value?.model}
data-in-workflow={surface === 'workflow'}
data-has-current-provider={!!currentProvider}
data-has-current-model={!!currentModel}
>
<button
type="button"
data-testid="model-selector"
data-default-model={JSON.stringify(defaultModel)}
data-model-list-count={modelList?.length || 0}
data-default-model={JSON.stringify(value)}
data-model-list-count={models?.length || 0}
data-scope-features={JSON.stringify(scopeFeatures)}
onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })}
onClick={() => onValueChange?.({ provider: 'openai', model: 'gpt-4' })}
>
Model Selector
</button>
</div>
)
},
}))
}
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
}) => (
<div
data-testid="agent-model-trigger"
data-disabled={disabled}
data-has-deprecated={hasDeprecated}
data-provider={providerName}
data-model={modelId}
data-scope={scope}
data-has-current-provider={!!currentProvider}
data-has-current-model={!!currentModel}
>
Agent Model Trigger
</div>
),
}),
)
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(<ModelParameterModal {...props} />)
// 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(<div data-testid="custom-trigger">Custom</div>)
const props = createDefaultProps({ renderTrigger })
// Act
render(<ModelParameterModal {...props} />)
// 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(<div>Custom</div>)
const value = { provider: 'openai', model: 'gpt-4' }
const props = createDefaultProps({ renderTrigger, value })
// Act
render(<ModelParameterModal {...props} />)
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(<ModelParameterModal {...props} />)
// 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(
<ModelParameterModal
{...createDefaultProps()}
scope={ModelTypeEnum.tts}
value={{ provider: 'tts-provider', model: 'tts-1' }}
/>,
)
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(<ModelParameterModal {...props} />)
// 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(<ModelParameterModal {...props} />)
// 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(<ModelParameterModal {...props} />)
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(<ModelParameterModal {...props} />)
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()
})
})

View File

@ -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<string, unknown> & {
completion_params?: FormValue
completionParams?: FormValue
language?: string
model?: string
model_type?: string
provider?: string
voice?: string
}
const ModelParameterModal: FC<ModelParameterModalProps> = ({
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<ModelParameterModalProps> = ({
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<ModelParameterModalProps> = ({
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<ModelParameterModalProps> = ({
}, [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<ModelParameterModalProps> = ({
})
}
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 (
<Popover
open={open}
onOpenChange={(newOpen) => {
if (readonly) return
if (readonly && newOpen) return
setOpen(newOpen)
}}
>
<div className="relative">
{isSplitTrigger ? (
<div className="flex h-8 min-w-74 items-center gap-px overflow-hidden rounded-lg">
<div className="min-w-0 flex-1">
<ModelSelector
defaultModel={
value?.provider || value?.model
? { provider: value?.provider, model: value?.model }
: undefined
}
modelList={scopedModelList}
readonly={readonly}
scopeFeatures={scopeFeatures}
triggerClassName={cn(
'h-8! w-full rounded-r-none!',
isInWorkflow &&
'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg',
)}
onSelect={handleChangeModel}
/>
</div>
<PopoverTrigger
aria-label={t(($) => $['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',
)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</PopoverTrigger>
</div>
) : (
<PopoverTrigger
render={(props, state) => (
<button
{...props}
type="button"
className={cn(
'block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]',
props.className,
)}
>
{renderTrigger ? (
renderTrigger({
open: state.open,
currentProvider,
currentModel,
providerName: value?.provider,
modelId: value?.model,
})
) : (
<AgentModelTrigger
disabled={disabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
scope={scope}
/>
)}
</button>
)}
<div className="isolate flex h-8 min-w-74 items-center gap-px rounded-lg">
<SplitModelSelector
value={selectedModel}
models={scopedModelList}
disabled={readonly}
scopeFeatures={scopeFeatures}
surface={isInWorkflow ? 'workflow' : 'default'}
onValueChange={handleChangeModel}
/>
)}
<ModelSettingsTrigger
disabled={readonly || !hasSelectedModel || modelSettingsDisabled}
surface={isInWorkflow ? 'workflow' : 'default'}
/>
</div>
<PopoverContent
placement={isInWorkflow ? 'left' : 'bottom-end'}
sideOffset={4}
popupClassName={cn(popupClassName, 'w-97.25 rounded-2xl')}
>
<div className="max-h-105 overflow-y-auto p-4 pt-3">
{!isSplitTrigger && (
<div className="relative">
<div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">
{t(($) => $['modelProvider.model'], { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={
hasSelectedModel ? { provider: value.provider, model: value.model } : undefined
}
modelList={scopedModelList}
scopeFeatures={scopeFeatures}
onSelect={handleChangeModel}
/>
</div>
)}
{!isSplitTrigger &&
(currentModel?.model_type === ModelTypeEnum.textGeneration ||
currentModel?.model_type === ModelTypeEnum.tts) && (
<div className="my-3 h-px bg-divider-subtle" />
)}
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
{currentModel?.model_type === ModelTypeEnum.textGeneration && selectedModel && (
<LLMParamsPanel
provider={value?.provider}
modelId={value?.model}
provider={selectedModel.provider}
modelId={selectedModel.model}
completionParams={value?.completion_params || {}}
onCompletionParamsChange={handleLLMParamsChange}
isAdvancedMode={isAdvancedMode}
/>
)}
{currentModel?.model_type === ModelTypeEnum.tts && (
{currentModel?.model_type === ModelTypeEnum.tts && selectedModel && (
<TTSParamsPanel
currentModel={currentModel}
language={value?.language}
voice={value?.voice}
language={value?.language ?? ''}
voice={value?.voice ?? ''}
onChange={handleTTSParamsChange}
/>
)}

View File

@ -255,7 +255,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
validating={false}
showOnVariableMap={{}}
isEditMode={true}
isAgentStrategy={true}
fieldLabelClassName="uppercase"
customRenderField={renderField}
override={override}

View File

@ -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) => (
<div>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:
{modelList.length}
{value ? `${value.provider}/${value.model}` : 'no-model'}:{models.length}
</div>
),
}))

View File

@ -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[]
}) => (
<div>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:
{modelList.length}
{value ? `${value.provider}/${value.model}` : 'no-model'}:{models.length}
</div>
),
}))
@ -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(<ModelBar />)
const emptySelector = screen.getByText((_, element) => element?.textContent === 'no-model:0')

View File

@ -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<ModelBarProps> = (props) => {
render={
<div className="relative" aria-label={tooltip}>
<ModelSelector
modelList={[]}
triggerClassName="bg-workflow-block-parma-bg h-6! rounded-md!"
defaultModel={undefined}
models={[]}
value={undefined}
size="small"
surface="workflow"
showDeprecatedWarnIcon={false}
readonly
deprecatedClassName="opacity-50"
disabled
/>
<StatusDot status="error" className="absolute -top-0.5 -right-0.5" />
</div>
@ -78,15 +78,15 @@ export const ModelBar: FC<ModelBarProps> = (props) => {
const modelSelector = (
<div className="relative" aria-label={showWarn ? modelNotInstalledTooltip : undefined}>
<ModelSelector
modelList={modelList}
triggerClassName="bg-workflow-block-parma-bg h-6! rounded-md!"
defaultModel={{
models={modelList}
value={{
provider: props.provider,
model: props.model,
}}
size="small"
surface="workflow"
showDeprecatedWarnIcon={false}
readonly
deprecatedClassName="opacity-50"
disabled
/>
{showWarn && <StatusDot status="error" className="absolute -top-0.5 -right-0.5" />}
</div>

View File

@ -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(<EmbeddingModel embeddingModel="text-embedding-3-large" />)
expect(mockModelSelector).toHaveBeenCalledWith(
expect.objectContaining({
defaultModel: undefined,
value: undefined,
}),
undefined,
)

View File

@ -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"
/>
<ModelSelector
defaultModel={
value={
embeddingModelConfig && {
provider: embeddingModelConfig.providerName,
model: embeddingModelConfig.modelName,
}
}
modelList={embeddingModelList}
onSelect={handleEmbeddingModelChange}
readonly={readonly}
models={embeddingModelList}
onValueChange={handleEmbeddingModelChange}
disabled={readonly}
showDeprecatedWarnIcon
/>
</Field>

View File

@ -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) => (
<div>
<div data-testid="default-model">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-default-model'}
<div data-testid="model-value">
{value ? `${value.provider}/${value.model}` : 'no-model-value'}
</div>
<div data-testid="model-list-count">{modelList.length}</div>
<button type="button" onClick={() => onSelect?.({ provider: 'cohere', model: 'rerank-v3' })}>
<div data-testid="models-count">{models.length}</div>
<button
type="button"
onClick={() => onValueChange?.({ provider: 'cohere', model: 'rerank-v3' })}
>
select-model
</button>
</div>
@ -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(
<RerankingModelSelector
rerankingModel={{
@ -69,11 +72,11 @@ describe('RerankingModelSelector', () => {
/>,
)
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(
<RerankingModelSelector
rerankingModel={{
@ -83,7 +86,7 @@ describe('RerankingModelSelector', () => {
/>,
)
expect(screen.getByTestId('default-model')).toHaveTextContent('cohere/rerank-v3')
expect(screen.getByTestId('model-value')).toHaveTextContent('cohere/rerank-v3')
})
})

View File

@ -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<typeof actual.useModelListAndDefaultModel>
) => mockUseModelListAndDefaultModel(...args),
}
},
)
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
vi.mock('../reranking-model-selector', () => ({
default: () => <button type="button">plugin.detailPanel.configureModel</button>,
}))
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<SVGProps<SVGSVGElement>> = (props) => (
<svg aria-hidden="true" {...props} />
)
@ -128,27 +101,6 @@ function renderSearchMethodOption(props: ReturnType<typeof createProps>) {
}
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()

View File

@ -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 (
<ModelSelector
defaultModel={rerankModel}
modelList={rerankModelList}
onSelect={handleRerankingModelChange}
readonly={readonly}
value={rerankModel}
models={rerankModelList}
onValueChange={handleRerankingModelChange}
disabled={readonly}
showDeprecatedWarnIcon
/>
)

View File

@ -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 } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div>{value ? `${value.provider}:${value.model}` : 'no-model'}</div>
),
}))
@ -47,7 +46,7 @@ describe('llm/node', () => {
} as unknown as ReturnType<typeof useTextGenerationCurrentProviderAndModelAndModelList>)
})
it('renders the readonly model selector when a model is configured', () => {
it('renders the disabled model selector when a model is configured', () => {
render(<Node id="llm-node" data={createData()} />)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()

View File

@ -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<NodeProps<LLMNodeType>> = ({ data }) => {
const { provider, name: modelId } = data.model || {}
@ -16,10 +16,10 @@ const Node: FC<NodeProps<LLMNodeType>> = ({ data }) => {
<div className="mb-1 px-3 py-1">
{hasSetModel && (
<ModelSelector
defaultModel={{ provider, model: modelId }}
modelList={textGenerationModelList}
triggerClassName="h-6! rounded-md!"
readonly
value={{ provider, model: modelId }}
models={textGenerationModelList}
size="small"
disabled
/>
)}
</div>

View File

@ -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 } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div>{value ? `${value.provider}:${value.model}` : 'no-model'}</div>
),
}))

View File

@ -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 } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div>{value ? `${value.provider}:${value.model}` : 'no-model'}</div>
),
}))
@ -49,7 +48,7 @@ describe('parameter-extractor/node', () => {
} as unknown as ReturnType<typeof useTextGenerationCurrentProviderAndModelAndModelList>)
})
it('renders the readonly model selector when a model is configured', () => {
it('renders the disabled model selector when a model is configured', () => {
render(<Node id="node-1" data={createData()} />)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()

View File

@ -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<NodeProps<ParameterExtractorNodeType>> = ({ data }) => {
const { provider, name: modelId } = data.model || {}
@ -13,10 +13,10 @@ const Node: FC<NodeProps<ParameterExtractorNodeType>> = ({ data }) => {
<div className="mb-1 px-3 py-1">
{hasSetModel && (
<ModelSelector
defaultModel={{ provider, model: modelId }}
modelList={textGenerationModelList}
triggerClassName="h-6! rounded-md!"
readonly
value={{ provider, model: modelId }}
models={textGenerationModelList}
size="small"
disabled
/>
)}
</div>

View File

@ -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) => (
<div>
{defaultModel.provider}:{defaultModel.model}
{value.provider}:{value.model}
</div>
),
}))

View File

@ -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 } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div>{value ? `${value.provider}:${value.model}` : 'no-model'}</div>
),
}))

View File

@ -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<NodeProps<QuestionClassifierNodeType>> = (props) => {
<div className="mb-1 px-3 py-1">
{hasSetModel && (
<ModelSelector
defaultModel={{ provider, model: modelId }}
triggerClassName="h-6! rounded-md!"
modelList={textGenerationModelList}
readonly
value={{ provider, model: modelId }}
models={textGenerationModelList}
size="small"
disabled
/>
)}
{!!topics.length && (

View File

@ -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> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',

View File

@ -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<string, string>) => void
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
activeTextGenerationModelList: Model[]
activeTextGenerationModelList: ModelSelectorProvider[]
}) => {
const emitVarsAndFeaturesUpdate = useCallback(
async (syncWorkflowDraft = false) => {

View File

@ -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

View File

@ -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<ModelParameterModalProps, 'setModel'>) => (
<button
type="button"
onClick={() =>
setModel({
modelId: 'claude-sonnet-4-5',
provider: 'langgenius/anthropic/anthropic',
plugin_id: 'langgenius/anthropic',
})
}
>
Select Anthropic
</button>
),
}),
)
describe('AgentModelField', () => {
it('replaces model-owned fields when selecting a model from another provider', () => {
const onSelect = vi.fn()
const currentModel: ComponentProps<typeof AgentModelField>['currentModel'] = {
provider: 'langgenius/openai/openai',
model: 'gpt-4.1',
plugin_id: 'langgenius/openai',
model_settings: { temperature: 0.7 },
}
render(
<AgentModelField
currentModel={currentModel}
textGenerationModelList={[]}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Select Anthropic' }))
expect(onSelect).toHaveBeenCalledWith({
provider: 'langgenius/anthropic/anthropic',
model: 'claude-sonnet-4-5',
plugin_id: 'langgenius/anthropic',
})
})
})

View File

@ -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 (
<Field name="model" className="gap-1 pb-4">
<FieldLabel className="py-0 system-sm-semibold-uppercase! text-text-secondary">
{t(($) => $['agentDetail.configure.model.label'])}
</FieldLabel>
<div className="relative h-8 min-w-0">
{readOnly ? (
<div className="flex h-8 w-full min-w-0 items-center rounded-lg bg-components-input-bg-disabled px-3 system-sm-regular text-components-input-text-filled">
<span className="truncate">{currentModel?.model}</span>
</div>
) : (
<div className="flex h-8 min-w-0 items-center gap-px overflow-hidden rounded-lg">
<ModelSelector
defaultModel={currentModel}
modelList={textGenerationModelList}
triggerClassName="h-8! w-full rounded-r-none! [&_.i-ri-arrow-down-s-line]:hidden"
popupClassName="w-(--anchor-width) max-w-[min(var(--anchor-width),var(--available-width),calc(100vw-32px))]"
showModelMeta={false}
modelPredicate={isAgentCompatibleModel}
modelSuggestionPredicate={isAgentSuggestedModel}
onSelect={onSelect}
/>
<div className="w-8 shrink-0">
<ModelParameterModal
isAdvancedMode
modelId={currentModel?.model ?? ''}
provider={currentModel?.provider ?? ''}
completionParams={(currentModel?.model_settings ?? {}) as FormValue}
readonly={!canConfigureModelSettings}
hideDebugWithMultipleModel
popupClassName="w-[400px]"
setModel={({ modelId, provider }) => {
onSelect({
...currentModel,
provider,
model: modelId,
})
}}
onCompletionParamsChange={(modelSettings) => {
if (!currentModel) return
{readOnly ? (
<div className="flex h-8 w-full min-w-0 items-center rounded-lg bg-components-input-bg-disabled px-3 system-sm-regular text-components-input-text-filled">
<span className="truncate">{currentModel?.model}</span>
</div>
) : (
<ModelParameterModal
isAdvancedMode
modelId={currentModel?.model ?? ''}
provider={currentModel?.provider ?? ''}
completionParams={(currentModel?.model_settings ?? {}) as FormValue}
hideDebugWithMultipleModel
modelList={textGenerationModelList}
showModelMeta={false}
modelPredicate={isAgentCompatibleModel}
modelSuggestionPredicate={isAgentSuggestedModel}
placement="bottom-end"
setModel={({ modelId, provider, plugin_id }) => {
onSelect({
provider,
model: modelId,
plugin_id,
})
}}
onCompletionParamsChange={(modelSettings) => {
if (!currentModel) return
onSelect({
...currentModel,
model_settings: modelSettings,
})
}}
renderTrigger={() => (
<Tooltip>
<TooltipTrigger
disabled={!canConfigureModelSettings}
render={
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg bg-components-button-tertiary-bg text-text-tertiary hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled">
<span className="sr-only">
{tCommon(($) => $['modelProvider.modelSettings'])}
</span>
<span className="i-ri-equalizer-2-line size-4" />
</span>
}
/>
<TooltipContent placement="top">
{tCommon(($) => $['modelProvider.modelSettings'])}
</TooltipContent>
</Tooltip>
)}
/>
</div>
</div>
)}
</div>
onSelect({
...currentModel,
model_settings: modelSettings,
})
}}
/>
)}
</Field>
)
}

View File

@ -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))
}

View File

@ -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<LoginResponse>(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)
}

View File

@ -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,
})

View File

@ -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,

View File

@ -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),
)
}