mirror of
https://github.com/langgenius/dify.git
synced 2026-08-15 04:59:46 +08:00
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:
parent
c1bde25bcc
commit
1cf8db8395
@ -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
|
||||
|
||||
@ -139,6 +139,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
<>
|
||||
<ModelParameterModal
|
||||
isAdvancedMode={isAdvancedMode}
|
||||
modelSelectorPopupClassName="w-108"
|
||||
provider={modelConfig.provider}
|
||||
completionParams={contextValue.completionParams}
|
||||
modelId={modelConfig.model_id}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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],
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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 }
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -255,7 +255,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
validating={false}
|
||||
showOnVariableMap={{}}
|
||||
isEditMode={true}
|
||||
isAgentStrategy={true}
|
||||
fieldLabelClassName="uppercase"
|
||||
customRenderField={renderField}
|
||||
override={override}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user