mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:03:14 +08:00
feat(web): metric card
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
Select,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../store'
|
||||
import { groupFieldOptions } from '../utils'
|
||||
@@ -40,9 +40,9 @@ function MappingRow({
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl border border-divider-subtle bg-components-card-bg p-3 xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto]">
|
||||
<div className="grid gap-2 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-card-bg p-3 xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto]">
|
||||
<Select value={mapping.sourceFieldId ?? ''} onValueChange={value => onUpdate({ sourceFieldId: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('metrics.custom.sourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -60,7 +60,7 @@ function MappingRow({
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4 -rotate-90" />
|
||||
</div>
|
||||
<Select value={mapping.targetVariableId ?? ''} onValueChange={value => onUpdate({ targetVariableId: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('metrics.custom.targetPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -94,55 +94,59 @@ const CustomMetricEditorCard = ({
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{t('metrics.custom.title')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('metrics.custom.description')}</div>
|
||||
</div>
|
||||
{!isConfigured && <Badge className="badge-warning">{t('metrics.custom.warningBadge')}</Badge>}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div>
|
||||
<div className="mb-2 text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.workflowLabel')}</div>
|
||||
<Select value={metric.customConfig.workflowId ?? ''} onValueChange={value => value && setCustomMetricWorkflow(resourceType, resourceId, metric.id, value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('metrics.custom.workflowPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.workflowOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedWorkflow && <div className="mt-2 text-text-tertiary system-xs-regular">{selectedWorkflow.description}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.mappingTitle')}</div>
|
||||
<Button size="small" variant="ghost" onClick={() => addCustomMetricMapping(resourceType, resourceId, metric.id)}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('metrics.custom.addMapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{metric.customConfig.mappings.map(mapping => (
|
||||
<MappingRow
|
||||
key={mapping.id}
|
||||
resourceType={resourceType}
|
||||
mapping={mapping}
|
||||
targetOptions={selectedWorkflow?.targetVariables ?? []}
|
||||
onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)}
|
||||
onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<Select value={metric.customConfig.workflowId ?? ''} onValueChange={value => value && setCustomMetricWorkflow(resourceType, resourceId, metric.id, value)}>
|
||||
<SelectTrigger className="h-auto rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 px-1 py-1 text-left">
|
||||
<div className={cn('truncate system-sm-regular', selectedWorkflow ? 'text-text-secondary' : 'text-components-input-text-placeholder')}>
|
||||
{selectedWorkflow?.label ?? t('metrics.custom.workflowPlaceholder')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.workflowOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
{t('metrics.custom.addMapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{metric.customConfig.mappings.map(mapping => (
|
||||
<MappingRow
|
||||
key={mapping.id}
|
||||
resourceType={resourceType}
|
||||
mapping={mapping}
|
||||
targetOptions={selectedWorkflow?.targetVariables ?? []}
|
||||
onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)}
|
||||
onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -86,8 +86,8 @@ describe('MetricSection', () => {
|
||||
renderMetricSection()
|
||||
|
||||
expect(screen.getByText('Custom Evaluator')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.addMapping' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const MetricSection = ({
|
||||
title={t('metrics.title')}
|
||||
tooltip={t('metrics.description')}
|
||||
/>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="mt-1 space-y-1">
|
||||
{!hasMetrics && <MetricSectionEmptyState description={t('metrics.description')} />}
|
||||
{resource.metrics.map(metric => (
|
||||
<MetricCard
|
||||
@@ -30,14 +30,16 @@ const MetricSection = ({
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
metric={metric}
|
||||
nodesLabel={t('metrics.nodesLabel')}
|
||||
nodesAllLabel={t('metrics.nodesAll')}
|
||||
removeLabel={t('metrics.remove')}
|
||||
customWarningLabel={t('metrics.custom.warningBadge')}
|
||||
/>
|
||||
))}
|
||||
<MetricSelector
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
triggerStyle="text"
|
||||
triggerClassName="rounded-md px-3 py-2 hover:bg-state-base-hover-alt"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,63 +3,125 @@
|
||||
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useEvaluationStore } from '../../store'
|
||||
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 & {
|
||||
metric: EvaluationMetric
|
||||
nodesLabel: string
|
||||
nodesAllLabel: string
|
||||
removeLabel: string
|
||||
customWarningLabel: string
|
||||
}
|
||||
|
||||
const MetricCard = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
metric,
|
||||
nodesLabel,
|
||||
nodesAllLabel,
|
||||
removeLabel,
|
||||
customWarningLabel,
|
||||
}: MetricCardProps) => {
|
||||
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||
const metricVisual = metric.kind === 'custom-workflow'
|
||||
? { icon: 'i-ri-equalizer-2-line', tone: 'indigo' as const }
|
||||
: getMetricVisual(metric.optionId)
|
||||
const metricToneClasses = getToneClasses(metricVisual.tone)
|
||||
const isCustomMetricInvalid = metric.kind === 'custom-workflow' && !isCustomMetricConfigured(metric)
|
||||
const hasSelectedNodes = metric.kind === 'builtin' && !!metric.nodeInfoList?.length
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{metric.label}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{metric.description}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{metric.badges.map(badge => (
|
||||
<Badge key={badge} className={badge === 'Workflow' ? 'badge-accent' : ''}>{badge}</Badge>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-components-panel-border',
|
||||
hasSelectedNodes ? 'bg-background-section' : 'bg-components-card-bg',
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{metric.kind === 'builtin' && (
|
||||
<div className="mt-3 rounded-xl bg-background-default-subtle px-3 py-2">
|
||||
<div className="text-text-secondary system-2xs-medium-uppercase">{nodesLabel}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{metric.nodeInfoList?.length
|
||||
? metric.nodeInfoList.map(nodeInfo => (
|
||||
<Badge key={nodeInfo.node_id} className="badge-accent">
|
||||
{nodeInfo.title}
|
||||
</Badge>
|
||||
))
|
||||
: (
|
||||
<span className="text-text-tertiary system-xs-regular">{nodesAllLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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 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}
|
||||
|
||||
@@ -4,13 +4,8 @@ type MetricSectionEmptyStateProps = {
|
||||
|
||||
const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-5 rounded-xl bg-background-section px-3 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-md">
|
||||
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
|
||||
</div>
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{description}
|
||||
</div>
|
||||
<div className="rounded-xl bg-background-section px-4 py-4 text-text-tertiary system-xs-regular">
|
||||
{description}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user