feat(web): add output

This commit is contained in:
JzoNg
2026-04-09 18:16:30 +08:00
parent c29245c1cb
commit 2a1761ac06
4 changed files with 220 additions and 7 deletions

View File

@ -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<StartNodeType> => ({
id: 'start-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: [],
},
})
const createEndNode = (
outputs: EndNodeType['outputs'],
): Node<EndNodeType> => ({
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(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-1"
metric={createMetric()}
/>,
)
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(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-1"
metric={createMetric()}
/>,
)
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
})
})
})

View File

@ -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<Node>) => {
return (nodes ?? [])
.filter(node => node.data.type === BlockEnum.End)
.flatMap((node) => {
const endNode = node as Node<EndNodeType>
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 (
<div className="px-3 pb-3 pt-1">
<div className="px-3 pt-1 pb-3">
<WorkflowSelector
value={metric.customConfig.workflowId}
selectedWorkflowName={metric.customConfig.workflowName ?? (selectedWorkflow ? getWorkflowName(selectedWorkflow) : null)}
@ -68,16 +88,37 @@ const CustomMetricEditorCard = ({
})}
/>
{!!workflowOutputs.length && (
<div className="mt-4 py-1">
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
{t('metrics.custom.outputTitle')}
</div>
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
{workflowOutputs.map((output, index) => (
<div key={output.variable} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.variable}</span>
{output.valueType && (
<span>{output.valueType}</span>
)}
{index < workflowOutputs.length - 1 && (
<span className="pl-0.5">,</span>
)}
</div>
))}
</div>
</div>
)}
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.mappingTitle')}</div>
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
<Button
size="small"
variant="ghost"
className="text-text-accent"
onClick={() => addCustomMetricMapping(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.custom.addMapping')}
</Button>
</div>
@ -94,7 +135,7 @@ const CustomMetricEditorCard = ({
))}
</div>
{!isConfigured && (
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 text-text-tertiary system-xs-regular">
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}

View File

@ -25,12 +25,12 @@ const CustomMetricCard = ({
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pb-1 pt-3">
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="flex min-w-0 flex-1 items-center gap-2 px-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5" />
</div>
<div className="truncate text-text-secondary system-md-medium">{metric.label}</div>
<div className="truncate system-md-medium text-text-secondary">{metric.label}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@ -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)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />

View File

@ -70,6 +70,7 @@
"metrics.custom.limitDescription": "Only one custom metric can be added.",
"metrics.custom.mappingTitle": "Variable Mapping",
"metrics.custom.mappingWarning": "Complete the workflow selection and each variable mapping to enable batch tests.",
"metrics.custom.outputTitle": "Output",
"metrics.custom.sourcePlaceholder": "Source variable",
"metrics.custom.targetPlaceholder": "Target variable",
"metrics.custom.title": "Custom Evaluator",