refactor(web): refactor evaluation page

This commit is contained in:
JzoNg 2026-03-30 11:06:41 +08:00
parent 871a2a149f
commit ca88516d54
14 changed files with 1514 additions and 1486 deletions

View File

@ -0,0 +1,164 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import { getEvaluationMockConfig } from '../mock'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../store'
import { TAB_CLASS_NAME } from '../utils'
const BatchTestPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const tabLabels = {
'input-fields': t('batch.tabs.input-fields'),
'history': t('batch.tabs.history'),
}
const statusLabels = {
running: t('batch.status.running'),
success: t('batch.status.success'),
failed: t('batch.status.failed'),
}
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
const runBatchTest = useEvaluationStore(state => state.runBatchTest)
const fileInputRef = useRef<HTMLInputElement>(null)
const isRunnable = isEvaluationRunnable(resource)
const handleDownloadTemplate = () => {
const content = ['case_id,input,expected', '1,Example input,Example output'].join('\n')
const link = document.createElement('a')
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
link.download = config.templateFileName
link.click()
}
const handleRun = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
runBatchTest(resourceType, resourceId)
}
return (
<div className="flex h-full min-h-0 flex-col border-l border-divider-subtle bg-components-card-bg">
<div className="border-b border-divider-subtle p-5">
<div className="flex items-center gap-2 text-text-primary system-md-semibold">
<span aria-hidden="true" className="i-ri-flask-line h-5 w-5" />
{t('batch.title')}
</div>
<div className="mt-2 rounded-xl border border-divider-subtle bg-background-default-subtle p-3">
<div className="text-text-primary system-sm-semibold">{t('batch.noticeTitle')}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{t('batch.noticeDescription')}</div>
</div>
<div className="mt-4 flex rounded-xl border border-divider-subtle bg-background-default-subtle p-1">
{(['input-fields', 'history'] as const).map(tab => (
<button
key={tab}
type="button"
className={cn(
TAB_CLASS_NAME,
resource.activeBatchTab === tab ? 'bg-components-card-bg text-text-primary shadow-xs' : 'text-text-tertiary',
)}
onClick={() => setBatchTab(resourceType, resourceId, tab)}
>
{tabLabels[tab]}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{resource.activeBatchTab === 'input-fields' && (
<div className="space-y-5">
<div>
<div className="mb-2 text-text-secondary system-xs-medium-uppercase">{t('batch.requirementsTitle')}</div>
<div className="space-y-2">
{config.batchRequirements.map(requirement => (
<div key={requirement} className="flex gap-2 text-text-tertiary system-sm-regular">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-text-quaternary" />
<span>{requirement}</span>
</div>
))}
</div>
</div>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-center" onClick={handleDownloadTemplate}>
<span aria-hidden="true" className="i-ri-download-line mr-1 h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<input
ref={fileInputRef}
hidden
type="file"
accept=".csv,.xlsx"
onChange={(event) => {
const file = event.target.files?.[0]
setUploadedFileName(resourceType, resourceId, file?.name ?? null)
}}
/>
<button
type="button"
className="flex w-full flex-col items-center justify-center rounded-2xl border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center hover:border-components-button-secondary-border"
onClick={() => fileInputRef.current?.click()}
>
<span aria-hidden="true" className="i-ri-file-upload-line h-5 w-5 text-text-tertiary" />
<div className="mt-2 text-text-primary system-sm-semibold">{t('batch.uploadTitle')}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{resource.uploadedFileName ?? t('batch.uploadHint')}</div>
</button>
</div>
{!isRunnable && (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 text-text-tertiary system-xs-regular">
{t('batch.validation')}
</div>
)}
<Button className="w-full justify-center" variant="primary" disabled={!isRunnable} onClick={handleRun}>
{t('batch.run')}
</Button>
</div>
)}
{resource.activeBatchTab === 'history' && (
<div className="space-y-3">
{resource.batchRecords.length === 0 && (
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center text-text-tertiary system-sm-regular">
{t('batch.emptyHistory')}
</div>
)}
{resource.batchRecords.map(record => (
<div key={record.id} className="rounded-2xl 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">{record.summary}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{record.fileName}</div>
</div>
<Badge className={record.status === 'failed' ? 'badge-warning' : record.status === 'success' ? 'badge-accent' : ''}>
{record.status === 'running'
? (
<span className="flex items-center gap-1">
<span aria-hidden="true" className="i-ri-loader-4-line h-3 w-3 animate-spin" />
{statusLabels.running}
</span>
)
: statusLabels[record.status]}
</Badge>
</div>
<div className="mt-3 text-text-tertiary system-xs-regular">{record.startedAt}</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default BatchTestPanel

View File

@ -0,0 +1,344 @@
'use client'
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationResourceProps,
JudgmentConditionGroup,
} from '../types'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
import dayjs from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import { cn } from '@/utils/classnames'
import { getEvaluationMockConfig } from '../mock'
import { getAllowedOperators, requiresConditionValue, useEvaluationStore } from '../store'
import { getFieldTypeIconClassName, getOperatorLabel, groupFieldOptions } from '../utils'
type ConditionFieldLabelProps = {
field?: EvaluationFieldOption
placeholder: string
}
type ConditionFieldSelectProps = {
field?: EvaluationFieldOption
fieldOptions: EvaluationFieldOption[]
placeholder: string
onChange: (fieldId: string) => void
}
type ConditionOperatorSelectProps = {
field?: EvaluationFieldOption
operator: ComparisonOperator
operators: ComparisonOperator[]
onChange: (operator: ComparisonOperator) => void
}
type FieldValueInputProps = {
field?: EvaluationFieldOption
operator: ComparisonOperator
value: string | number | boolean | null
onChange: (value: string | number | boolean | null) => void
}
type ConditionGroupProps = EvaluationResourceProps & {
group: JudgmentConditionGroup
index: number
}
const ConditionFieldLabel = ({
field,
placeholder,
}: ConditionFieldLabelProps) => {
if (!field)
return <span className="px-1 text-components-input-text-placeholder system-sm-regular">{placeholder}</span>
return (
<div className="flex min-w-0 items-center gap-2 px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pl-[5px] pr-1.5 shadow-xs">
<span className={cn(getFieldTypeIconClassName(field.type), 'h-3 w-3 shrink-0 text-text-secondary')} />
<span className="truncate text-text-secondary system-xs-medium">{field.label}</span>
</div>
<span className="shrink-0 text-text-tertiary system-xs-regular">{field.type}</span>
</div>
)
}
const ConditionFieldSelect = ({
field,
fieldOptions,
placeholder,
onChange,
}: ConditionFieldSelectProps) => {
return (
<Select value={field?.id ?? ''} onValueChange={value => value && onChange(value)}>
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
<ConditionFieldLabel field={field} placeholder={placeholder} />
</SelectTrigger>
<SelectContent popupClassName="w-[320px]">
{groupFieldOptions(fieldOptions).map(([groupName, fields]) => (
<SelectGroup key={groupName}>
<SelectGroupLabel className="px-3 pb-1 pt-2 text-text-tertiary system-xs-medium-uppercase">{groupName}</SelectGroupLabel>
{fields.map(option => (
<SelectItem key={option.id} value={option.id}>
<div className="flex min-w-0 items-center gap-2">
<span className={cn(getFieldTypeIconClassName(option.type), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate">{option.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
const ConditionOperatorSelect = ({
field,
operator,
operators,
onChange,
}: ConditionOperatorSelectProps) => {
const { t } = useTranslation('evaluation')
return (
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate text-text-secondary system-xs-medium">{getOperatorLabel(operator, field?.type, t)}</span>
</SelectTrigger>
<SelectContent className="z-[1002]" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={nextOperator}>
{getOperatorLabel(nextOperator, field?.type, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const FieldValueInput = ({
field,
operator,
value,
onChange,
}: FieldValueInputProps) => {
const { t } = useTranslation('evaluation')
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'time') {
const selectedTime = typeof value === 'string' && value ? dayjs(value) : undefined
return (
<div className="px-2 py-1.5">
<DatePicker
value={selectedTime}
onChange={date => onChange(date ? date.toISOString() : null)}
onClear={() => onChange(null)}
placeholder={t('conditions.selectTime')}
triggerWrapClassName="w-full"
popupZIndexClassname="z-[1002]"
renderTrigger={({ handleClickTrigger }) => (
<button
type="button"
className="group flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-state-base-hover-alt"
onClick={handleClickTrigger}
>
<span
className={cn(
'min-w-0 flex-1 truncate system-sm-regular',
selectedTime ? 'text-text-secondary' : 'text-components-input-text-placeholder',
)}
>
{selectedTime ? selectedTime.format('MMM D, YYYY h:mm A') : t('conditions.selectTime')}
</span>
<span className="i-ri-calendar-line h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary" />
</button>
)}
/>
</div>
)
}
if (field.type === 'boolean') {
return (
<div className="px-2 py-1.5">
<Select value={value === null ? '' : String(value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('conditions.boolean.true')}</SelectItem>
<SelectItem value="false">{t('conditions.boolean.false')}</SelectItem>
</SelectContent>
</Select>
</div>
)
}
if (field.type === 'enum') {
return (
<div className="px-2 py-1.5">
<Select value={typeof value === 'string' ? value : ''} onValueChange={nextValue => onChange(nextValue)}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
return (
<div className="px-2 py-1.5">
<Input
type={field.type === 'number' ? 'number' : 'text'}
value={value === null || typeof value === 'boolean' ? '' : value}
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
placeholder={t('conditions.valuePlaceholder')}
onChange={(e) => {
if (field.type === 'number') {
const nextValue = e.target.value
onChange(nextValue === '' ? null : Number(nextValue))
return
}
onChange(e.target.value)
}}
/>
</div>
)
}
const ConditionGroup = ({
resourceType,
resourceId,
group,
index,
}: ConditionGroupProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const logicalLabels = {
and: t('conditions.logical.and'),
or: t('conditions.logical.or'),
}
const removeConditionGroup = useEvaluationStore(state => state.removeConditionGroup)
const setConditionGroupOperator = useEvaluationStore(state => state.setConditionGroupOperator)
const addConditionItem = useEvaluationStore(state => state.addConditionItem)
const removeConditionItem = useEvaluationStore(state => state.removeConditionItem)
const updateConditionField = useEvaluationStore(state => state.updateConditionField)
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
return (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge>{t('conditions.groupLabel', { index: index + 1 })}</Badge>
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
{(['and', 'or'] as const).map(operator => (
<button
key={operator}
type="button"
className={cn(
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
group.logicalOperator === operator
? 'bg-components-card-bg text-text-primary shadow-xs'
: 'text-text-tertiary',
)}
onClick={() => setConditionGroupOperator(resourceType, resourceId, group.id, operator)}
>
{logicalLabels[operator]}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="small" variant="ghost" onClick={() => addConditionItem(resourceType, resourceId, group.id)}>
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
{t('conditions.addCondition')}
</Button>
<Button
size="small"
variant="ghost"
aria-label={t('conditions.removeGroup')}
onClick={() => removeConditionGroup(resourceType, resourceId, group.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-3">
{group.items.map((item) => {
const field = config.fieldOptions.find(option => option.id === item.fieldId)
const allowedOperators = getAllowedOperators(resourceType, item.fieldId)
const showValue = !!field && requiresConditionValue(item.operator)
return (
<div key={item.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionFieldSelect
field={field}
fieldOptions={config.fieldOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionField(resourceType, resourceId, group.id, item.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
field={field}
operator={item.operator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, group.id, item.id, value)}
/>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<FieldValueInput
field={field}
operator={item.operator}
value={item.value}
onChange={value => updateConditionValue(resourceType, resourceId, group.id, item.id, value)}
/>
</div>
)}
</div>
<div className="pl-1 pt-1">
<Button
size="small"
variant="ghost"
aria-label={t('conditions.removeCondition')}
onClick={() => removeConditionItem(resourceType, resourceId, group.id, item.id)}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}
export default ConditionGroup

View File

@ -0,0 +1,51 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { useEvaluationResource, useEvaluationStore } from '../store'
import ConditionGroup from './condition-group'
import SectionHeader from './section-header'
const ConditionsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const addConditionGroup = useEvaluationStore(state => state.addConditionGroup)
return (
<section className="rounded-2xl border border-divider-subtle bg-components-card-bg p-5">
<SectionHeader
title={t('conditions.title')}
description={t('conditions.description')}
action={(
<Button variant="secondary" onClick={() => addConditionGroup(resourceType, resourceId)}>
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
{t('conditions.addGroup')}
</Button>
)}
/>
<div className="mt-4 space-y-4">
{resource.conditions.length === 0 && (
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center">
<div className="text-text-primary system-sm-semibold">{t('conditions.emptyTitle')}</div>
<div className="mt-1 text-text-tertiary system-sm-regular">{t('conditions.emptyDescription')}</div>
</div>
)}
{resource.conditions.map((group, index) => (
<ConditionGroup
key={group.id}
resourceType={resourceType}
resourceId={resourceId}
group={group}
index={index}
/>
))}
</div>
</section>
)
}
export default ConditionsSection

View File

@ -0,0 +1,151 @@
'use client'
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,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import { getEvaluationMockConfig } from '../mock'
import { isCustomMetricConfigured, useEvaluationStore } from '../store'
import { groupFieldOptions } from '../utils'
type CustomMetricEditorProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
type MappingRowProps = {
resourceType: EvaluationResourceType
mapping: CustomMetricMapping
targetOptions: Array<{ id: string, label: string }>
onUpdate: (patch: { sourceFieldId?: string | null, targetVariableId?: string | null }) => void
onRemove: () => void
}
function MappingRow({
resourceType,
mapping,
targetOptions,
onUpdate,
onRemove,
}: MappingRowProps) {
const { t } = useTranslation('evaluation')
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]">
<Select value={mapping.sourceFieldId ?? ''} onValueChange={value => onUpdate({ sourceFieldId: value })}>
<SelectTrigger>
<SelectValue placeholder={t('metrics.custom.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
{groupFieldOptions(config.fieldOptions).map(([groupName, fields]) => (
<SelectGroup key={groupName}>
<SelectGroupLabel>{groupName}</SelectGroupLabel>
{fields.map(field => (
<SelectItem key={field.id} value={field.id}>{field.label}</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<div className="flex items-center justify-center text-text-quaternary">
<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>
<SelectValue placeholder={t('metrics.custom.targetPlaceholder')} />
</SelectTrigger>
<SelectContent>
{targetOptions.map(option => (
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="small" aria-label={t('metrics.remove')} onClick={onRemove}>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
)
}
const CustomMetricEditor = ({
resourceType,
resourceId,
metric,
}: CustomMetricEditorProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const addCustomMetricMapping = useEvaluationStore(state => state.addCustomMetricMapping)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const removeCustomMetricMapping = useEvaluationStore(state => state.removeCustomMetricMapping)
const selectedWorkflow = config.workflowOptions.find(option => option.id === metric.customConfig?.workflowId)
const isConfigured = isCustomMetricConfigured(metric)
if (!metric.customConfig)
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>
)}
</div>
</div>
</div>
)
}
export default CustomMetricEditor

View File

@ -0,0 +1,43 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { useEffect } from 'react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useEvaluationResource, useEvaluationStore } from '../store'
import { decodeModelSelection, encodeModelSelection } from '../utils'
const JudgeModelSelector = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
const resource = useEvaluationResource(resourceType, resourceId)
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
const selectedModel = decodeModelSelection(resource.judgeModelId)
useEffect(() => {
if (resource.judgeModelId || !modelList.length)
return
const firstProvider = modelList[0]
const firstModel = firstProvider.models[0]
if (!firstProvider || !firstModel)
return
setJudgeModel(resourceType, resourceId, encodeModelSelection(firstProvider.provider, firstModel.model))
}, [modelList, resource.judgeModelId, resourceId, resourceType, setJudgeModel])
return (
<ModelSelector
defaultModel={selectedModel}
modelList={modelList}
onSelect={model => setJudgeModel(resourceType, resourceId, encodeModelSelection(model.provider, model.model))}
showDeprecatedWarnIcon
triggerClassName="h-11"
/>
)
}
export default JudgeModelSelector

View File

@ -0,0 +1,63 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { useEvaluationResource, useEvaluationStore } from '../store'
import CustomMetricEditor from './custom-metric-editor'
import MetricSelector from './metric-selector'
import SectionHeader from './section-header'
const MetricSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const removeMetric = useEvaluationStore(state => state.removeMetric)
return (
<section className="rounded-2xl border border-divider-subtle bg-components-card-bg p-5">
<SectionHeader
title={t('metrics.title')}
description={t('metrics.description')}
action={<MetricSelector resourceType={resourceType} resourceId={resourceId} />}
/>
<div className="mt-4 space-y-3">
{resource.metrics.map(metric => (
<div key={metric.id} className="rounded-2xl border border-divider-subtle 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>
</div>
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{metric.kind === 'custom-workflow' && (
<CustomMetricEditor
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)}
</div>
))}
</div>
</section>
)
}
export default MetricSection

View File

@ -0,0 +1,181 @@
'use client'
import type { ChangeEvent } from 'react'
import type { EvaluationResourceProps } from '../types'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { cn } from '@/utils/classnames'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationResource, useEvaluationStore } from '../store'
const MetricSelector = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const metricGroupLabels = {
quality: t('metrics.groups.quality'),
operations: t('metrics.groups.operations'),
}
const metrics = useEvaluationResource(resourceType, resourceId).metrics
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [showAll, setShowAll] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const loadingTimerRef = useRef<number | null>(null)
const triggerLoading = () => {
if (loadingTimerRef.current)
window.clearTimeout(loadingTimerRef.current)
setIsLoading(true)
loadingTimerRef.current = window.setTimeout(() => {
setIsLoading(false)
}, 180)
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) {
triggerLoading()
return
}
if (loadingTimerRef.current)
window.clearTimeout(loadingTimerRef.current)
setIsLoading(false)
}
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
if (open)
triggerLoading()
}
useEffect(() => {
return () => {
if (loadingTimerRef.current)
window.clearTimeout(loadingTimerRef.current)
}
}, [])
const filteredGroups = useMemo(() => {
const filteredMetrics = config.builtinMetrics.filter((metric) => {
const keyword = query.trim().toLowerCase()
if (!keyword)
return true
return metric.label.toLowerCase().includes(keyword) || metric.description.toLowerCase().includes(keyword)
})
const grouped = filteredMetrics.reduce<Record<string, typeof filteredMetrics>>((acc, metric) => {
acc[metric.group] = [...(acc[metric.group] ?? []), metric]
return acc
}, {})
return Object.entries(grouped)
}, [config.builtinMetrics, query])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="btn btn-medium btn-secondary inline-flex items-center">
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
{t('metrics.add')}
</PopoverTrigger>
<PopoverContent popupClassName="w-[360px] p-3">
<div className="space-y-3">
<Input
value={query}
showLeftIcon
placeholder={t('metrics.searchPlaceholder')}
onChange={handleQueryChange}
/>
<div className="max-h-[320px] space-y-3 overflow-y-auto pr-1">
{isLoading && (
<div className="space-y-2" data-testid="evaluation-metric-loading">
{['metric-skeleton-1', 'metric-skeleton-2', 'metric-skeleton-3'].map(key => (
<div key={key} className="h-14 animate-pulse rounded-xl bg-background-default-subtle" />
))}
</div>
)}
{!isLoading && filteredGroups.length === 0 && (
<div className="rounded-xl border border-dashed border-divider-subtle px-4 py-8 text-center text-text-tertiary system-sm-regular">
{t('metrics.noResults')}
</div>
)}
{!isLoading && filteredGroups.map(([groupName, options]) => {
const shownOptions = showAll ? options : options.slice(0, 2)
return (
<div key={groupName}>
<div className="mb-2 text-text-tertiary system-xs-medium-uppercase">{metricGroupLabels[groupName as keyof typeof metricGroupLabels] ?? groupName}</div>
<div className="space-y-2">
{shownOptions.map(option => (
<button
key={option.id}
type="button"
className="w-full rounded-xl border border-divider-subtle px-3 py-3 text-left hover:border-components-button-secondary-border hover:bg-state-base-hover-alt"
onClick={() => {
addBuiltinMetric(resourceType, resourceId, option.id)
setOpen(false)
}}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-text-primary system-sm-semibold">{option.label}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{option.description}</div>
</div>
{metrics.some(metric => metric.optionId === option.id && metric.kind === 'builtin') && (
<Badge className="badge-accent">{t('metrics.added')}</Badge>
)}
</div>
</button>
))}
</div>
</div>
)
})}
</div>
{filteredGroups.some(([, options]) => options.length > 2) && (
<button
type="button"
className="flex items-center text-text-accent system-sm-medium"
onClick={() => setShowAll(value => !value)}
>
{showAll ? t('metrics.showLess') : t('metrics.showMore')}
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line ml-1 h-4 w-4 transition-transform', showAll && 'rotate-180')}
/>
</button>
)}
<div className="border-t border-divider-subtle pt-3">
<Button
className="w-full justify-center"
variant="ghost-accent"
onClick={() => {
addCustomMetric(resourceType, resourceId)
setOpen(false)
}}
>
{t('metrics.addCustom')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default MetricSelector

View File

@ -0,0 +1,27 @@
'use client'
import type { ReactNode } from 'react'
type SectionHeaderProps = {
title: string
description: string
action?: ReactNode
}
const SectionHeader = ({
title,
description,
action,
}: SectionHeaderProps) => {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-text-primary system-md-semibold">{title}</div>
<div className="mt-1 text-text-tertiary system-sm-regular">{description}</div>
</div>
{action}
</div>
)
}
export default SectionHeader

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,183 @@
import type {
BatchTestRecord,
ComparisonOperator,
CustomMetricMapping,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
MetricOption,
} from './types'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
export type EvaluationStoreResources = Record<string, EvaluationResourceState>
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
export const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
export const createBuiltinMetric = (metric: MetricOption): EvaluationMetric => ({
id: createId('metric'),
optionId: metric.id,
kind: 'builtin',
label: metric.label,
description: metric.description,
badges: metric.badges,
})
export const createCustomMetricMapping = (): CustomMetricMapping => ({
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
})
export const createCustomMetric = (): EvaluationMetric => ({
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [createCustomMetricMapping()],
},
})
export const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
export const createConditionGroup = (resourceType: EvaluationResourceType): JudgmentConditionGroup => ({
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
})
export const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric ? [createBuiltinMetric(defaultMetric)] : [],
conditions: [createConditionGroup(resourceType)],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
export const getResourceState = (
resources: EvaluationStoreResources,
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
export const updateResourceState = (
resources: EvaluationStoreResources,
resourceType: EvaluationResourceType,
resourceId: string,
updater: (resource: EvaluationResourceState) => EvaluationResourceState,
) => {
const { resource, resourceKey } = getResourceState(resources, resourceType, resourceId)
return {
...resources,
[resourceKey]: updater(resource),
}
}
export const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
export const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const createBatchTestRecord = (
resourceType: EvaluationResourceType,
uploadedFileName: string | null | undefined,
): BatchTestRecord => {
const config = getEvaluationMockConfig(resourceType)
return {
id: createId('batch'),
fileName: uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
}
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@ -1,14 +1,29 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
import { getDefaultOperator, getEvaluationMockConfig } from './mock'
import {
buildConditionItem,
buildInitialState,
buildResourceKey,
createBatchTestRecord,
createBuiltinMetric,
createConditionGroup,
createCustomMetric,
createCustomMetricMapping,
getAllowedOperators as getAllowedOperatorsFromUtils,
getConditionValue,
getResourceState,
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
isEvaluationRunnable as isEvaluationRunnableFromUtils,
requiresConditionValue as requiresConditionValueFromUtils,
updateConditionGroup,
updateMetric,
updateResourceState,
} from './store-utils'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
@ -46,117 +61,8 @@ type EvaluationStore = {
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
@ -172,19 +78,12 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgeModelId,
})),
}))
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
@ -192,430 +91,254 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const { resource } = getResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
...currentResource,
metrics: [...currentResource.metrics, createBuiltinMetric(option)],
})),
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: [...resource.metrics, createCustomMetric()],
})),
}))
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
})),
}))
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
})),
}))
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [...metric.customConfig.mappings, createCustomMetricMapping()],
}
: metric.customConfig,
})),
})),
}))
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
})),
}))
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
})),
}))
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: [...resource.conditions, createConditionGroup(resourceType)],
})),
}))
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
})),
}))
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
})),
}))
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [...group.items, buildConditionItem(resourceType)],
})),
})),
}))
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
})),
}))
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const nextOperator = field ? getDefaultOperator(field.type) : item.operator
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
return {
...item,
fieldId,
operator: nextOperator,
value: getConditionValue(field, nextOperator),
}
}),
})),
})),
}))
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
...currentResource,
conditions: updateConditionGroup(currentResource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
})),
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
})),
}))
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
activeBatchTab: tab,
})),
}))
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
uploadedFileName,
})),
}))
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
const { uploadedFileName } = get().resources[buildResourceKey(resourceType, resourceId)] ?? buildInitialState(resourceType)
const nextRecord = createBatchTestRecord(resourceType, uploadedFileName)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
})),
}))
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
batchRecords: resource.batchRecords.map(record => record.id === nextRecord.id
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
})),
}))
}, 1200)
},
}))
@ -626,10 +349,17 @@ export const useEvaluationResource = (resourceType: EvaluationResourceType, reso
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
return getAllowedOperatorsFromUtils(resourceType, fieldId)
}
export const isCustomMetricConfigured = (metric: EvaluationResourceState['metrics'][number]) => {
return isCustomMetricConfiguredFromUtils(metric)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return isEvaluationRunnableFromUtils(state)
}
export const requiresConditionValue = (operator: ComparisonOperator) => {
return requiresConditionValueFromUtils(operator)
}

