Merge branch 'jzh' into deploy/dev

This commit is contained in:
JzoNg 2026-04-29 18:41:14 +08:00
commit 1d1b9d56c7
21 changed files with 390 additions and 198 deletions

View File

@ -12,6 +12,7 @@ const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
@ -86,23 +87,7 @@ vi.mock('@/service/use-workflow', () => ({
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: () => ({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
},
isLoading: false,
}),
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
const renderWithQueryClient = (ui: ReactNode) => {
@ -199,6 +184,24 @@ describe('Evaluation', () => {
},
},
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
input_fields: [],
},
isLoading: false,
})
mockUpload.mockResolvedValue({
id: 'uploaded-file-id',
name: 'evaluation.csv',
@ -305,6 +308,92 @@ describe('Evaluation', () => {
expect(resetButton).toBeDisabled()
})
it('should hide the batch config warning when judge model and metrics are configured', () => {
const resourceType = 'apps'
const resourceId = 'app-batch-configured'
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' },
])
})
renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
expect(screen.queryByText('evaluation.batch.noticeDescription')).not.toBeInTheDocument()
})
it('should use published snippet input fields for snippet batch templates', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'graph_only',
type: 'text-input',
}],
},
}],
},
input_fields: [
{
label: 'Snippet Topic',
variable: 'snippet_topic',
type: 'text-input',
required: true,
},
{
label: 'Need Summary',
variable: 'need_summary',
type: 'checkbox',
required: false,
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-fields" />)
expect(mockUseSnippetPublishedWorkflow).toHaveBeenCalledWith('snippet-fields')
expect(screen.getByText('snippet_topic')).toBeInTheDocument()
expect(screen.getByText('need_summary')).toBeInTheDocument()
expect(screen.queryByText('graph_only')).not.toBeInTheDocument()
})
it('should show snippet-specific empty input fields copy', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'graph_only',
type: 'text-input',
}],
},
}],
},
input_fields: [],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-empty-fields" />)
expect(screen.getByText('evaluation.batch.noSnippetInputFields')).toBeInTheDocument()
expect(screen.queryByText('evaluation.batch.noInputFields')).not.toBeInTheDocument()
expect(screen.queryByText('graph_only')).not.toBeInTheDocument()
})
it('should hide the value row for empty operators', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
@ -366,7 +455,7 @@ describe('Evaluation', () => {
render(<ConditionsSection resourceType={resourceType} resourceId={resourceId} />)
fireEvent.click(screen.getByRole('combobox', { name: 'evaluation.conditions.addCondition' }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.conditions.addCondition' }))
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
@ -375,7 +464,7 @@ describe('Evaluation', () => {
expect(screen.getByText('evaluation.conditions.valueTypes.number')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.string')).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: /reason/i }))
fireEvent.click(screen.getByRole('menuitem', { name: /reason/i }))
const condition = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].judgmentConfig.conditions[0]
@ -552,7 +641,7 @@ describe('Evaluation', () => {
expect(screen.getAllByText('query').length).toBeGreaterThan(0)
expect(screen.getAllByText('Expect Results').length).toBeGreaterThan(0)
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"][accept=".csv,.xlsx"]')
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"][accept=".csv"]')
expect(fileInput).toBeInTheDocument()
fireEvent.change(fileInput!, {

View File

@ -181,7 +181,7 @@ const HistoryTab = ({
</tbody>
</table>
{!isInitialLoading && records.length === 0 && (
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center system-sm-regular text-text-tertiary">
<div className="mt-4 rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center system-sm-regular text-text-tertiary">
{t('history.empty')}
</div>
)}

View File

@ -22,7 +22,7 @@ const BatchTestPanel = ({
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const isRunnable = isEvaluationRunnable(resource)
const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0
const hasBatchConfig = !!resource.judgeModelId && resource.metrics.length > 0
return (
<div className="flex h-full min-h-0 flex-col bg-background-default">
@ -31,12 +31,14 @@ const BatchTestPanel = ({
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-tertiary">{t('batch.noticeDescription')}</div>
{!hasBatchConfig && (
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-tertiary">{t('batch.noticeDescription')}</div>
</div>
</div>
</div>
)}
</div>
<div className="border-b border-divider-subtle px-6">
<div className="flex gap-4">
@ -56,12 +58,12 @@ const BatchTestPanel = ({
))}
</div>
</div>
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !isPanelReady && 'opacity-50')}>
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !hasBatchConfig && 'opacity-50')}>
{resource.activeBatchTab === 'input-fields' && (
<InputFieldsTab
resourceType={resourceType}
resourceId={resourceId}
isPanelReady={isPanelReady}
isPanelReady={hasBatchConfig}
isRunnable={isRunnable}
/>
)}

View File

@ -33,6 +33,7 @@ const InputFieldsTab = ({
return (
<div className="space-y-5">
<InputFieldsRequirements
resourceType={resourceType}
inputFields={inputFields}
isLoading={isInputFieldsLoading}
/>
@ -54,7 +55,6 @@ const InputFieldsTab = ({
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onDownloadTemplate={actions.handleDownloadTemplate}
onRun={actions.handleRun}
/>
</div>

View File

@ -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')
})
})
})

View File

@ -1,16 +1,22 @@
import type { EvaluationResourceType } from '../../../types'
import type { InputField } from './input-fields-utils'
import { useTranslation } from 'react-i18next'
type InputFieldsRequirementsProps = {
resourceType: EvaluationResourceType
inputFields: InputField[]
isLoading: boolean
}
const InputFieldsRequirements = ({
resourceType,
inputFields,
isLoading,
}: InputFieldsRequirementsProps) => {
const { t } = useTranslation('evaluation')
const emptyDescription = resourceType === 'snippets'
? t('batch.noSnippetInputFields')
: t('batch.noInputFields')
return (
<div>
@ -24,7 +30,7 @@ const InputFieldsRequirements = ({
)}
{!isLoading && inputFields.length === 0 && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.noInputFields')}
{emptyDescription}
</div>
)}
{!isLoading && inputFields.map(field => (

View File

@ -1,13 +1,17 @@
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { SnippetInputField } from '@/types/snippet'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
export type InputField = {
name: string
type: string
}
export const EXPECTED_OUTPUT_FIELD_NAME = 'expected_output'
export const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
@ -27,6 +31,32 @@ export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
}))
}
const PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE: Record<PipelineInputVarType, string> = {
[PipelineInputVarType.textInput]: 'string',
[PipelineInputVarType.paragraph]: 'string',
[PipelineInputVarType.select]: 'string',
[PipelineInputVarType.number]: 'number',
[PipelineInputVarType.singleFile]: 'file',
[PipelineInputVarType.multiFiles]: 'array[file]',
[PipelineInputVarType.checkbox]: 'boolean',
}
export const getSnippetInputFields = (fields?: SnippetInputField[]): InputField[] => {
if (!Array.isArray(fields))
return []
return fields
.filter((field): field is SnippetInputField & { variable: string } =>
typeof field.variable === 'string' && !!field.variable,
)
.map(field => ({
name: field.variable,
type: typeof field.type === 'string' && field.type in PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE
? PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE[field.type as PipelineInputVarType]
: 'string',
}))
}
const escapeCsvCell = (value: string) => {
if (!/[",\n\r]/.test(value))
return value
@ -35,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) => {

View File

@ -25,7 +25,6 @@ type UploadRunPopoverProps = {
isRunning: boolean
onUploadFile: (file: File | undefined) => void
onClearUploadedFile: () => void
onDownloadTemplate: () => void
onRun: () => void
}
@ -43,7 +42,6 @@ const UploadRunPopover = ({
isRunning,
onUploadFile,
onClearUploadedFile,
onDownloadTemplate,
onRun,
}: UploadRunPopoverProps) => {
const { t } = useTranslation('evaluation')
@ -82,7 +80,7 @@ const UploadRunPopover = ({
ref={fileInputRef}
hidden
type="file"
accept=".csv,.xlsx"
accept=".csv"
onChange={handleFileChange}
/>
{currentFileName
@ -111,7 +109,7 @@ const UploadRunPopover = ({
onClick={onClearUploadedFile}
aria-label={t('batch.removeUploadedFile')}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</button>
</div>
</div>
@ -139,15 +137,15 @@ const UploadRunPopover = ({
{t('batch.uploadDropzoneSuffix')}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{t('batch.uploadDropzoneDownloadPrefix')}
{' '}
<button
type="button"
className="text-text-accent hover:underline"
onClick={onDownloadTemplate}
onClick={() => fileInputRef.current?.click()}
>
{t('batch.uploadDropzoneDownloadLink')}
{t('batch.uploadDropzoneUploadButton')}
</button>
{' '}
{t('batch.uploadHint')}
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@ import type { EvaluationResourceType } from '../../../types'
import { useMemo } from 'react'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { getGraphNodes, getStartNodeInputFields } from './input-fields-utils'
import { getSnippetInputFields, getStartNodeInputFields } from './input-fields-utils'
export const usePublishedInputFields = (
resourceType: EvaluationResourceType,
@ -16,10 +16,10 @@ export const usePublishedInputFields = (
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
if (resourceType === 'snippets')
return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph))
return getSnippetInputFields(currentSnippetWorkflow?.input_fields)
return []
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType])
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.input_fields, resourceType])
return {
inputFields,

View File

@ -1,15 +1,12 @@
'use client'
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Button } from '@langgenius/dify-ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationStore } from '../../store'
@ -28,47 +25,59 @@ const AddConditionSelect = ({
}: AddConditionSelectProps) => {
const { t } = useTranslation('evaluation')
const addCondition = useEvaluationStore(state => state.addCondition)
const [selectKey, setSelectKey] = useState(0)
const [open, setOpen] = useState(false)
const handleOpenChange = (nextOpen: boolean) => {
if (disabled)
return
setOpen(nextOpen)
}
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',
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button
variant="ghost-accent"
aria-label={t('conditions.addCondition')}
disabled={disabled}
>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}
</Button>
)}
disabled={disabled}
/>
<PopoverContent
placement="bottom-start"
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<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}>
<SelectLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectLabel>
{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>
<div className="max-h-[360px] overflow-y-auto bg-components-panel-bg p-1" role="menu">
{metricOptionGroups.map(group => (
<div key={group.label} role="group" aria-label={group.label}>
<div className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</div>
{group.options.map(option => (
<button
key={option.id}
type="button"
role="menuitem"
className="flex h-auto w-full items-center gap-3 overflow-hidden rounded-lg px-3 py-2 text-left hover:bg-components-panel-on-panel-item-bg-hover"
onClick={() => {
addCondition(resourceType, resourceId, option.variableSelector)
setOpen(false)
}}
>
<span className="min-w-0 flex-1 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>
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -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 <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
return (
<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.itemLabel}</span>
if (metric.kind === 'builtin' && metric.nodeInfo) {
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{getMetricVariableLabel(metric.variableSelector[1])}</span>
<span className="system-xs-regular text-divider-deep">/</span>
<span className="flex min-w-0 shrink-0 items-center gap-0.5">
<BlockIcon type={getEvaluationNodeBlockType(metric.nodeInfo)} size="xs" className="size-3 rounded-[5px]" />
<span className="max-w-[96px] truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
</div>
)
}
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.groupLabel}</span>
</div>
)
}
@ -114,7 +125,6 @@ const ConditionMetricSelect = ({
{group.options.map(option => (
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
<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.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-quaternary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
@ -141,7 +151,7 @@ const ConditionOperatorSelect = ({
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate system-xs-medium text-text-secondary">{getComparisonOperatorLabel(operator, t)}</span>
</SelectTrigger>
<SelectContent className="z-[1002]" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
<SelectContent className="z-1002" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={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 (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
{(['and', 'or'] as const).map(operator => (
<button
key={operator}
type="button"
className={cn(
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
resource.judgmentConfig.logicalOperator === operator
? 'bg-components-card-bg text-text-primary shadow-xs'
: 'text-text-tertiary',
)}
onClick={() => setConditionLogicalOperator(resourceType, resourceId, operator)}
>
{logicalLabels[operator]}
</button>
))}
<div className={cn('relative', hasMultipleConditions && 'pl-[48px]')}>
{hasMultipleConditions && (
<div className="absolute top-0 bottom-0 left-0 w-[48px]">
<div className="absolute top-4 bottom-4 left-[34px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep" />
<div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-card-bg" />
<button
type="button"
aria-label={logicalLabels[logicalOperator]}
className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none"
onClick={toggleLogicalOperator}
>
{logicalLabels[logicalOperator]}
<span aria-hidden="true" className="ml-0.5 i-ri-loop-left-line h-3 w-3" />
</button>
</div>
</div>
</div>
)}
<div className="space-y-3">
{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)
<div className="space-y-3">
{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 (
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
return (
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
{showValue && (
<div className="border-t border-divider-subtle">
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
</div>
<div className="pt-1 pl-1">
<ActionButton
aria-label={t('conditions.removeCondition')}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</ActionButton>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
</div>
<div className="pt-1 pl-1">
<Button
size="small"
variant="ghost"
aria-label={t('conditions.removeCondition')}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
</Button>
</div>
</div>
)
})}
)
})}
</div>
</div>
</div>
)

View File

@ -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({

View File

@ -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 (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
@ -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)}
>
<span aria-hidden="true" className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5" />
</button>

View File

@ -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 = ({
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
triggerStyle === 'text'
? (
<button type="button" className={cn('inline-flex items-center system-sm-medium text-text-accent', triggerClassName)}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.add')}
</button>
)
: (
<Button variant="ghost-accent" className={triggerClassName}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.add')}
</Button>
)
<Button variant="ghost-accent" className={triggerClassName}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.add')}
</Button>
)}
/>
<PopoverContent popupClassName="w-[360px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]">

View File

@ -3,7 +3,6 @@ import type { NodeInfo } from '@/types/evaluation'
export type MetricSelectorProps = NonPipelineEvaluationResourceProps & {
triggerClassName?: string
triggerStyle?: 'button' | 'text'
}
export type MetricVisualTone = 'indigo' | 'green'

View File

@ -58,7 +58,6 @@ const PipelineBatchActions = ({
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onDownloadTemplate={actions.handleDownloadTemplate}
onRun={actions.handleRun}
/>
</div>

View File

@ -89,10 +89,12 @@ export type JudgmentConfig = {
export type ConditionMetricOption = {
id: string
kind: MetricKind
groupLabel: string
itemLabel: string
valueType: ConditionMetricValueType
variableSelector: [string, string]
nodeInfo?: NodeInfo
}
export type ConditionMetricOptionGroup = {

View File

@ -70,10 +70,12 @@ export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): Condit
return (metric.nodeInfoList ?? []).map((nodeInfo) => {
return {
id: `${nodeInfo.node_id}:${metric.optionId}`,
kind: metric.kind,
groupLabel: metric.label,
itemLabel: nodeInfo.title || nodeInfo.node_id,
valueType: metric.valueType,
variableSelector: [nodeInfo.node_id, metric.optionId] as [string, string],
nodeInfo,
}
})
}
@ -86,6 +88,7 @@ export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): Condit
return customConfig.outputs.map((output) => {
return {
id: `${customConfig.workflowId}:${output.id}`,
kind: metric.kind,
groupLabel: customConfig.workflowName ?? metric.label,
itemLabel: output.id,
valueType: getMetricValueType(output.valueType),

View File

@ -1,11 +1,12 @@
{
"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.",
"batch.loadingInputFields": "Loading input fields...",
"batch.noInputFields": "No published start node input fields found.",
"batch.noSnippetInputFields": "No published snippet input fields found.",
"batch.noticeDescription": "Configuration incomplete. Select the Judge Model and Metrics on the left to generate your batch test template.",
"batch.noticeTitle": "Quick start",
"batch.removeUploadedFile": "Remove uploaded file",
@ -21,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.",

View File

@ -1,11 +1,12 @@
{
"batch.description": "执行批量评测并追踪性能历史。",
"batch.downloadTemplate": "下载 Excel 模板",
"batch.downloadTemplate": "下载 CSV 模板",
"batch.emptyHistory": "还没有测试历史。",
"batch.example": "示例:",
"batch.fileRequired": "请先上传评估数据集文件,再运行测试。",
"batch.loadingInputFields": "正在加载输入字段...",
"batch.noInputFields": "未找到已发布 Start 节点的输入字段。",
"batch.noSnippetInputFields": "未找到已发布的片段输入字段。",
"batch.noticeDescription": "配置尚未完成。请先在左侧选择判定模型和指标,以生成批量测试模板。",
"batch.noticeTitle": "快速开始",
"batch.removeUploadedFile": "移除已上传文件",
@ -21,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": "运行批量测试前,请先完成判定模型、指标和自定义映射配置。",

View File

@ -5,9 +5,13 @@ import { env } from './env'
const isDev = process.env.NODE_ENV === 'development'
const withMDX = createMDX()
const allowedDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS?.split(',')
.map(origin => origin.trim())
.filter(Boolean)
const nextConfig: NextConfig = {
basePath: env.NEXT_PUBLIC_BASE_PATH,
...(allowedDevOrigins?.length ? { allowedDevOrigins } : {}),
transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'],
turbopack: {
rules: codeInspectorPlugin({