From d8173b1cda05ccf8fece67e14e8497301e2ca27c Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 29 Apr 2026 13:32:46 +0800 Subject: [PATCH] feat(web): add reset button --- .../evaluation/__tests__/index.spec.tsx | 33 ++++- .../evaluation/__tests__/store.spec.ts | 2 +- .../components/batch-test-panel/index.tsx | 52 +------- .../evaluation/components/config-actions.tsx | 80 ++++++++++++ .../layout/non-pipeline-evaluation.tsx | 2 + .../components/layout/pipeline-evaluation.tsx | 2 + web/app/components/evaluation/store.ts | 119 ++++++++++++++++-- 7 files changed, 231 insertions(+), 59 deletions(-) create mode 100644 web/app/components/evaluation/components/config-actions.tsx diff --git a/web/app/components/evaluation/__tests__/index.spec.tsx b/web/app/components/evaluation/__tests__/index.spec.tsx index 4f8db5a32d..4a8d409d9a 100644 --- a/web/app/components/evaluation/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/__tests__/index.spec.tsx @@ -128,7 +128,7 @@ const renderWithQueryClient = (ui: ReactNode) => { describe('Evaluation', () => { beforeEach(() => { - useEvaluationStore.setState({ resources: {} }) + useEvaluationStore.setState({ resources: {}, initialResources: {} }) vi.clearAllMocks() mockUseEvaluationConfig.mockReturnValue({ data: null, @@ -251,6 +251,37 @@ describe('Evaluation', () => { }) }) + it('should reset unsaved non-pipeline config changes to the hydrated config', () => { + mockUseEvaluationConfig.mockReturnValue({ + data: { + evaluation_model: 'gpt-4o-mini', + evaluation_model_provider: 'openai', + default_metrics: [], + customized_metrics: null, + judgment_config: null, + }, + }) + + renderWithQueryClient() + + const resetButton = screen.getByRole('button', { name: 'common.operation.reset' }) + expect(resetButton).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' })) + fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), { + target: { value: 'faith' }, + }) + fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness')) + + expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(1) + expect(resetButton).toBeEnabled() + + fireEvent.click(resetButton) + + expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(0) + expect(resetButton).toBeDisabled() + }) + it('should hide the value row for empty operators', () => { const resourceType = 'apps' const resourceId = 'app-2' diff --git a/web/app/components/evaluation/__tests__/store.spec.ts b/web/app/components/evaluation/__tests__/store.spec.ts index 150f285b52..648eee68d8 100644 --- a/web/app/components/evaluation/__tests__/store.spec.ts +++ b/web/app/components/evaluation/__tests__/store.spec.ts @@ -10,7 +10,7 @@ import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../stor describe('evaluation store', () => { beforeEach(() => { - useEvaluationStore.setState({ resources: {} }) + useEvaluationStore.setState({ resources: {}, initialResources: {} }) }) it('should configure a custom metric mapping to a valid state', () => { diff --git a/web/app/components/evaluation/components/batch-test-panel/index.tsx b/web/app/components/evaluation/components/batch-test-panel/index.tsx index ce3ace9ef5..8b4d9ca98a 100644 --- a/web/app/components/evaluation/components/batch-test-panel/index.tsx +++ b/web/app/components/evaluation/components/batch-test-panel/index.tsx @@ -1,13 +1,9 @@ 'use client' import type { BatchTestTab, EvaluationResourceProps } from '../../types' -import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { toast } from '@langgenius/dify-ui/toast' import { useTranslation } from 'react-i18next' -import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation' import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store' -import { buildEvaluationConfigPayload } from '../../store-utils' import { TAB_CLASS_NAME } from '../../utils' import HistoryTab from './history-tab' import InputFieldsTab from './input-fields-tab' @@ -19,63 +15,21 @@ const BatchTestPanel = ({ resourceId, }: EvaluationResourceProps) => { const { t } = useTranslation('evaluation') - const { t: tCommon } = useTranslation('common') const tabLabels: Record = { 'input-fields': t('batch.tabs.input-fields'), 'history': t('batch.tabs.history'), } const resource = useEvaluationResource(resourceType, resourceId) const setBatchTab = useEvaluationStore(state => state.setBatchTab) - const saveConfigMutation = useSaveEvaluationConfigMutation() const isRunnable = isEvaluationRunnable(resource) const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0 - const handleSave = () => { - if (!isRunnable) { - toast.warning(t('batch.validation')) - return - } - - const body = buildEvaluationConfigPayload(resource, resourceType) - - if (!body) { - toast.warning(t('batch.validation')) - return - } - - saveConfigMutation.mutate({ - params: { - targetType: resourceType, - targetId: resourceId, - }, - body, - }, { - onSuccess: () => { - toast.success(tCommon('api.saved')) - }, - onError: () => { - toast.error(t('config.saveFailed')) - }, - }) - } - return (
-
-
-
{t('batch.title')}
-
{t('batch.description')}
-
- +
+
{t('batch.title')}
+
{t('batch.description')}
diff --git a/web/app/components/evaluation/components/config-actions.tsx b/web/app/components/evaluation/components/config-actions.tsx new file mode 100644 index 0000000000..8c1e769045 --- /dev/null +++ b/web/app/components/evaluation/components/config-actions.tsx @@ -0,0 +1,80 @@ +'use client' + +import type { EvaluationResourceProps } from '../types' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useTranslation } from 'react-i18next' +import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation' +import { + isEvaluationRunnable, + useEvaluationResource, + useEvaluationStore, + useIsEvaluationConfigDirty, +} from '../store' +import { buildEvaluationConfigPayload } from '../store-utils' + +const EvaluationConfigActions = ({ + resourceType, + resourceId, +}: EvaluationResourceProps) => { + const { t } = useTranslation('evaluation') + const { t: tCommon } = useTranslation('common') + const resource = useEvaluationResource(resourceType, resourceId) + const isDirty = useIsEvaluationConfigDirty(resourceType, resourceId) + const resetResourceConfig = useEvaluationStore(state => state.resetResourceConfig) + const markResourceConfigSaved = useEvaluationStore(state => state.markResourceConfigSaved) + const saveConfigMutation = useSaveEvaluationConfigMutation() + const isRunnable = isEvaluationRunnable(resource) + + const handleSave = () => { + if (!isRunnable) { + toast.warning(t('batch.validation')) + return + } + + const body = buildEvaluationConfigPayload(resource, resourceType) + + if (!body) { + toast.warning(t('batch.validation')) + return + } + + saveConfigMutation.mutate({ + params: { + targetType: resourceType, + targetId: resourceId, + }, + body, + }, { + onSuccess: () => { + markResourceConfigSaved(resourceType, resourceId) + toast.success(tCommon('api.saved')) + }, + onError: () => { + toast.error(t('config.saveFailed')) + }, + }) + } + + return ( +
+ + +
+ ) +} + +export default EvaluationConfigActions diff --git a/web/app/components/evaluation/components/layout/non-pipeline-evaluation.tsx b/web/app/components/evaluation/components/layout/non-pipeline-evaluation.tsx index 5d47a754ff..5c6acf289d 100644 --- a/web/app/components/evaluation/components/layout/non-pipeline-evaluation.tsx +++ b/web/app/components/evaluation/components/layout/non-pipeline-evaluation.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import BatchTestPanel from '../batch-test-panel' import ConditionsSection from '../conditions-section' +import EvaluationConfigActions from '../config-actions' import JudgeModelSelector from '../judge-model-selector' import MetricSection from '../metric-section' import SectionHeader, { InlineSectionHeader } from '../section-header' @@ -38,6 +39,7 @@ const NonPipelineEvaluation = ({ )} descriptionClassName="max-w-[700px]" + action={} />
diff --git a/web/app/components/evaluation/components/layout/pipeline-evaluation.tsx b/web/app/components/evaluation/components/layout/pipeline-evaluation.tsx index 9fb6c70a90..102360f526 100644 --- a/web/app/components/evaluation/components/layout/pipeline-evaluation.tsx +++ b/web/app/components/evaluation/components/layout/pipeline-evaluation.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { useEvaluationStore } from '../../store' import HistoryTab from '../batch-test-panel/history-tab' +import EvaluationConfigActions from '../config-actions' import JudgeModelSelector from '../judge-model-selector' import PipelineBatchActions from '../pipeline/pipeline-batch-actions' import PipelineMetricsSection from '../pipeline/pipeline-metrics-section' @@ -45,6 +46,7 @@ const PipelineEvaluation = ({ )} + action={} />
diff --git a/web/app/components/evaluation/store.ts b/web/app/components/evaluation/store.ts index 4e2df1bb00..9f94e594b0 100644 --- a/web/app/components/evaluation/store.ts +++ b/web/app/components/evaluation/store.ts @@ -4,6 +4,7 @@ import type { EvaluationResourceType, } from './types' import type { EvaluationConfig, NodeInfo } from '@/types/evaluation' +import { isEqual } from 'es-toolkit/predicate' import { create } from 'zustand' import { getEvaluationMockConfig } from './mock' import { @@ -28,8 +29,11 @@ import { buildConditionMetricOptions } from './utils' type EvaluationStore = { resources: Record + initialResources: Record ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void hydrateResource: (resourceType: EvaluationResourceType, resourceId: string, config: EvaluationConfig) => void + resetResourceConfig: (resourceType: EvaluationResourceType, resourceId: string) => void + markResourceConfigSaved: (resourceType: EvaluationResourceType, resourceId: string) => void setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void updateMetricThreshold: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, threshold: number) => void @@ -88,8 +92,68 @@ type EvaluationStore = { const initialResourceCache: Record = {} +const cloneEvaluationResourceState = (resource: EvaluationResourceState): EvaluationResourceState => ({ + ...resource, + metrics: resource.metrics.map(metric => ({ + ...metric, + nodeInfoList: metric.nodeInfoList?.map(nodeInfo => ({ ...nodeInfo })), + customConfig: metric.customConfig + ? { + ...metric.customConfig, + mappings: metric.customConfig.mappings.map(mapping => ({ ...mapping })), + outputs: metric.customConfig.outputs.map(output => ({ ...output })), + } + : undefined, + })), + judgmentConfig: { + ...resource.judgmentConfig, + conditions: resource.judgmentConfig.conditions.map(condition => ({ ...condition })), + }, + batchRecords: resource.batchRecords.map(record => ({ ...record })), +}) + +const preserveBatchState = ( + configState: EvaluationResourceState, + currentResource: EvaluationResourceState | undefined, + resourceType: EvaluationResourceType, +): EvaluationResourceState => { + const initialState = buildInitialState(resourceType) + + return { + ...cloneEvaluationResourceState(configState), + activeBatchTab: currentResource?.activeBatchTab ?? initialState.activeBatchTab, + uploadedFileId: currentResource?.uploadedFileId ?? initialState.uploadedFileId, + uploadedFileName: currentResource?.uploadedFileName ?? initialState.uploadedFileName, + selectedRunId: currentResource?.selectedRunId ?? initialState.selectedRunId, + batchRecords: currentResource?.batchRecords.map(record => ({ ...record })) ?? initialState.batchRecords, + } +} + +const createConfigSnapshot = ( + resourceType: EvaluationResourceType, + resource: EvaluationResourceState, +): EvaluationResourceState => { + const initialState = buildInitialState(resourceType) + + return { + ...cloneEvaluationResourceState(resource), + activeBatchTab: initialState.activeBatchTab, + uploadedFileId: initialState.uploadedFileId, + uploadedFileName: initialState.uploadedFileName, + selectedRunId: initialState.selectedRunId, + batchRecords: initialState.batchRecords, + } +} + +const pickConfigComparableState = (resource: EvaluationResourceState) => ({ + judgeModelId: resource.judgeModelId, + metrics: resource.metrics, + judgmentConfig: resource.judgmentConfig, +}) + export const useEvaluationStore = create((set, get) => ({ resources: {}, + initialResources: {}, ensureResource: (resourceType, resourceId) => { const resourceKey = buildResourceKey(resourceType, resourceId) if (get().resources[resourceKey]) @@ -103,17 +167,42 @@ export const useEvaluationStore = create((set, get) => ({ })) }, hydrateResource: (resourceType, resourceId, config) => { + const resourceKey = buildResourceKey(resourceType, resourceId) + const configState = buildStateFromEvaluationConfig(resourceType, config) + set(state => ({ resources: { ...state.resources, - [buildResourceKey(resourceType, resourceId)]: { - ...buildStateFromEvaluationConfig(resourceType, config), - activeBatchTab: state.resources[buildResourceKey(resourceType, resourceId)]?.activeBatchTab ?? 'input-fields', - uploadedFileId: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileId ?? null, - uploadedFileName: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? null, - selectedRunId: state.resources[buildResourceKey(resourceType, resourceId)]?.selectedRunId ?? null, - batchRecords: state.resources[buildResourceKey(resourceType, resourceId)]?.batchRecords ?? [], - }, + [resourceKey]: preserveBatchState(configState, state.resources[resourceKey], resourceType), + }, + initialResources: { + ...state.initialResources, + [resourceKey]: createConfigSnapshot(resourceType, configState), + }, + })) + }, + resetResourceConfig: (resourceType, resourceId) => { + const resourceKey = buildResourceKey(resourceType, resourceId) + + set(state => ({ + resources: { + ...state.resources, + [resourceKey]: preserveBatchState( + state.initialResources[resourceKey] ?? buildInitialState(resourceType), + state.resources[resourceKey], + resourceType, + ), + }, + })) + }, + markResourceConfigSaved: (resourceType, resourceId) => { + const resourceKey = buildResourceKey(resourceType, resourceId) + const resource = get().resources[resourceKey] ?? buildInitialState(resourceType) + + set(state => ({ + initialResources: { + ...state.initialResources, + [resourceKey]: createConfigSnapshot(resourceType, resource), }, })) }, @@ -436,6 +525,20 @@ export const useEvaluationResource = (resourceType: EvaluationResourceType, reso return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType))) } +export const useIsEvaluationConfigDirty = (resourceType: EvaluationResourceType, resourceId: string) => { + const resourceKey = buildResourceKey(resourceType, resourceId) + + return useEvaluationStore((state) => { + const resource = state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)) + const initialResource = state.initialResources[resourceKey] ?? buildInitialState(resourceType) + + return !isEqual( + pickConfigComparableState(resource), + pickConfigComparableState(initialResource), + ) + }) +} + export const getAllowedOperators = ( metrics: EvaluationResourceState['metrics'], variableSelector: [string, string] | null,