mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
@ -1,7 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ModelParameterModal from '../index'
|
||||
|
||||
let isAPIKeySet = true
|
||||
let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@ -40,7 +39,7 @@ let activeTextGenerationModelList: Array<Record<string, unknown>> = [
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
isAPIKeySet,
|
||||
isAPIKeySet: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -50,6 +49,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -62,12 +62,18 @@ vi.mock('../../hooks', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../parameter-item', () => ({
|
||||
default: ({ parameterRule, onChange, onSwitch }: {
|
||||
default: ({ parameterRule, onChange, onSwitch, nodesOutputVars, availableNodes }: {
|
||||
parameterRule: { name: string, label: { en_US: string } }
|
||||
onChange: (v: number) => void
|
||||
onSwitch: (checked: boolean, val: unknown) => void
|
||||
nodesOutputVars?: unknown[]
|
||||
availableNodes?: unknown[]
|
||||
}) => (
|
||||
<div data-testid={`param-${parameterRule.name}`}>
|
||||
<div
|
||||
data-testid={`param-${parameterRule.name}`}
|
||||
data-has-nodes-output-vars={!!nodesOutputVars}
|
||||
data-has-available-nodes={!!availableNodes}
|
||||
>
|
||||
{parameterRule.label.en_US}
|
||||
<button onClick={() => onChange(0.9)}>Change</button>
|
||||
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
|
||||
@ -119,7 +125,6 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAPIKeySet = true
|
||||
isRulesLoading = false
|
||||
parameterRules = [
|
||||
{
|
||||
@ -233,6 +238,26 @@ describe('ModelParameterModal', () => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass nodesOutputVars and availableNodes to ParameterItem', () => {
|
||||
const mockNodesOutputVars = [{ nodeId: 'n1', title: 'Node', vars: [] }]
|
||||
const mockAvailableNodes = [{ id: 'n1', data: { title: 'Node', type: 'llm' } }]
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars as never}
|
||||
availableNodes={mockAvailableNodes as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
const paramEl = screen.getByTestId('param-temperature')
|
||||
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
|
||||
expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true')
|
||||
})
|
||||
|
||||
it('should support custom triggers, workflow mode, and missing default model values', async () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { ModelParameterRule } from '../../declarations'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import ParameterItem from '../parameter-item'
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
@ -18,6 +23,29 @@ vi.mock('@/app/components/base/tag-input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
let promptEditorOnChange: ((text: string) => void) | undefined
|
||||
let capturedWorkflowNodesMap: Record<string, { title: string, type: string }> | undefined
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({ value, onChange, workflowVariableBlock }: {
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
workflowVariableBlock?: {
|
||||
show: boolean
|
||||
variables: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, { title: string, type: string }>
|
||||
}
|
||||
}) => {
|
||||
promptEditorOnChange = onChange
|
||||
capturedWorkflowNodesMap = workflowVariableBlock?.workflowNodesMap
|
||||
return (
|
||||
<div data-testid="prompt-editor" data-value={value} data-has-workflow-vars={!!workflowVariableBlock?.variables}>
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ParameterItem', () => {
|
||||
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
||||
name: 'temp',
|
||||
@ -30,9 +58,10 @@ describe('ParameterItem', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
promptEditorOnChange = undefined
|
||||
capturedWorkflowNodesMap = undefined
|
||||
})
|
||||
|
||||
// Float tests
|
||||
it('should render float controls and clamp numeric input to max', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
|
||||
@ -50,7 +79,6 @@ describe('ParameterItem', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(0.1)
|
||||
})
|
||||
|
||||
// Int tests
|
||||
it('should render int controls and clamp numeric input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
|
||||
@ -75,22 +103,17 @@ describe('ParameterItem', () => {
|
||||
it('should render int input without slider if min or max is missing', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
// No max -> precision step
|
||||
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
|
||||
})
|
||||
|
||||
// Slider events (uses generic value mock for slider)
|
||||
it('should handle slide change and clamp values', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
|
||||
|
||||
// Test that the actual slider triggers the onChange logic correctly
|
||||
// The implementation of Slider uses onChange(val) directly via the mock
|
||||
fireEvent.click(screen.getByTestId('slider-btn'))
|
||||
expect(onChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
// Text & String tests
|
||||
it('should render exact string input and propagate text changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
|
||||
@ -109,21 +132,17 @@ describe('ParameterItem', () => {
|
||||
|
||||
it('should render select for string with options', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
|
||||
// Select renders the selected value in the trigger
|
||||
expect(screen.getByText('a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Tag Tests
|
||||
it('should render tag input for tag type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
|
||||
expect(screen.getByText('placeholder')).toBeInTheDocument()
|
||||
// Trigger mock tag input
|
||||
fireEvent.click(screen.getByTestId('tag-input'))
|
||||
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
|
||||
})
|
||||
|
||||
// Boolean tests
|
||||
it('should render boolean radios and update value on click', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
|
||||
@ -131,7 +150,6 @@ describe('ParameterItem', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Switch tests
|
||||
it('should call onSwitch with current value when optional switch is toggled off', () => {
|
||||
const onSwitch = vi.fn()
|
||||
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
|
||||
@ -146,7 +164,6 @@ describe('ParameterItem', () => {
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Default Value Fallbacks (rendering without value)
|
||||
it('should use default values if value is undefined', () => {
|
||||
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
|
||||
@ -158,26 +175,102 @@ describe('ParameterItem', () => {
|
||||
expect(screen.getByText('True')).toBeInTheDocument()
|
||||
expect(screen.getByText('False')).toBeInTheDocument()
|
||||
|
||||
// Without default
|
||||
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
|
||||
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />)
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(0)
|
||||
})
|
||||
|
||||
// Input Blur
|
||||
it('should reset input to actual bound value on blur', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
|
||||
// Actually our test fires a change so localValue = 1, then blur sets it
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.blur(input)
|
||||
expect(input).toHaveValue(1)
|
||||
})
|
||||
|
||||
// Unsupported
|
||||
it('should render no input for unsupported parameter type', () => {
|
||||
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('workflow variable reference', () => {
|
||||
const mockNodesOutputVars: NodeOutPutVar[] = [
|
||||
{ nodeId: 'node1', title: 'LLM Node', vars: [] },
|
||||
]
|
||||
const mockAvailableNodes: Node[] = [
|
||||
{ id: 'node1', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'LLM Node', type: BlockEnum.LLM } } as Node,
|
||||
{ id: 'start', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } } as Node,
|
||||
]
|
||||
|
||||
it('should build workflowNodesMap and render PromptEditor for string type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="hello {{#node1.output#}}"
|
||||
onChange={onChange}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars}
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('prompt-editor')
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
|
||||
expect(capturedWorkflowNodesMap).toBeDefined()
|
||||
expect(capturedWorkflowNodesMap!.node1.title).toBe('LLM Node')
|
||||
expect(capturedWorkflowNodesMap!.sys.title).toBe('workflow.blocks.start')
|
||||
expect(capturedWorkflowNodesMap!.sys.type).toBe(BlockEnum.Start)
|
||||
|
||||
promptEditorOnChange?.('updated text')
|
||||
expect(onChange).toHaveBeenCalledWith('updated text')
|
||||
})
|
||||
|
||||
it('should build workflowNodesMap and render PromptEditor for text type', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'text', name: 'user_prompt' })}
|
||||
value="some long text"
|
||||
onChange={onChange}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars}
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('prompt-editor')
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
|
||||
expect(capturedWorkflowNodesMap).toBeDefined()
|
||||
|
||||
promptEditorOnChange?.('new long text')
|
||||
expect(onChange).toHaveBeenCalledWith('new long text')
|
||||
})
|
||||
|
||||
it('should fall back to plain input when not in workflow mode for string type', () => {
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="plain"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('prompt-editor')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return undefined workflowNodesMap when not in workflow mode', () => {
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
|
||||
value="plain"
|
||||
availableNodes={mockAvailableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(capturedWorkflowNodesMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,6 +9,10 @@ import type {
|
||||
} from '../declarations'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import type { TriggerProps } from './trigger'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
@ -46,6 +50,8 @@ export type ModelParameterModalProps = {
|
||||
readonly?: boolean
|
||||
isInWorkflow?: boolean
|
||||
scope?: string
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
|
||||
const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
@ -62,11 +68,18 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
renderTrigger,
|
||||
readonly,
|
||||
isInWorkflow,
|
||||
nodesOutputVars,
|
||||
availableNodes,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@ -192,7 +205,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isLoading
|
||||
isRulesLoading
|
||||
? <div className="py-5"><Loading /></div>
|
||||
: (
|
||||
[
|
||||
@ -206,6 +219,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
nodesOutputVars={nodesOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@ -214,7 +229,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!parameterRules.length && isLoading && (
|
||||
!parameterRules.length && isRulesLoading && (
|
||||
<div className="px-4 py-5"><Loading /></div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import type { ModelParameterRule } from '../declarations'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { isNullOrUndefined } from '../utils'
|
||||
@ -18,18 +25,43 @@ type ParameterItemProps = {
|
||||
onChange?: (value: ParameterValue) => void
|
||||
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
|
||||
isInWorkflow?: boolean
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
}
|
||||
|
||||
function ParameterItem({
|
||||
parameterRule,
|
||||
value,
|
||||
onChange,
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
nodesOutputVars,
|
||||
availableNodes = [],
|
||||
}: ParameterItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const workflowNodesMap = useMemo(() => {
|
||||
if (!isInWorkflow || !availableNodes.length)
|
||||
return undefined
|
||||
|
||||
return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('blocks.start', { ns: 'workflow' }),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [availableNodes, isInWorkflow, t])
|
||||
|
||||
const getDefaultValue = () => {
|
||||
let defaultValue: ParameterValue
|
||||
|
||||
@ -196,6 +228,25 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||
if (isInWorkflow && nodesOutputVars) {
|
||||
return (
|
||||
<div className="ml-4 w-[200px] rounded-lg bg-components-input-bg-normal px-2 py-1">
|
||||
<PromptEditor
|
||||
compact
|
||||
className="min-h-[22px] text-[13px]"
|
||||
value={renderValue as string}
|
||||
onChange={(text) => { handleInputChange(text) }}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
|
||||
@ -206,6 +257,25 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'text') {
|
||||
if (isInWorkflow && nodesOutputVars) {
|
||||
return (
|
||||
<div className="ml-4 w-full rounded-lg bg-components-input-bg-normal px-2 py-1">
|
||||
<PromptEditor
|
||||
compact
|
||||
className="min-h-[56px] text-[13px]"
|
||||
value={renderValue as string}
|
||||
onChange={(text) => { handleInputChange(text) }}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
|
||||
@ -215,7 +285,7 @@ function ParameterItem({
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
|
||||
if (parameterRule.type === 'string' && !!parameterRule.options?.length) {
|
||||
return (
|
||||
<Select
|
||||
value={renderValue as string}
|
||||
|
||||
Reference in New Issue
Block a user