From 2a1761ac066d01902b9e85732b04a9ec48b55f77 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Thu, 9 Apr 2026 18:16:30 +0800 Subject: [PATCH] feat(web): add output --- .../__tests__/index.spec.tsx | 171 ++++++++++++++++++ .../components/custom-metric-editor/index.tsx | 49 ++++- .../metric-section/custom-metric-card.tsx | 6 +- web/i18n/en-US/evaluation.json | 1 + 4 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 web/app/components/evaluation/components/custom-metric-editor/__tests__/index.spec.tsx diff --git a/web/app/components/evaluation/components/custom-metric-editor/__tests__/index.spec.tsx b/web/app/components/evaluation/components/custom-metric-editor/__tests__/index.spec.tsx new file mode 100644 index 0000000000..21063a0391 --- /dev/null +++ b/web/app/components/evaluation/components/custom-metric-editor/__tests__/index.spec.tsx @@ -0,0 +1,171 @@ +import type { EvaluationMetric } from '../../../types' +import type { EndNodeType } from '@/app/components/workflow/nodes/end/types' +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' +import type { Node } from '@/app/components/workflow/types' +import type { FetchWorkflowDraftResponse } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import CustomMetricEditorCard from '..' +import { useEvaluationStore } from '../../../store' + +const mockUseAppWorkflow = vi.hoisted(() => vi.fn()) +const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn()) +const mockUseInfiniteScroll = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args), +})) + +vi.mock('@/service/use-evaluation', () => ({ + useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args), +})) + +vi.mock('ahooks', () => ({ + useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args), +})) + +const createStartNode = (): Node => ({ + id: 'start-node', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + title: 'Start', + desc: '', + variables: [], + }, +}) + +const createEndNode = ( + outputs: EndNodeType['outputs'], +): Node => ({ + id: 'end-node', + type: 'custom', + position: { x: 100, y: 0 }, + data: { + type: BlockEnum.End, + title: 'End', + desc: '', + outputs, + }, +}) + +const createWorkflow = ( + nodes: Node[], +): FetchWorkflowDraftResponse => ({ + id: 'workflow-1', + graph: { + nodes, + edges: [], + }, + features: {}, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'User One', + email: 'user-one@example.com', + }, + hash: 'hash-1', + updated_at: 1710000001, + updated_by: { + id: 'user-2', + name: 'User Two', + email: 'user-two@example.com', + }, + tool_published: true, + environment_variables: [], + conversation_variables: [], + version: '1', + marked_name: 'Evaluation Workflow', + marked_comment: 'Published', +}) + +const createMetric = (): EvaluationMetric => ({ + id: 'metric-1', + optionId: 'custom-1', + kind: 'custom-workflow', + label: 'Custom Evaluator', + description: 'Map workflow variables to your evaluation inputs.', + customConfig: { + workflowId: 'workflow-1', + workflowAppId: 'app-1', + workflowName: 'Evaluation Workflow', + mappings: [{ + id: 'mapping-1', + sourceFieldId: null, + targetVariableId: null, + }], + }, +}) + +describe('CustomMetricEditorCard', () => { + beforeEach(() => { + vi.clearAllMocks() + useEvaluationStore.setState({ resources: {} }) + + mockUseInfiniteScroll.mockImplementation(() => undefined) + mockUseAvailableEvaluationWorkflows.mockReturnValue({ + data: { + pages: [{ + items: [], + page: 1, + limit: 20, + has_more: false, + }], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + }) + }) + + // Verify end-node outputs are shown after a workflow is selected. + describe('Outputs', () => { + it('should render the selected workflow outputs from the end node', () => { + mockUseAppWorkflow.mockReturnValue({ + data: createWorkflow([ + createStartNode(), + createEndNode([ + { variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number }, + { variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string }, + ]), + ]), + }) + + render( + , + ) + + expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument() + expect(screen.getByText('answer_score')).toBeInTheDocument() + expect(screen.getByText('number')).toBeInTheDocument() + expect(screen.getByText('reason')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + }) + + it('should hide the output section when the selected workflow has no end outputs', () => { + mockUseAppWorkflow.mockReturnValue({ + data: createWorkflow([ + createStartNode(), + createEndNode([]), + ]), + }) + + render( + , + ) + + expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/evaluation/components/custom-metric-editor/index.tsx b/web/app/components/evaluation/components/custom-metric-editor/index.tsx index 04f4d9485d..7fa385a4ce 100644 --- a/web/app/components/evaluation/components/custom-metric-editor/index.tsx +++ b/web/app/components/evaluation/components/custom-metric-editor/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { EvaluationMetric, EvaluationResourceProps } from '../../types' +import type { EndNodeType } from '@/app/components/workflow/nodes/end/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import type { Node } from '@/app/components/workflow/types' import { useMemo } from 'react' @@ -29,6 +30,22 @@ const getWorkflowTargetVariables = ( })) } +const getWorkflowOutputs = (nodes?: Array) => { + return (nodes ?? []) + .filter(node => node.data.type === BlockEnum.End) + .flatMap((node) => { + const endNode = node as Node + if (!Array.isArray(endNode.data.outputs)) + return [] + + return endNode.data.outputs + .map(output => ({ + variable: output.variable, + valueType: output.value_type, + })) + }) +} + const getWorkflowName = (workflow: { marked_name?: string app_name?: string @@ -51,13 +68,16 @@ const CustomMetricEditorCard = ({ const targetOptions = useMemo(() => { return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes) }, [selectedWorkflow?.graph.nodes]) + const workflowOutputs = useMemo(() => { + return getWorkflowOutputs(selectedWorkflow?.graph.nodes) + }, [selectedWorkflow?.graph.nodes]) const isConfigured = isCustomMetricConfigured(metric) if (!metric.customConfig) return null return ( -
+
+ {!!workflowOutputs.length && ( +
+
+ {t('metrics.custom.outputTitle')} +
+
+ {workflowOutputs.map((output, index) => ( +
+ {output.variable} + {output.valueType && ( + {output.valueType} + )} + {index < workflowOutputs.length - 1 && ( + , + )} +
+ ))} +
+
+ )} +
-
{t('metrics.custom.mappingTitle')}
+
{t('metrics.custom.mappingTitle')}
@@ -94,7 +135,7 @@ const CustomMetricEditorCard = ({ ))}
{!isConfigured && ( -
+
{t('metrics.custom.mappingWarning')}
)} diff --git a/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx b/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx index 824ed8664e..bf86f0e0ba 100644 --- a/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx +++ b/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx @@ -25,12 +25,12 @@ const CustomMetricCard = ({ return (
-
+
-
{metric.label}
+
{metric.label}
@@ -43,7 +43,7 @@ const CustomMetricCard = ({ size="small" variant="ghost" aria-label={t('metrics.remove')} - className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity hover:text-text-secondary focus-visible:opacity-100 group-hover:opacity-100" + className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100" onClick={() => removeMetric(resourceType, resourceId, metric.id)} >