View File

@ -1,5 +1,10 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type EvaluationResourceProps = {
resourceType: EvaluationResourceType
resourceId: string
}
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'

View File

@ -0,0 +1,60 @@
import type { TFunction } from 'i18next'
import type { ComparisonOperator, EvaluationFieldOption } from './types'
export const TAB_CLASS_NAME = 'flex-1 rounded-lg px-3 py-2 text-left system-sm-medium'
const compactOperatorLabels: Partial<Record<ComparisonOperator, string>> = {
is: '=',
is_not: '!=',
greater_than: '>',
less_than: '<',
greater_or_equal: '>=',
less_or_equal: '<=',
}
export const encodeModelSelection = (provider: string, model: string) => `${provider}::${model}`
export const decodeModelSelection = (judgeModelId: string | null) => {
if (!judgeModelId)
return undefined
const [provider, model] = judgeModelId.split('::')
if (!provider || !model)
return undefined
return { provider, model }
}
export const groupFieldOptions = (fieldOptions: EvaluationFieldOption[]) => {
return Object.entries(fieldOptions.reduce<Record<string, EvaluationFieldOption[]>>((acc, field) => {
acc[field.group] = [...(acc[field.group] ?? []), field]
return acc
}, {}))
}
export const getOperatorLabel = (
operator: ComparisonOperator,
fieldType: EvaluationFieldOption['type'] | undefined,
t: TFunction<'evaluation'>,
) => {
if (fieldType === 'number' && compactOperatorLabels[operator])
return compactOperatorLabels[operator] as string
return t(`conditions.operators.${operator}` as const)
}
export const getFieldTypeIconClassName = (fieldType: EvaluationFieldOption['type']) => {
if (fieldType === 'number')
return 'i-ri-hashtag'
if (fieldType === 'boolean')
return 'i-ri-checkbox-circle-line'
if (fieldType === 'enum')
return 'i-ri-list-check-2'
if (fieldType === 'time')
return 'i-ri-time-line'
return 'i-ri-text'
}

View File

@ -54,7 +54,6 @@ describe('useSnippetPublish', () => {
it('should publish the snippet, close the menu, and show success feedback', async () => {
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
section: 'orchestrate',
}))
await act(async () => {
@ -73,7 +72,6 @@ describe('useSnippetPublish', () => {
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
section: 'orchestrate',
}))
await act(async () => {
@ -89,7 +87,6 @@ describe('useSnippetPublish', () => {
it('should trigger publish on ctrl+shift+p in the orchestrate section', async () => {
renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
section: 'orchestrate',
}))
const event = new KeyboardEvent('keydown')
@ -110,7 +107,6 @@ describe('useSnippetPublish', () => {
it('should ignore the shortcut outside the orchestrate section', () => {
renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
section: 'evaluation',
}))
const event = new KeyboardEvent('keydown')