From 9af2c1252c03c2eac6c174ad937073c4e5158f19 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 29 Apr 2026 16:17:31 +0800 Subject: [PATCH 1/6] fix(web): remove node --- .../metric-section/__tests__/index.spec.tsx | 17 +++++++++++++++++ .../metric-section/builtin-metric-card.tsx | 17 +++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx index 6b240966bf..4cdf58a5dc 100644 --- a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx @@ -131,6 +131,23 @@ describe('MetricSection', () => { expect(screen.getByText('Answer Node')).toBeInTheDocument() }) + it('should remove the builtin metric when removing its last selected node', () => { + // Arrange + act(() => { + useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [ + { node_id: 'node-answer', title: 'Answer Node', type: 'llm' }, + ]) + }) + + // Act + renderMetricSection() + fireEvent.click(screen.getByRole('button', { name: 'Answer Node' })) + + // Assert + expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument() + expect(useEvaluationStore.getState().resources[`${resourceType}:${resourceId}`]!.metrics).toHaveLength(0) + }) + it('should show only unselected nodes in the add-node dropdown and append the selected node', () => { // Arrange mockUseDefaultEvaluationMetrics.mockReturnValue({ diff --git a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx index eb661e3a14..8013ceb2af 100644 --- a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx +++ b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx @@ -39,6 +39,16 @@ const BuiltinMetricCard = ({ ? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id)) : [] const shouldShowAddNode = selectableNodeInfoList.length > 0 + const handleRemoveNode = (nodeId: string) => { + const nextSelectedNodeInfoList = selectedNodeInfoList.filter(item => item.node_id !== nodeId) + + if (nextSelectedNodeInfoList.length === 0) { + removeMetric(resourceType, resourceId, metric.id) + return + } + + updateBuiltinMetric(resourceType, resourceId, metric.optionId, nextSelectedNodeInfoList) + } return (
@@ -92,12 +102,7 @@ const BuiltinMetricCard = ({ type="button" className="flex h-4 w-4 items-center justify-center rounded-sm text-text-quaternary transition-colors hover:text-text-secondary" aria-label={nodeInfo.title} - onClick={() => updateBuiltinMetric( - resourceType, - resourceId, - metric.optionId, - selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id), - )} + onClick={() => handleRemoveNode(nodeInfo.node_id)} >
+ ))} + + + ) } diff --git a/web/app/components/evaluation/components/conditions-section/condition-group.tsx b/web/app/components/evaluation/components/conditions-section/condition-group.tsx index 15b9246834..078b94ef80 100644 --- a/web/app/components/evaluation/components/conditions-section/condition-group.tsx +++ b/web/app/components/evaluation/components/conditions-section/condition-group.tsx @@ -6,7 +6,6 @@ import type { EvaluationResourceProps, JudgmentConditionItem, } from '../../types' -import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Select, @@ -19,7 +18,9 @@ import { } from '@langgenius/dify-ui/select' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' import Input from '@/app/components/base/input' +import BlockIcon from '@/app/components/workflow/block-icon' import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store' import { buildConditionMetricOptions, @@ -29,6 +30,7 @@ import { isSelectorEqual, serializeVariableSelector, } from '../../utils' +import { getEvaluationNodeBlockType } from '../metric-selector/utils' type ConditionMetricLabelProps = { metric?: ConditionMetricOption @@ -56,14 +58,8 @@ type ConditionValueInputProps = { type ConditionGroupProps = EvaluationResourceProps -const getMetricValueTypeIconClassName = (valueType: ConditionMetricOption['valueType']) => { - if (valueType === 'number') - return 'i-ri-hashtag' - - if (valueType === 'boolean') - return 'i-ri-checkbox-circle-line' - - return 'i-ri-bar-chart-box-line' +const getMetricVariableLabel = (variableName: string) => { + return variableName.replaceAll('-', '_') } const ConditionMetricLabel = ({ @@ -73,13 +69,28 @@ const ConditionMetricLabel = ({ if (!metric) return {placeholder} - return ( -
-
- - {metric.itemLabel} + if (metric.kind === 'builtin' && metric.nodeInfo) { + return ( +
+
+ {getMetricVariableLabel(metric.variableSelector[1])} + / + + + {metric.itemLabel} + + {metric.valueType} +
+
+ ) + } + + return ( +
+
+ {metric.itemLabel} + {metric.valueType}
- {metric.groupLabel}
) } @@ -114,7 +125,6 @@ const ConditionMetricSelect = ({ {group.options.map(option => (
- {option.itemLabel} {t(getConditionMetricValueTypeTranslationKey(option.valueType))} @@ -141,7 +151,7 @@ const ConditionOperatorSelect = ({ {getComparisonOperatorLabel(operator, t)} - + {operators.map(nextOperator => ( {getComparisonOperatorLabel(nextOperator, t)} @@ -212,88 +222,87 @@ const ConditionGroup = ({ const { t } = useTranslation('evaluation') const resource = useEvaluationResource(resourceType, resourceId) const metricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics]) + const logicalOperator = resource.judgmentConfig.logicalOperator const logicalLabels = { and: t('conditions.logical.and'), or: t('conditions.logical.or'), } + const hasMultipleConditions = resource.judgmentConfig.conditions.length > 1 const setConditionLogicalOperator = useEvaluationStore(state => state.setConditionLogicalOperator) const removeCondition = useEvaluationStore(state => state.removeCondition) const updateConditionMetric = useEvaluationStore(state => state.updateConditionMetric) const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator) const updateConditionValue = useEvaluationStore(state => state.updateConditionValue) + const toggleLogicalOperator = () => { + setConditionLogicalOperator(resourceType, resourceId, logicalOperator === 'and' ? 'or' : 'and') + } return (
-
-
-
- {(['and', 'or'] as const).map(operator => ( - - ))} +
+ {hasMultipleConditions && ( +
+
+
+
-
-
+ )} -
- {resource.judgmentConfig.conditions.map((condition) => { - const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector)) - const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector) - const showValue = !!metric && requiresConditionValue(condition.comparisonOperator) +
+ {resource.judgmentConfig.conditions.map((condition) => { + const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector)) + const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector) + const showValue = !!metric && requiresConditionValue(condition.comparisonOperator) - return ( -
-
-
-
- updateConditionMetric(resourceType, resourceId, condition.id, value)} + return ( +
+
+
+
+ updateConditionMetric(resourceType, resourceId, condition.id, value)} + /> +
+
+ updateConditionOperator(resourceType, resourceId, condition.id, value)} />
-
- updateConditionOperator(resourceType, resourceId, condition.id, value)} - /> + {showValue && ( +
+ updateConditionValue(resourceType, resourceId, condition.id, value)} + /> +
+ )} +
+
+ removeCondition(resourceType, resourceId, condition.id)} + > +
- {showValue && ( -
- updateConditionValue(resourceType, resourceId, condition.id, value)} - /> -
- )}
-
- -
-
- ) - })} + ) + })} +
) diff --git a/web/app/components/evaluation/components/metric-selector/index.tsx b/web/app/components/evaluation/components/metric-selector/index.tsx index 68ad67d343..10ccc5b76a 100644 --- a/web/app/components/evaluation/components/metric-selector/index.tsx +++ b/web/app/components/evaluation/components/metric-selector/index.tsx @@ -3,7 +3,6 @@ import type { ChangeEvent } from 'react' import type { MetricSelectorProps } from './types' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, @@ -22,7 +21,6 @@ const MetricSelector = ({ resourceType, resourceId, triggerClassName, - triggerStyle = 'button', }: MetricSelectorProps) => { const { t } = useTranslation('evaluation') const resource = useEvaluationResource(resourceType, resourceId) @@ -63,19 +61,10 @@ const MetricSelector = ({ -
diff --git a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx index cad35dc31b..123c654f8e 100644 --- a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx +++ b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx @@ -58,7 +58,6 @@ const PipelineBatchActions = ({ isRunning={actions.isRunning} onUploadFile={actions.handleUploadFile} onClearUploadedFile={actions.handleClearUploadedFile} - onDownloadTemplate={actions.handleDownloadTemplate} onRun={actions.handleRun} />
diff --git a/web/i18n/en-US/evaluation.json b/web/i18n/en-US/evaluation.json index e5d5ff790e..acdcbe54c6 100644 --- a/web/i18n/en-US/evaluation.json +++ b/web/i18n/en-US/evaluation.json @@ -1,6 +1,6 @@ { "batch.description": "Execute batch evaluations and track performance history.", - "batch.downloadTemplate": "Download Excel Template", + "batch.downloadTemplate": "Download CSV Template", "batch.emptyHistory": "No test history yet.", "batch.example": "Example:", "batch.fileRequired": "Upload an evaluation dataset file before running the test.", @@ -22,13 +22,12 @@ "batch.tabs.input-fields": "Input Fields", "batch.title": "Batch Test", "batch.uploadAndRun": "Upload & Run Test", - "batch.uploadDropzoneDownloadLink": "here", - "batch.uploadDropzoneDownloadPrefix": "Don't have the template? Download", "batch.uploadDropzoneEmphasis": "filled", "batch.uploadDropzonePrefix": "Drag and drop your", - "batch.uploadDropzoneSuffix": "Excel Template", + "batch.uploadDropzoneSuffix": "CSV Template", + "batch.uploadDropzoneUploadButton": "Upload file", "batch.uploadError": "Failed to upload file.", - "batch.uploadHint": "Select a .csv or .xlsx file", + "batch.uploadHint": "Select a .csv file", "batch.uploadTitle": "Upload test file", "batch.uploading": "Uploading file...", "batch.validation": "Complete the judge model, metrics, and custom mappings before running a batch test.", diff --git a/web/i18n/zh-Hans/evaluation.json b/web/i18n/zh-Hans/evaluation.json index d2c465c04e..c0972282c6 100644 --- a/web/i18n/zh-Hans/evaluation.json +++ b/web/i18n/zh-Hans/evaluation.json @@ -1,6 +1,6 @@ { "batch.description": "执行批量评测并追踪性能历史。", - "batch.downloadTemplate": "下载 Excel 模板", + "batch.downloadTemplate": "下载 CSV 模板", "batch.emptyHistory": "还没有测试历史。", "batch.example": "示例:", "batch.fileRequired": "请先上传评估数据集文件,再运行测试。", @@ -22,13 +22,12 @@ "batch.tabs.input-fields": "输入字段", "batch.title": "批量测试", "batch.uploadAndRun": "上传并运行测试", - "batch.uploadDropzoneDownloadLink": "下载", - "batch.uploadDropzoneDownloadPrefix": "还没有模板?", "batch.uploadDropzoneEmphasis": "已填写的", "batch.uploadDropzonePrefix": "拖拽你的", - "batch.uploadDropzoneSuffix": "Excel 模板", + "batch.uploadDropzoneSuffix": "CSV 模板", + "batch.uploadDropzoneUploadButton": "上传文件", "batch.uploadError": "文件上传失败。", - "batch.uploadHint": "选择 .csv 或 .xlsx 文件", + "batch.uploadHint": "选择 .csv 文件", "batch.uploadTitle": "上传测试文件", "batch.uploading": "文件上传中...", "batch.validation": "运行批量测试前,请先完成判定模型、指标和自定义映射配置。", From 7384a3c1215cff87ad5682f98c0a4f57d7bfb1b4 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 29 Apr 2026 18:05:34 +0800 Subject: [PATCH 6/6] fix(web): template generate --- .../__tests__/input-fields-utils.spec.ts | 26 +++++++++++++++++++ .../input-fields/input-fields-utils.ts | 9 ++++++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts new file mode 100644 index 0000000000..67ee548738 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts @@ -0,0 +1,26 @@ +import { buildTemplateCsvContent } from '../input-fields-utils' + +describe('input fields utils', () => { + describe('buildTemplateCsvContent', () => { + it('should append expected_output as the last CSV column', () => { + expect(buildTemplateCsvContent([ + { name: 'query', type: 'string' }, + { name: 'context', type: 'string' }, + ])).toBe('query,context,expected_output\n') + }) + + it('should not duplicate expected_output when it already exists', () => { + expect(buildTemplateCsvContent([ + { name: 'query', type: 'string' }, + { name: 'expected_output', type: 'string' }, + ])).toBe('query,expected_output\n') + }) + + it('should escape CSV column names before appending expected_output', () => { + expect(buildTemplateCsvContent([ + { name: 'query,text', type: 'string' }, + { name: 'answer "draft"', type: 'string' }, + ])).toBe('"query,text","answer ""draft""",expected_output\n') + }) + }) +}) diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts index ce21045e13..6daae07a84 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts @@ -10,6 +10,8 @@ export type InputField = { type: string } +export const EXPECTED_OUTPUT_FIELD_NAME = 'expected_output' + export const getGraphNodes = (graph?: Record) => { return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : [] } @@ -63,7 +65,12 @@ const escapeCsvCell = (value: string) => { } export const buildTemplateCsvContent = (inputFields: InputField[]) => { - return `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n` + const fieldNames = inputFields.map(field => field.name) + const templateFieldNames = fieldNames.includes(EXPECTED_OUTPUT_FIELD_NAME) + ? fieldNames + : [...fieldNames, EXPECTED_OUTPUT_FIELD_NAME] + + return `${templateFieldNames.map(escapeCsvCell).join(',')}\n` } export const getFileExtension = (fileName: string) => {