feat(web): support select node in metric card

This commit is contained in:
JzoNg
2026-03-31 18:07:52 +08:00
parent 688bf7e7a1
commit 873e13c2fb
4 changed files with 183 additions and 5 deletions

View File

@@ -31,7 +31,13 @@ describe('MetricSection', () => {
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
})
},
})
})
@@ -103,6 +109,65 @@ describe('MetricSection', () => {
expect(screen.getByText('Answer Node')).toBeInTheDocument()
})
it('should show only unselected nodes in the add-node dropdown and append the selected node', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
},
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.addNode' }))
// Assert
expect(screen.queryByRole('menuitem', { name: 'LLM 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'LLM 2' }))
expect(screen.getByText('LLM 2')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
it('should hide the add-node button when the builtin metric already targets all nodes', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
},
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
})
// Verify the extracted custom metric editor card renders inside the metric card.

View File

@@ -1,21 +1,30 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
import { useEvaluationStore } from '../../store'
import { getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
import { dedupeNodeInfoList, getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
type BuiltinMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const BuiltinMetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList = [],
}: BuiltinMetricCardProps) => {
const { t } = useTranslation('evaluation')
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
@@ -23,6 +32,12 @@ const BuiltinMetricCard = ({
const [isExpanded, setIsExpanded] = useState(true)
const metricVisual = getMetricVisual(metric.optionId)
const metricToneClasses = getToneClasses(metricVisual.tone)
const selectedNodeInfoList = metric.nodeInfoList ?? []
const selectedNodeIdSet = new Set(selectedNodeInfoList.map(nodeInfo => nodeInfo.node_id))
const selectableNodeInfoList = selectedNodeInfoList.length > 0
? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id))
: []
const shouldShowAddNode = selectableNodeInfoList.length > 0
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
@@ -59,8 +74,8 @@ const BuiltinMetricCard = ({
{isExpanded && (
<div className="flex flex-wrap gap-1 px-3 pb-3 pt-1">
{metric.nodeInfoList?.length
? metric.nodeInfoList.map((nodeInfo) => {
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
@@ -81,7 +96,7 @@ const BuiltinMetricCard = ({
resourceType,
resourceId,
metric.optionId,
metric.nodeInfoList?.filter(item => item.node_id !== nodeInfo.node_id) ?? [],
selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
)}
>
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
@@ -92,6 +107,50 @@ const BuiltinMetricCard = ({
: (
<span className="px-1 text-text-tertiary system-xs-regular">{t('metrics.nodesAll')}</span>
)}
{shouldShowAddNode && (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-background-default-hover text-text-tertiary transition-colors hover:bg-state-base-hover"
/>
)}
>
<span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
popupClassName="w-[252px] rounded-md border-[0.5px] border-components-panel-border py-1 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
{selectableNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
return (
<DropdownMenuItem
key={nodeInfo.node_id}
className="h-auto gap-0 rounded-md px-3 py-1.5"
onClick={() => updateBuiltinMetric(
resourceType,
resourceId,
metric.optionId,
dedupeNodeInfoList([...selectedNodeInfoList, nodeInfo]),
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="truncate text-text-secondary system-sm-medium">{nodeInfo.title}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>

View File

@@ -1,9 +1,13 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
import { useEvaluationResource } from '../../store'
import MetricSelector from '../metric-selector'
import { toEvaluationTargetType } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import MetricCard from './metric-card'
import MetricSectionEmptyState from './metric-section-empty-state'
@@ -14,7 +18,52 @@ const MetricSection = ({
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, NodeInfo[]>>({})
const hasMetrics = resource.metrics.length > 0
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
const shouldLoadNodeInfo = resourceType !== 'pipeline' && !!resourceId && hasBuiltinMetrics
const { data: availableMetricsData } = useAvailableEvaluationMetrics(shouldLoadNodeInfo)
const { mutate: loadNodeInfo } = useEvaluationNodeInfoMutation()
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
const availableMetricIdsKey = availableMetricIds.join(',')
const resolvedNodeInfoMap = shouldLoadNodeInfo ? nodeInfoMap : {}
useEffect(() => {
if (!shouldLoadNodeInfo || availableMetricIds.length === 0)
return
let isActive = true
loadNodeInfo(
{
params: {
targetType: toEvaluationTargetType(resourceType),
targetId: resourceId,
},
body: {
metrics: availableMetricIds,
},
},
{
onSuccess: (data) => {
if (!isActive)
return
setNodeInfoMap(data)
},
onError: () => {
if (!isActive)
return
setNodeInfoMap({})
},
},
)
return () => {
isActive = false
}
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, resourceId, resourceType, shouldLoadNodeInfo])
return (
<section className="max-w-[700px] py-4">
@@ -30,6 +79,7 @@ const MetricSection = ({
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={metric.kind === 'builtin' ? (resolvedNodeInfoMap[metric.optionId] ?? []) : undefined}
/>
))}
<MetricSelector

View File

@@ -1,17 +1,20 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import BuiltinMetricCard from './builtin-metric-card'
import CustomMetricCard from './custom-metric-card'
type MetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const MetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList,
}: MetricCardProps) => {
if (metric.kind === 'custom-workflow') {
return (
@@ -28,6 +31,7 @@ const MetricCard = ({
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={availableNodeInfoList}
/>
)
}