mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
feat(web): metric card style
This commit is contained in:
parent
a6ffff3b39
commit
688bf7e7a1
@ -48,14 +48,17 @@ describe('MetricSection', () => {
|
|||||||
// Verify the extracted builtin metric card presentation and removal flow.
|
// Verify the extracted builtin metric card presentation and removal flow.
|
||||||
describe('Builtin Metric Card', () => {
|
describe('Builtin Metric Card', () => {
|
||||||
it('should render node badges for a builtin metric and remove it when delete is clicked', () => {
|
it('should render node badges for a builtin metric and remove it when delete is clicked', () => {
|
||||||
|
// Arrange
|
||||||
act(() => {
|
act(() => {
|
||||||
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
|
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
|
||||||
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
renderMetricSection()
|
renderMetricSection()
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('Answer Correctness')).toBeInTheDocument()
|
expect(screen.getByText('Answer Correctness')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Answer Node')).toBeInTheDocument()
|
expect(screen.getByText('Answer Node')).toBeInTheDocument()
|
||||||
|
|
||||||
@ -66,14 +69,40 @@ describe('MetricSection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render the all-nodes label when a builtin metric has no node selection', () => {
|
it('should render the all-nodes label when a builtin metric has no node selection', () => {
|
||||||
|
// Arrange
|
||||||
act(() => {
|
act(() => {
|
||||||
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
|
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
renderMetricSection()
|
renderMetricSection()
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
|
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should collapse and expand the node section when the metric header is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
act(() => {
|
||||||
|
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
|
||||||
|
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderMetricSection()
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole('button', { name: 'evaluation.metrics.collapseNodes' })
|
||||||
|
fireEvent.click(toggleButton)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.queryByText('Answer Node')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Answer Node')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify the extracted custom metric editor card renders inside the metric card.
|
// Verify the extracted custom metric editor card renders inside the metric card.
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { cn } from '@/utils/classnames'
|
||||||
|
import { useEvaluationStore } from '../../store'
|
||||||
|
import { getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
|
||||||
|
|
||||||
|
type BuiltinMetricCardProps = EvaluationResourceProps & {
|
||||||
|
metric: EvaluationMetric
|
||||||
|
}
|
||||||
|
|
||||||
|
const BuiltinMetricCard = ({
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
metric,
|
||||||
|
}: BuiltinMetricCardProps) => {
|
||||||
|
const { t } = useTranslation('evaluation')
|
||||||
|
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
||||||
|
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
|
const metricVisual = getMetricVisual(metric.optionId)
|
||||||
|
const metricToneClasses = getToneClasses(metricVisual.tone)
|
||||||
|
|
||||||
|
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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={isExpanded ? t('metrics.collapseNodes') : t('metrics.expandNodes')}
|
||||||
|
onClick={() => setIsExpanded(current => !current)}
|
||||||
|
>
|
||||||
|
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
|
||||||
|
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-center gap-0.5">
|
||||||
|
<div className="truncate text-text-secondary system-md-medium">{metric.label}</div>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-3 pb-3 pt-1">
|
||||||
|
{metric.nodeInfoList?.length
|
||||||
|
? metric.nodeInfoList.map((nodeInfo) => {
|
||||||
|
const nodeVisual = getNodeVisual(nodeInfo)
|
||||||
|
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={nodeInfo.node_id}
|
||||||
|
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
|
||||||
|
>
|
||||||
|
<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="px-1 text-text-primary system-xs-regular">{nodeInfo.title}</span>
|
||||||
|
<button
|
||||||
|
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,
|
||||||
|
metric.nodeInfoList?.filter(item => item.node_id !== nodeInfo.node_id) ?? [],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: (
|
||||||
|
<span className="px-1 text-text-tertiary system-xs-regular">{t('metrics.nodesAll')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuiltinMetricCard
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Badge from '@/app/components/base/badge'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { cn } from '@/utils/classnames'
|
||||||
|
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
|
||||||
|
import CustomMetricEditorCard from '../custom-metric-editor-card'
|
||||||
|
import { getToneClasses } from '../metric-selector/utils'
|
||||||
|
|
||||||
|
type CustomMetricCardProps = EvaluationResourceProps & {
|
||||||
|
metric: EvaluationMetric
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomMetricCard = ({
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
metric,
|
||||||
|
}: CustomMetricCardProps) => {
|
||||||
|
const { t } = useTranslation('evaluation')
|
||||||
|
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||||
|
const isCustomMetricInvalid = !isCustomMetricConfigured(metric)
|
||||||
|
const metricToneClasses = getToneClasses('indigo')
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{isCustomMetricInvalid && (
|
||||||
|
<Badge className="badge-warning">
|
||||||
|
{t('metrics.custom.warningBadge')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomMetricEditorCard
|
||||||
|
resourceType={resourceType}
|
||||||
|
resourceId={resourceId}
|
||||||
|
metric={metric}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomMetricCard
|
||||||
@ -30,9 +30,6 @@ const MetricSection = ({
|
|||||||
resourceType={resourceType}
|
resourceType={resourceType}
|
||||||
resourceId={resourceId}
|
resourceId={resourceId}
|
||||||
metric={metric}
|
metric={metric}
|
||||||
nodesAllLabel={t('metrics.nodesAll')}
|
|
||||||
removeLabel={t('metrics.remove')}
|
|
||||||
customWarningLabel={t('metrics.custom.warningBadge')}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<MetricSelector
|
<MetricSelector
|
||||||
|
|||||||
@ -1,135 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||||
import Badge from '@/app/components/base/badge'
|
import BuiltinMetricCard from './builtin-metric-card'
|
||||||
import Button from '@/app/components/base/button'
|
import CustomMetricCard from './custom-metric-card'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
|
||||||
import { cn } from '@/utils/classnames'
|
|
||||||
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
|
|
||||||
import CustomMetricEditorCard from '../custom-metric-editor-card'
|
|
||||||
import { getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
|
|
||||||
|
|
||||||
type MetricCardProps = EvaluationResourceProps & {
|
type MetricCardProps = EvaluationResourceProps & {
|
||||||
metric: EvaluationMetric
|
metric: EvaluationMetric
|
||||||
nodesAllLabel: string
|
|
||||||
removeLabel: string
|
|
||||||
customWarningLabel: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MetricCard = ({
|
const MetricCard = ({
|
||||||
resourceType,
|
resourceType,
|
||||||
resourceId,
|
resourceId,
|
||||||
metric,
|
metric,
|
||||||
nodesAllLabel,
|
|
||||||
removeLabel,
|
|
||||||
customWarningLabel,
|
|
||||||
}: MetricCardProps) => {
|
}: MetricCardProps) => {
|
||||||
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
if (metric.kind === 'custom-workflow') {
|
||||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
return (
|
||||||
const metricVisual = metric.kind === 'custom-workflow'
|
<CustomMetricCard
|
||||||
? { icon: 'i-ri-equalizer-2-line', tone: 'indigo' as const }
|
resourceType={resourceType}
|
||||||
: getMetricVisual(metric.optionId)
|
resourceId={resourceId}
|
||||||
const metricToneClasses = getToneClasses(metricVisual.tone)
|
metric={metric}
|
||||||
const isCustomMetricInvalid = metric.kind === 'custom-workflow' && !isCustomMetricConfigured(metric)
|
/>
|
||||||
const hasSelectedNodes = metric.kind === 'builtin' && !!metric.nodeInfoList?.length
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<BuiltinMetricCard
|
||||||
className={cn(
|
resourceType={resourceType}
|
||||||
'overflow-hidden rounded-xl border border-components-panel-border',
|
resourceId={resourceId}
|
||||||
hasSelectedNodes ? 'bg-background-section' : 'bg-components-card-bg',
|
metric={metric}
|
||||||
)}
|
/>
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3 px-3 pb-1 pt-3">
|
|
||||||
<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={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<div className="truncate text-text-secondary system-md-medium">{metric.label}</div>
|
|
||||||
{metric.description && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={(
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-4 w-4 shrink-0 items-center justify-center text-text-quaternary transition-colors hover:text-text-tertiary"
|
|
||||||
aria-label={metric.label}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<TooltipContent>
|
|
||||||
{metric.description}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{isCustomMetricInvalid && (
|
|
||||||
<Badge className="badge-warning">
|
|
||||||
{customWarningLabel}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={removeLabel}
|
|
||||||
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{metric.kind === 'builtin' && (
|
|
||||||
<div className="flex flex-wrap gap-1 px-3 pb-3 pt-1">
|
|
||||||
{metric.nodeInfoList?.length
|
|
||||||
? metric.nodeInfoList.map((nodeInfo) => {
|
|
||||||
const nodeVisual = getNodeVisual(nodeInfo)
|
|
||||||
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={nodeInfo.node_id}
|
|
||||||
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
|
|
||||||
>
|
|
||||||
<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="px-1 text-text-primary system-xs-regular">{nodeInfo.title}</span>
|
|
||||||
<button
|
|
||||||
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,
|
|
||||||
metric.nodeInfoList?.filter(item => item.node_id !== nodeInfo.node_id) ?? [],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: (
|
|
||||||
<span className="px-1 text-text-tertiary system-xs-regular">{nodesAllLabel}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{metric.kind === 'custom-workflow' && (
|
|
||||||
<CustomMetricEditorCard
|
|
||||||
resourceType={resourceType}
|
|
||||||
resourceId={resourceId}
|
|
||||||
metric={metric}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
"metrics.add": "Add Metric",
|
"metrics.add": "Add Metric",
|
||||||
"metrics.addCustom": "Add Custom Metrics",
|
"metrics.addCustom": "Add Custom Metrics",
|
||||||
"metrics.added": "Added",
|
"metrics.added": "Added",
|
||||||
|
"metrics.collapseNodes": "Collapse nodes",
|
||||||
"metrics.custom.addMapping": "Add Mapping",
|
"metrics.custom.addMapping": "Add Mapping",
|
||||||
"metrics.custom.description": "Select an evaluation workflow and map your variables before running tests.",
|
"metrics.custom.description": "Select an evaluation workflow and map your variables before running tests.",
|
||||||
"metrics.custom.footerDescription": "Connect your published evaluation workflows",
|
"metrics.custom.footerDescription": "Connect your published evaluation workflows",
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"metrics.custom.workflowLabel": "Evaluation Workflow",
|
"metrics.custom.workflowLabel": "Evaluation Workflow",
|
||||||
"metrics.custom.workflowPlaceholder": "Select a workflow",
|
"metrics.custom.workflowPlaceholder": "Select a workflow",
|
||||||
"metrics.description": "Choose from built-in metrics like Groundedness and Correctness to evaluate your workflow outputs.",
|
"metrics.description": "Choose from built-in metrics like Groundedness and Correctness to evaluate your workflow outputs.",
|
||||||
|
"metrics.expandNodes": "Expand nodes",
|
||||||
"metrics.groups.operations": "Operations",
|
"metrics.groups.operations": "Operations",
|
||||||
"metrics.groups.other": "Other",
|
"metrics.groups.other": "Other",
|
||||||
"metrics.groups.quality": "Quality",
|
"metrics.groups.quality": "Quality",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user