mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 20:36:14 +08:00
feat(web): add condition
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Evaluation from '..'
|
||||
import ConditionsSection from '../components/conditions-section'
|
||||
import { useEvaluationStore } from '../store'
|
||||
|
||||
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
|
||||
@ -150,6 +151,50 @@ describe('Evaluation', () => {
|
||||
expect(screen.queryByPlaceholderText('evaluation.conditions.valuePlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should add a condition from grouped metric dropdown items', () => {
|
||||
const resourceType = 'apps'
|
||||
const resourceId = 'app-conditions-dropdown'
|
||||
const store = useEvaluationStore.getState()
|
||||
|
||||
act(() => {
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
|
||||
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
|
||||
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
|
||||
])
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const customMetric = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].metrics.find(metric => metric.kind === 'custom-workflow')!
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
|
||||
workflowId: 'workflow-1',
|
||||
workflowAppId: 'workflow-app-1',
|
||||
workflowName: 'Review Workflow',
|
||||
})
|
||||
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
|
||||
id: 'reason',
|
||||
valueType: 'string',
|
||||
}])
|
||||
})
|
||||
|
||||
render(<ConditionsSection resourceType={resourceType} resourceId={resourceId} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'evaluation.conditions.addCondition' }))
|
||||
|
||||
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
|
||||
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('Retriever Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('reason')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.conditions.valueTypes.number')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.conditions.valueTypes.string')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: /reason/i }))
|
||||
|
||||
const condition = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].judgmentConfig.conditions[0]
|
||||
|
||||
expect(condition.variableSelector).toEqual(['workflow-1', 'reason'])
|
||||
expect(screen.getAllByText('Review Workflow').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render the metric no-node empty state', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
|
||||
@ -132,6 +132,35 @@ describe('evaluation store', () => {
|
||||
expect(getAllowedOperators(state.metrics, condition.variableSelector)).toEqual(['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null'])
|
||||
})
|
||||
|
||||
it('should add a condition from the selected custom metric output', () => {
|
||||
const resourceType = 'apps'
|
||||
const resourceId = 'app-condition-selector'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const customMetric = useEvaluationStore.getState().resources['apps:app-condition-selector'].metrics.find(metric => metric.kind === 'custom-workflow')!
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
|
||||
workflowId: config.workflowOptions[0].id,
|
||||
workflowAppId: 'custom-workflow-app-id',
|
||||
workflowName: config.workflowOptions[0].label,
|
||||
})
|
||||
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
|
||||
id: 'reason',
|
||||
valueType: 'string',
|
||||
}])
|
||||
|
||||
store.addCondition(resourceType, resourceId, [config.workflowOptions[0].id, 'reason'])
|
||||
|
||||
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
|
||||
|
||||
expect(condition.variableSelector).toEqual([config.workflowOptions[0].id, 'reason'])
|
||||
expect(condition.comparisonOperator).toBe('contains')
|
||||
expect(condition.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear values for operators without values', () => {
|
||||
const resourceType = 'apps'
|
||||
const resourceId = 'app-3'
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationStore } from '../../store'
|
||||
import { getConditionMetricValueTypeTranslationKey } from '../../utils'
|
||||
|
||||
type AddConditionSelectProps = EvaluationResourceProps & {
|
||||
metricOptionGroups: ConditionMetricOptionGroup[]
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const AddConditionSelect = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
metricOptionGroups,
|
||||
disabled,
|
||||
}: AddConditionSelectProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const addCondition = useEvaluationStore(state => state.addCondition)
|
||||
const [selectKey, setSelectKey] = useState(0)
|
||||
|
||||
return (
|
||||
<Select key={selectKey}>
|
||||
<SelectTrigger
|
||||
aria-label={t('conditions.addCondition')}
|
||||
className={cn(
|
||||
'inline-flex w-auto min-w-0 border-none bg-transparent px-0 py-0 text-text-accent shadow-none hover:bg-transparent focus-visible:bg-transparent',
|
||||
disabled && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-line h-4 w-4" />
|
||||
{t('conditions.addCondition')}
|
||||
</SelectTrigger>
|
||||
<SelectContent placement="bottom-start" popupClassName="w-[320px]">
|
||||
{metricOptionGroups.map(group => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
|
||||
{group.options.map(option => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
className="h-auto gap-0 px-3 py-2"
|
||||
onClick={() => {
|
||||
addCondition(resourceType, resourceId, option.variableSelector)
|
||||
setSelectKey(current => current + 1)
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="truncate system-sm-medium text-text-secondary">{option.itemLabel}</span>
|
||||
<span className="ml-auto shrink-0 system-xs-medium text-text-tertiary">
|
||||
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddConditionSelect
|
||||
@ -24,6 +24,8 @@ import { getAllowedOperators, requiresConditionValue, useEvaluationResource, use
|
||||
import {
|
||||
buildConditionMetricOptions,
|
||||
getComparisonOperatorLabel,
|
||||
getConditionMetricValueTypeTranslationKey,
|
||||
groupConditionMetricOptions,
|
||||
isSelectorEqual,
|
||||
serializeVariableSelector,
|
||||
} from '../../utils'
|
||||
@ -75,9 +77,9 @@ const ConditionMetricLabel = ({
|
||||
<div className="flex min-w-0 items-center gap-2 px-1">
|
||||
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] shadow-xs">
|
||||
<span className={cn(getMetricValueTypeIconClassName(metric.valueType), 'h-3 w-3 shrink-0 text-text-secondary')} />
|
||||
<span className="truncate system-xs-medium text-text-secondary">{metric.label}</span>
|
||||
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
|
||||
</div>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.group}</span>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.groupLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -88,11 +90,9 @@ const ConditionMetricSelect = ({
|
||||
placeholder,
|
||||
onChange,
|
||||
}: ConditionMetricSelectProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const groupedMetricOptions = useMemo(() => {
|
||||
return Object.entries(metricOptions.reduce<Record<string, ConditionMetricOption[]>>((acc, option) => {
|
||||
acc[option.group] = [...(acc[option.group] ?? []), option]
|
||||
return acc
|
||||
}, {}))
|
||||
return groupConditionMetricOptions(metricOptions)
|
||||
}, [metricOptions])
|
||||
|
||||
return (
|
||||
@ -108,15 +108,17 @@ const ConditionMetricSelect = ({
|
||||
<ConditionMetricLabel metric={metric} placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[360px]">
|
||||
{groupedMetricOptions.map(([groupName, options]) => (
|
||||
<SelectGroup key={groupName}>
|
||||
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{groupName}</SelectGroupLabel>
|
||||
{options.map(option => (
|
||||
{groupedMetricOptions.map(group => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
|
||||
{group.options.map(option => (
|
||||
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={cn(getMetricValueTypeIconClassName(option.valueType), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="shrink-0 text-text-quaternary">{option.description}</span>
|
||||
<span className="truncate">{option.itemLabel}</span>
|
||||
<span className="ml-auto shrink-0 system-xs-medium text-text-quaternary">
|
||||
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import { buildConditionMetricOptions } from '../../utils'
|
||||
import { useEvaluationResource } from '../../store'
|
||||
import { buildConditionMetricOptions, groupConditionMetricOptions } from '../../utils'
|
||||
import { InlineSectionHeader } from '../section-header'
|
||||
import AddConditionSelect from './add-condition-select'
|
||||
import ConditionGroup from './condition-group'
|
||||
|
||||
const ConditionsSection = ({
|
||||
@ -15,8 +15,8 @@ const ConditionsSection = ({
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const addCondition = useEvaluationStore(state => state.addCondition)
|
||||
const conditionMetricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
|
||||
const groupedConditionMetricOptions = useMemo(() => groupConditionMetricOptions(conditionMetricOptions), [conditionMetricOptions])
|
||||
const canAddCondition = conditionMetricOptions.length > 0
|
||||
|
||||
return (
|
||||
@ -37,18 +37,12 @@ const ConditionsSection = ({
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center system-sm-medium text-text-accent',
|
||||
!canAddCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
<AddConditionSelect
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
metricOptionGroups={groupedConditionMetricOptions}
|
||||
disabled={!canAddCondition}
|
||||
onClick={() => addCondition(resourceType, resourceId)}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
|
||||
{t('conditions.addCondition')}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@ -330,8 +330,17 @@ export function createCustomMetric(): EvaluationMetric {
|
||||
}
|
||||
}
|
||||
|
||||
export const buildConditionItem = (metrics: EvaluationMetric[]): JudgmentConditionItem => {
|
||||
const metricOption = buildConditionMetricOptions(metrics)[0]
|
||||
export const buildConditionItem = (
|
||||
metrics: EvaluationMetric[],
|
||||
variableSelector?: [string, string] | null,
|
||||
): JudgmentConditionItem => {
|
||||
const metricOptions = buildConditionMetricOptions(metrics)
|
||||
const metricOption = variableSelector
|
||||
? metricOptions.find(option =>
|
||||
option.variableSelector[0] === variableSelector[0]
|
||||
&& option.variableSelector[1] === variableSelector[1],
|
||||
) ?? metricOptions[0]
|
||||
: metricOptions[0]
|
||||
const comparisonOperator = metricOption ? getDefaultComparisonOperator(metricOption.valueType) : 'is'
|
||||
|
||||
return {
|
||||
|
||||
@ -61,7 +61,11 @@ type EvaluationStore = {
|
||||
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
|
||||
) => void
|
||||
setConditionLogicalOperator: (resourceType: EvaluationResourceType, resourceId: string, logicalOperator: 'and' | 'or') => void
|
||||
addCondition: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
addCondition: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
variableSelector?: [string, string] | null,
|
||||
) => void
|
||||
removeCondition: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string) => void
|
||||
updateConditionMetric: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, variableSelector: [string, string]) => void
|
||||
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, operator: ComparisonOperator) => void
|
||||
@ -270,13 +274,13 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addCondition: (resourceType, resourceId) => {
|
||||
addCondition: (resourceType, resourceId, variableSelector) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
conditions: [...resource.judgmentConfig.conditions, buildConditionItem(resource.metrics)],
|
||||
conditions: [...resource.judgmentConfig.conditions, buildConditionItem(resource.metrics, variableSelector)],
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
@ -112,13 +112,17 @@ export type JudgmentConfig = {
|
||||
|
||||
export type ConditionMetricOption = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
description: string
|
||||
groupLabel: string
|
||||
itemLabel: string
|
||||
valueType: ConditionMetricValueType
|
||||
variableSelector: [string, string]
|
||||
}
|
||||
|
||||
export type ConditionMetricOptionGroup = {
|
||||
label: string
|
||||
options: ConditionMetricOption[]
|
||||
}
|
||||
|
||||
export type BatchTestRecord = {
|
||||
id: string
|
||||
fileName: string
|
||||
|
||||
@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
ConditionMetricOption,
|
||||
ConditionMetricOptionGroup,
|
||||
ConditionMetricValueType,
|
||||
EvaluationMetric,
|
||||
} from './types'
|
||||
@ -69,9 +70,8 @@ export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): Condit
|
||||
return (metric.nodeInfoList ?? []).map((nodeInfo) => {
|
||||
return {
|
||||
id: `${nodeInfo.node_id}:${metric.optionId}`,
|
||||
group: nodeInfo.title,
|
||||
label: metric.label,
|
||||
description: nodeInfo.type,
|
||||
groupLabel: metric.label,
|
||||
itemLabel: nodeInfo.title || nodeInfo.node_id,
|
||||
valueType: metric.valueType,
|
||||
variableSelector: [nodeInfo.node_id, metric.optionId] as [string, string],
|
||||
}
|
||||
@ -86,9 +86,8 @@ export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): Condit
|
||||
return customConfig.outputs.map((output) => {
|
||||
return {
|
||||
id: `${customConfig.workflowId}:${output.id}`,
|
||||
group: customConfig.workflowName ?? metric.label,
|
||||
label: output.id,
|
||||
description: customConfig.workflowName ?? metric.label,
|
||||
groupLabel: customConfig.workflowName ?? metric.label,
|
||||
itemLabel: output.id,
|
||||
valueType: getMetricValueType(output.valueType),
|
||||
variableSelector: [customConfig.workflowId, output.id] as [string, string],
|
||||
}
|
||||
@ -96,6 +95,30 @@ export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): Condit
|
||||
})
|
||||
}
|
||||
|
||||
export const groupConditionMetricOptions = (metricOptions: ConditionMetricOption[]): ConditionMetricOptionGroup[] => {
|
||||
const groups = metricOptions.reduce<Map<string, ConditionMetricOption[]>>((acc, option) => {
|
||||
acc.set(option.groupLabel, [...(acc.get(option.groupLabel) ?? []), option])
|
||||
return acc
|
||||
}, new Map())
|
||||
|
||||
return Array.from(groups.entries()).map(([label, options]) => ({
|
||||
label,
|
||||
options,
|
||||
}))
|
||||
}
|
||||
|
||||
const conditionMetricValueTypeTranslationKeys = {
|
||||
string: 'conditions.valueTypes.string',
|
||||
number: 'conditions.valueTypes.number',
|
||||
boolean: 'conditions.valueTypes.boolean',
|
||||
} as const
|
||||
|
||||
export const getConditionMetricValueTypeTranslationKey = (
|
||||
valueType: ConditionMetricValueType,
|
||||
) => {
|
||||
return conditionMetricValueTypeTranslationKeys[valueType]
|
||||
}
|
||||
|
||||
export const serializeVariableSelector = (value: [string, string] | null | undefined) => {
|
||||
return value ? JSON.stringify(value) : ''
|
||||
}
|
||||
|
||||
@ -43,6 +43,9 @@
|
||||
"conditions.selectValue": "Choose a value",
|
||||
"conditions.title": "Judgment Conditions",
|
||||
"conditions.valuePlaceholder": "Enter a value",
|
||||
"conditions.valueTypes.boolean": "Boolean",
|
||||
"conditions.valueTypes.number": "Number",
|
||||
"conditions.valueTypes.string": "String",
|
||||
"description": "Configure automated testing to grade your application's performance.",
|
||||
"history.columns.creator": "Creator",
|
||||
"history.columns.status": "Status",
|
||||
|
||||
@ -43,6 +43,9 @@
|
||||
"conditions.selectValue": "选择值",
|
||||
"conditions.title": "判定条件",
|
||||
"conditions.valuePlaceholder": "输入值",
|
||||
"conditions.valueTypes.boolean": "布尔",
|
||||
"conditions.valueTypes.number": "数值",
|
||||
"conditions.valueTypes.string": "文本",
|
||||
"description": "配置自动化测试,对应用表现进行评分。",
|
||||
"judgeModel.description": "选择用于打分和判定评测结果的模型。",
|
||||
"judgeModel.title": "判定模型",
|
||||
|
||||
Reference in New Issue
Block a user