mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix(web): split model selection and settings actions (#38797)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
6e59c723c9
commit
594c21d98c
@ -3704,10 +3704,10 @@
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx": {
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import type { CredentialPanelState } from '../../provider-added-card/use-credential-panel-state'
|
||||
import { ModelStatusEnum } from '../../declarations'
|
||||
import { deriveTriggerStatus } from '../derive-trigger-status'
|
||||
|
||||
const baseCredentialState: CredentialPanelState = {
|
||||
variant: 'api-active',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
}
|
||||
|
||||
const mockProvider = { provider: 'openai' } as ModelProvider
|
||||
const mockModel = { model: 'gpt-4', status: ModelStatusEnum.active } as ModelItem
|
||||
|
||||
describe('deriveTriggerStatus', () => {
|
||||
it('returns empty when modelId is missing', () => {
|
||||
expect(deriveTriggerStatus(undefined, 'openai', mockProvider, mockModel, baseCredentialState)).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns empty when providerName is missing', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', undefined, mockProvider, mockModel, baseCredentialState)).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns incompatible when provider plugin is not installed', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', undefined, mockModel, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns credits-exhausted when credits priority and exhausted', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('returns active when credits priority but not exhausted', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: false,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
|
||||
})
|
||||
|
||||
it('returns api-key-unavailable when variant is api-unavailable', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('returns incompatible when currentModel is missing (deprecated)', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns credits-exhausted when currentModel is missing and AI credits are exhausted without api key', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('returns configure-required when model status is no-configure', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.noConfigure } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('configure-required')
|
||||
})
|
||||
|
||||
it('returns incompatible when model status is noPermission', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.noPermission } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns disabled when model status is disabled', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.disabled } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('disabled')
|
||||
})
|
||||
|
||||
it('returns active when all conditions are satisfied', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, baseCredentialState)).toBe('active')
|
||||
})
|
||||
|
||||
it('prioritises credits-exhausted over api-unavailable', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
variant: 'api-unavailable',
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('does not return credits-exhausted when supportsCredits is false', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'credits',
|
||||
isCreditsExhausted: true,
|
||||
supportsCredits: false,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
|
||||
})
|
||||
})
|
||||
@ -123,6 +123,7 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
})
|
||||
|
||||
describe('ModelParameterModal', () => {
|
||||
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i }))
|
||||
const defaultProps = {
|
||||
isAdvancedMode: false,
|
||||
modelId: 'gpt-3.5-turbo',
|
||||
@ -179,14 +180,42 @@ describe('ModelParameterModal', () => {
|
||||
it('should render trigger and open modal content when trigger is clicked', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep model selection and model settings as separate actions', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||
|
||||
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||
modelId: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
mode: 'chat',
|
||||
features: ['vision', 'tool-call'],
|
||||
})
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i }))
|
||||
|
||||
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable model settings when no model is selected', () => {
|
||||
render(<ModelParameterModal {...defaultProps} provider="" modelId="" />)
|
||||
|
||||
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} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
fireEvent.click(screen.getByText('Change'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
@ -206,7 +235,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should call onCompletionParamsChange when preset is selected', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
@ -227,14 +256,14 @@ describe('ModelParameterModal', () => {
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||
|
||||
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||
@ -247,7 +276,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should toggle debug mode when debug footer is clicked', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
|
||||
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
|
||||
})
|
||||
@ -256,7 +285,7 @@ describe('ModelParameterModal', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -271,7 +300,7 @@ describe('ModelParameterModal', () => {
|
||||
modelId=""
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
@ -279,14 +308,14 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no parameter items when rules are undefined', () => {
|
||||
parameterRules = undefined
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
@ -304,7 +333,7 @@ describe('ModelParameterModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
const paramEl = screen.getByTestId('param-temperature')
|
||||
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
|
||||
@ -343,7 +372,7 @@ describe('ModelParameterModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
|
||||
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
|
||||
@ -355,7 +384,7 @@ describe('ModelParameterModal', () => {
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
openSettings()
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,322 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Trigger from '../trigger'
|
||||
|
||||
const mockUseCredentialPanelState = vi.fn()
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: () => mockUseCredentialPanelState(),
|
||||
}))
|
||||
|
||||
vi.mock('../../model-icon', () => ({
|
||||
default: () => <div data-testid="model-icon">Icon</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../model-name', () => ({
|
||||
default: ({
|
||||
modelItem,
|
||||
showMode,
|
||||
showFeatures,
|
||||
}: {
|
||||
modelItem: { model: string }
|
||||
showMode?: boolean
|
||||
showFeatures?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<span>{modelItem.model}</span>
|
||||
{showMode && <span data-testid="model-name-mode">mode</span>}
|
||||
{showFeatures && <span data-testid="model-name-features">features</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const activeCredentialState = {
|
||||
variant: 'api-active' as const,
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey' as const,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
}
|
||||
|
||||
describe('Trigger', () => {
|
||||
const currentProvider = {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
} as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
||||
|
||||
const currentModel = {
|
||||
model: 'gpt-4',
|
||||
status: 'active',
|
||||
} as unknown as ComponentProps<typeof Trigger>['currentModel']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCredentialPanelState.mockReturnValue(activeCredentialState)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render active state with model features in non-workflow mode', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name-mode')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-name-features')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback model id when current model is missing', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="openai"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render split layout with workflow styles when workflow mode is enabled', () => {
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
isInWorkflow
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
const leftPanel = container.querySelector('.rounded-l-lg')
|
||||
expect(leftPanel).toBeInTheDocument()
|
||||
expect(leftPanel).toHaveClass('border-workflow-block-parma-bg')
|
||||
const rightPanel = container.querySelector('.rounded-r-lg')
|
||||
expect(rightPanel).toBeInTheDocument()
|
||||
expect(rightPanel).toHaveClass('border-workflow-block-parma-bg')
|
||||
})
|
||||
|
||||
it('should render empty state when no provider or model is selected', () => {
|
||||
render(<Trigger isInWorkflow />)
|
||||
|
||||
expect(screen.getByText('workflow.errorMsg.configureModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-workflow empty state with warning border', () => {
|
||||
const { container } = render(<Trigger />)
|
||||
|
||||
expect(screen.getByText('workflow.errorMsg.configureModel')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('border-text-warning')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status badges', () => {
|
||||
it('should render credits exhausted badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credits exhausted badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when model is deprecated (currentModel missing)', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credits exhausted badge when model is missing and AI credits are exhausted without api key', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'no-usage',
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure required badge when model status is no-configure', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'no-configure' } as typeof currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled badge when model status is disabled', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'disabled' } as typeof currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when provider plugin is not installed', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="unknown-provider"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Split layout', () => {
|
||||
it('should use split layout with settings button in non-workflow mode', () => {
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
const splitContainer = container.querySelector('.rounded-l-lg')
|
||||
expect(splitContainer).toBeInTheDocument()
|
||||
const settingsButton = container.querySelector('.rounded-r-lg')
|
||||
expect(settingsButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use split layout for error states in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
const splitContainer = container.querySelector('.rounded-l-lg')
|
||||
expect(splitContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,9 +0,0 @@
|
||||
import {
|
||||
DERIVED_MODEL_STATUS_BADGE_I18N,
|
||||
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
|
||||
deriveModelStatus,
|
||||
} from '../derive-model-status'
|
||||
|
||||
export const deriveTriggerStatus = deriveModelStatus
|
||||
export const TRIGGER_STATUS_BADGE_I18N = DERIVED_MODEL_STATUS_BADGE_I18N
|
||||
export const TRIGGER_STATUS_TOOLTIP_I18N = DERIVED_MODEL_STATUS_TOOLTIP_I18N
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
ModelParameterRule,
|
||||
} from '../declarations'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import type { TriggerProps } from './trigger'
|
||||
import type { TriggerProps } from './types'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
@ -20,7 +20,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@ -33,7 +33,6 @@ import ModelSelector from '../model-selector'
|
||||
import ParameterItem from './parameter-item'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import { getSupportedPresetConfig } from './presets-parameter-utils'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
popupClassName?: string
|
||||
@ -73,7 +72,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isLoading,
|
||||
@ -134,6 +132,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const hasSelectedModel = !!provider && !!modelId
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@ -143,37 +143,52 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]">
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
{renderTrigger
|
||||
? (
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]">
|
||||
{renderTrigger({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: provider,
|
||||
modelId,
|
||||
})
|
||||
: (
|
||||
<Trigger
|
||||
isInWorkflow={isInWorkflow}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={provider}
|
||||
modelId={modelId}
|
||||
settingsRef={settingsIconRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
readonly={readonly}
|
||||
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>
|
||||
)}
|
||||
<PopoverContent
|
||||
placement={isInWorkflow ? 'left' : (renderTrigger ? 'bottom-end' : 'left-start')}
|
||||
sideOffset={4}
|
||||
popupClassName={cn(popupClassName, 'w-[400px] rounded-2xl')}
|
||||
positionerProps={!renderTrigger ? { anchor: settingsIconRef } : undefined}
|
||||
>
|
||||
<div className="relative px-3 pt-3.5 pb-1">
|
||||
<div className="pr-8 pl-1 system-xl-semibold text-text-primary">
|
||||
@ -184,17 +199,19 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
</PopoverClose>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
<div className="px-4 pt-2 pb-4">
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
onHide={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{renderTrigger && (
|
||||
<div className="px-4 pt-2 pb-4">
|
||||
<ModelSelector
|
||||
defaultModel={hasSelectedModel ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
onHide={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
!!parameterRules.length && (
|
||||
<div className="flex flex-col gap-2 border-t border-divider-subtle px-4 pt-3 pb-4">
|
||||
<div className={cn('flex flex-col gap-2 px-4 pt-3 pb-4', renderTrigger && 'border-t border-divider-subtle')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t($ => $['modelProvider.parameters'], { ns: 'common' })}</div>
|
||||
{
|
||||
|
||||
@ -1,138 +0,0 @@
|
||||
import type { FC, Ref } from 'react'
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
deriveTriggerStatus,
|
||||
TRIGGER_STATUS_BADGE_I18N,
|
||||
TRIGGER_STATUS_TOOLTIP_I18N,
|
||||
} from './derive-trigger-status'
|
||||
|
||||
export type TriggerProps = {
|
||||
open?: boolean
|
||||
currentProvider?: ModelProvider | Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
isInWorkflow?: boolean
|
||||
settingsRef?: Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
const Trigger: FC<TriggerProps> = ({
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName,
|
||||
modelId,
|
||||
isInWorkflow,
|
||||
settingsRef,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const currentModelProvider = modelProviders.find(p => p.provider === providerName)
|
||||
const credentialState = useCredentialPanelState(currentModelProvider)
|
||||
const status = deriveTriggerStatus(modelId, providerName, currentModelProvider, currentModel, credentialState)
|
||||
const badgeKey = TRIGGER_STATUS_BADGE_I18N[status as keyof typeof TRIGGER_STATUS_BADGE_I18N]
|
||||
const tooltipKey = TRIGGER_STATUS_TOOLTIP_I18N[status as keyof typeof TRIGGER_STATUS_TOOLTIP_I18N]
|
||||
const badgeLabel = badgeKey ? t($ => $[badgeKey], { ns: 'common' }) : null
|
||||
const tooltipLabel = tooltipKey ? t($ => $[tooltipKey], { ns: 'common' }) : null
|
||||
const isActive = status === 'active'
|
||||
const iconProvider = currentProvider || modelProviders.find(item => item.provider === providerName)
|
||||
|
||||
if (status === 'empty') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 min-w-[296px] cursor-pointer items-center rounded-lg px-2',
|
||||
isInWorkflow
|
||||
? 'border border-text-warning bg-state-warning-hover pr-[30px]'
|
||||
: 'border border-text-warning bg-state-warning-hover ring-text-warning ring-inset hover:ring-[0.5px]',
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 flex size-6 shrink-0 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>
|
||||
<div className="mr-1 flex-1 truncate text-[13px] font-normal text-text-secondary">
|
||||
{t($ => $['errorMsg.configureModel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<span className={cn('i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary', isInWorkflow && 'absolute top-[9px] right-2 h-3.5 w-3.5')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 min-w-[296px] cursor-pointer items-center gap-px overflow-hidden rounded-lg">
|
||||
<div className={cn('flex flex-1 items-center gap-0.5 rounded-l-lg p-1', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-input-bg-normal')}>
|
||||
<ModelIcon
|
||||
className="p-0.5"
|
||||
provider={iconProvider}
|
||||
modelName={currentModel?.model || modelId}
|
||||
/>
|
||||
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
|
||||
{currentModel
|
||||
? (
|
||||
<ModelName
|
||||
className="grow"
|
||||
modelItem={currentModel}
|
||||
showMode={isActive}
|
||||
showFeatures={isActive}
|
||||
/>
|
||||
)
|
||||
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
|
||||
</div>
|
||||
{badgeKey && (
|
||||
tooltipLabel
|
||||
? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex shrink-0 items-center pr-0.5">
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill size-3 text-text-warning" />
|
||||
<span className="system-xs-medium whitespace-nowrap text-text-warning">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<div className="flex shrink-0 items-center pr-0.5">
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill size-3 text-text-warning" />
|
||||
<span className="system-xs-medium whitespace-nowrap text-text-warning">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!badgeKey && (
|
||||
<div className="flex shrink-0 items-center pr-1">
|
||||
<span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={settingsRef} className={cn('flex shrink-0 items-center justify-center rounded-r-lg p-2', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-button-tertiary-bg')}>
|
||||
<span className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Trigger
|
||||
@ -0,0 +1,13 @@
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
|
||||
export type TriggerProps = {
|
||||
open?: boolean
|
||||
currentProvider?: ModelProvider | Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
}
|
||||
@ -70,49 +70,41 @@ vi.mock('@/utils/completion-params', () => ({
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, modelList, scopeFeatures, onSelect }: {
|
||||
default: ({ defaultModel, modelList, scopeFeatures, triggerClassName, readonly, onSelect }: {
|
||||
defaultModel?: { provider?: string, model?: string }
|
||||
modelList?: Model[]
|
||||
scopeFeatures?: string[]
|
||||
triggerClassName?: string
|
||||
readonly?: boolean
|
||||
onSelect?: (model: { provider: string, model: string }) => void
|
||||
}) => (
|
||||
<div
|
||||
data-testid="model-selector"
|
||||
data-default-model={JSON.stringify(defaultModel)}
|
||||
data-model-list-count={modelList?.length || 0}
|
||||
data-scope-features={JSON.stringify(scopeFeatures)}
|
||||
onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })}
|
||||
>
|
||||
Model Selector
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger', () => ({
|
||||
default: ({ currentProvider, currentModel, providerName, modelId, isInWorkflow }: {
|
||||
currentProvider?: Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
isInWorkflow?: boolean
|
||||
}) => {
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
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 modelDisabled = currentModel?.status !== ModelStatusEnum.active
|
||||
const disabled = !mockProviderContextValue.isAPIKeySet || hasDeprecated || modelDisabled
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="trigger"
|
||||
data-disabled={disabled}
|
||||
data-disabled={readonly || hasDeprecated || modelDisabled}
|
||||
data-has-deprecated={hasDeprecated}
|
||||
data-model-disabled={modelDisabled}
|
||||
data-provider={providerName}
|
||||
data-model={modelId}
|
||||
data-in-workflow={isInWorkflow}
|
||||
data-provider={defaultModel?.provider}
|
||||
data-model={defaultModel?.model}
|
||||
data-in-workflow={triggerClassName?.includes('workflow-block-parma-bg')}
|
||||
data-has-current-provider={!!currentProvider}
|
||||
data-has-current-model={!!currentModel}
|
||||
>
|
||||
Trigger
|
||||
<button
|
||||
type="button"
|
||||
data-testid="model-selector"
|
||||
data-default-model={JSON.stringify(defaultModel)}
|
||||
data-model-list-count={modelList?.length || 0}
|
||||
data-scope-features={JSON.stringify(scopeFeatures)}
|
||||
onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })}
|
||||
>
|
||||
Model Selector
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -255,6 +247,8 @@ const setupModelLists = (config: {
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('ModelParameterModal', () => {
|
||||
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderContextValue.isAPIKeySet = true
|
||||
@ -287,6 +281,16 @@ describe('ModelParameterModal', () => {
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep model selection and model settings as separate actions', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument()
|
||||
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 })
|
||||
@ -339,20 +343,25 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model selector inside portal content when open', async () => {
|
||||
it('should render model settings inside portal content when open', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const model = createModel({
|
||||
provider: 'openai',
|
||||
models: [createModelItem({ model: 'gpt-4' })],
|
||||
})
|
||||
setupModelLists({ textGeneration: [model] })
|
||||
const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } })
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('llm-params-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -383,11 +392,19 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should apply popupClassName to portal content', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ popupClassName: 'custom-popup-class' })
|
||||
const model = createModel({
|
||||
provider: 'openai',
|
||||
models: [createModelItem({ model: 'gpt-4' })],
|
||||
})
|
||||
setupModelLists({ textGeneration: [model] })
|
||||
const props = createDefaultProps({
|
||||
popupClassName: 'custom-popup-class',
|
||||
value: { provider: 'openai', model: 'gpt-4' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -403,7 +420,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
const selector = screen.getByTestId('model-selector')
|
||||
@ -413,19 +430,24 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// ==================== State Management ====================
|
||||
describe('State Management', () => {
|
||||
it('should toggle open state when trigger is clicked', async () => {
|
||||
it('should toggle model settings when the settings button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const model = createModel({
|
||||
provider: 'openai',
|
||||
models: [createModelItem({ model: 'gpt-4' })],
|
||||
})
|
||||
setupModelLists({ textGeneration: [model] })
|
||||
const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } })
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('llm-params-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -434,16 +456,10 @@ describe('ModelParameterModal', () => {
|
||||
const props = createDefaultProps({ readonly: true })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<ModelParameterModal {...props} />)
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Force a re-render to ensure state is stable
|
||||
rerender(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert - open state should remain false due to readonly
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled()
|
||||
expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -455,7 +471,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -470,7 +486,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -493,7 +509,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -511,7 +527,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -528,7 +544,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -545,7 +561,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -562,7 +578,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -579,7 +595,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -596,7 +612,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -613,7 +629,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -735,7 +751,7 @@ describe('ModelParameterModal', () => {
|
||||
})
|
||||
|
||||
describe('Memoization - disabled', () => {
|
||||
it('should set disabled to true when isAPIKeySet is false', () => {
|
||||
it('should keep model selection available when isAPIKeySet is false', () => {
|
||||
// Arrange
|
||||
mockProviderContextValue.isAPIKeySet = false
|
||||
const model = createModel({
|
||||
@ -749,7 +765,7 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true')
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
|
||||
it('should set disabled to true when hasDeprecated is true', () => {
|
||||
@ -812,7 +828,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -837,7 +853,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -869,7 +885,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -894,7 +910,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -928,7 +944,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId('llm-params-panel')
|
||||
@ -965,7 +981,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId('tts-params-panel')
|
||||
@ -1002,7 +1018,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1028,7 +1044,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1054,7 +1070,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1063,7 +1079,7 @@ describe('ModelParameterModal', () => {
|
||||
expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when model type is textGeneration or tts', async () => {
|
||||
it('should not render a selector divider inside split model settings', async () => {
|
||||
// Arrange
|
||||
const textGenModel = createModel({
|
||||
provider: 'openai',
|
||||
@ -1081,11 +1097,11 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-divider-subtle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1101,7 +1117,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'true')
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'false')
|
||||
})
|
||||
|
||||
it('should handle undefined value', () => {
|
||||
@ -1122,7 +1138,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1161,7 +1177,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1240,7 +1256,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1256,7 +1272,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert - defaultModel is created with undefined provider
|
||||
await waitFor(() => {
|
||||
@ -1273,7 +1289,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert - defaultModel is created with undefined model
|
||||
await waitFor(() => {
|
||||
@ -1290,7 +1306,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
// Assert - when defaultModel is undefined, attribute is not set (returns null)
|
||||
await waitFor(() => {
|
||||
@ -1326,7 +1342,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
openSettings()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model-list-count', '1')
|
||||
@ -1341,7 +1357,7 @@ describe('ModelParameterModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should update disabled state when isAPIKeySet changes', () => {
|
||||
it('should keep selector state independent from isAPIKeySet changes', () => {
|
||||
// Arrange
|
||||
const model = createModel({
|
||||
provider: 'openai',
|
||||
@ -1359,7 +1375,7 @@ describe('ModelParameterModal', () => {
|
||||
rerender(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true')
|
||||
expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import type {
|
||||
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/trigger'
|
||||
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
@ -22,7 +22,6 @@ 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 Trigger from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
|
||||
@ -174,6 +173,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const hasSelectedModel = !!value?.provider && !!value?.model
|
||||
const isSplitTrigger = !renderTrigger && !isAgentStrategy
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@ -184,20 +186,47 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]">
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: value?.provider,
|
||||
modelId: value?.model,
|
||||
})
|
||||
: (isAgentStrategy
|
||||
? (
|
||||
{isSplitTrigger
|
||||
? (
|
||||
<div className="flex h-8 min-w-[296px] 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={(
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]">
|
||||
{renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: value?.provider,
|
||||
modelId: value?.model,
|
||||
})
|
||||
: (
|
||||
<AgentModelTrigger
|
||||
disabled={disabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
@ -207,39 +236,31 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
modelId={value?.model}
|
||||
scope={scope}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Trigger
|
||||
isInWorkflow={isInWorkflow}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
modelId={value?.model}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<PopoverContent
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
sideOffset={4}
|
||||
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
|
||||
<div className="relative">
|
||||
<div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">
|
||||
{t($ => $['modelProvider.model'], { ns: 'common' }).toLocaleUpperCase()}
|
||||
{!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>
|
||||
<ModelSelector
|
||||
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
|
||||
modelList={scopedModelList}
|
||||
scopeFeatures={scopeFeatures}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
</div>
|
||||
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
|
||||
)}
|
||||
{!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 && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user