feat(web): support variable selecting in variable mapping

This commit is contained in:
JzoNg 2026-04-09 19:23:22 +08:00
parent 2a1761ac06
commit 4879ea5cd5
10 changed files with 531 additions and 175 deletions

View File

@ -30,9 +30,10 @@ describe('evaluation store', () => {
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
})
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, syncedMetric!.customConfig!.mappings[0].id, {
outputVariableId: 'answer',
})
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
@ -159,7 +160,7 @@ describe('evaluation store', () => {
customized_metrics: {
evaluation_workflow_id: 'workflow-precision-review',
input_fields: {
'app.input.query': 'query',
query: 'answer',
},
},
judgement_conditions: [{
@ -203,8 +204,8 @@ describe('evaluation store', () => {
])
expect(hydratedState.metrics[1].kind).toBe('custom-workflow')
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.mappings[0].sourceFieldId).toBe('app.input.query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].targetVariableId).toBe('query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].inputVariableId).toBe('query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].outputVariableId).toBe('answer')
expect(hydratedState.conditions[0].logicalOperator).toBe('or')
expect(hydratedState.conditions[0].items[0]).toMatchObject({
fieldId: 'system.has_context',

View File

@ -1,21 +1,30 @@
import type { EvaluationMetric } from '../../../types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node } from '@/app/components/workflow/types'
import type { SnippetWorkflow } from '@/types/snippet'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import CustomMetricEditorCard from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
const mockPublishedGraphVariablePicker = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
@ -24,6 +33,13 @@ vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
vi.mock('../published-graph-variable-picker', () => ({
default: (props: Record<string, unknown>) => {
mockPublishedGraphVariablePicker(props)
return <div data-testid="published-graph-variable-picker" />
},
}))
const createStartNode = (): Node<StartNodeType> => ({
id: 'start-node',
type: 'custom',
@ -32,7 +48,20 @@ const createStartNode = (): Node<StartNodeType> => ({
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: [],
variables: [
{
variable: 'user_question',
label: 'User Question',
type: InputVarType.textInput,
required: true,
},
{
variable: 'retrieved_context',
label: 'Retrieved Context',
type: InputVarType.textInput,
required: true,
},
],
},
})
@ -50,6 +79,33 @@ const createEndNode = (
},
})
const createCodeNode = (
id: string,
title: string,
outputs: Record<string, { type: VarType }>,
): Node<CodeNodeType> => ({
id,
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.Code,
title,
desc: '',
code: '',
code_language: CodeLanguage.python3,
outputs: Object.fromEntries(
Object.entries(outputs).map(([key, value]) => [
key,
{
type: value.type,
children: null,
},
]),
),
variables: [],
},
})
const createWorkflow = (
nodes: Node[],
): FetchWorkflowDraftResponse => ({
@ -80,6 +136,20 @@ const createWorkflow = (
marked_comment: 'Published',
})
const createSnippetWorkflow = (
nodes: Node[],
): SnippetWorkflow => ({
id: 'snippet-workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
hash: 'snippet-hash-1',
created_at: 1710000000,
updated_at: 1710000001,
})
const createMetric = (): EvaluationMetric => ({
id: 'metric-1',
optionId: 'custom-1',
@ -88,12 +158,16 @@ const createMetric = (): EvaluationMetric => ({
description: 'Map workflow variables to your evaluation inputs.',
customConfig: {
workflowId: 'workflow-1',
workflowAppId: 'app-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Evaluation Workflow',
mappings: [{
id: 'mapping-1',
sourceFieldId: null,
targetVariableId: null,
inputVariableId: 'user_question',
outputVariableId: 'current-node.answer',
}, {
id: 'mapping-2',
inputVariableId: 'retrieved_context',
outputVariableId: 'current-node.score',
}],
},
})
@ -102,6 +176,7 @@ describe('CustomMetricEditorCard', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockPublishedGraphVariablePicker.mockReset()
mockUseInfiniteScroll.mockImplementation(() => undefined)
mockUseAvailableEvaluationWorkflows.mockReturnValue({
@ -119,48 +194,74 @@ describe('CustomMetricEditorCard', () => {
isFetchingNextPage: false,
isLoading: false,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
// Verify end-node outputs are shown after a workflow is selected.
// Verify the selected evaluation workflow still drives the output summary section.
describe('Outputs', () => {
it('should render the selected workflow outputs from the end node', () => {
mockUseAppWorkflow.mockReturnValue({
data: createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-1"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument()
expect(screen.getByText('answer_score')).toBeInTheDocument()
expect(screen.getByText('number')).toBeInTheDocument()
expect(screen.getByText('reason')).toBeInTheDocument()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getAllByText('answer_score').length).toBeGreaterThan(0)
expect(screen.getAllByText('number').length).toBeGreaterThan(0)
expect(screen.getAllByText('reason').length).toBeGreaterThan(0)
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
})
it('should hide the output section when the selected workflow has no end outputs', () => {
mockUseAppWorkflow.mockReturnValue({
data: createWorkflow([
createStartNode(),
createEndNode([]),
]),
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-1"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
@ -168,4 +269,94 @@ describe('CustomMetricEditorCard', () => {
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
})
})
// Verify mapping rows use workflow start variables on the left and current published graph variables on the right.
describe('Variable Mapping', () => {
it('should pass the current app published graph and saved selector values to the picker', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createStartNode(),
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('user_question')).toBeInTheDocument()
expect(screen.getByText('retrieved_context')).toBeInTheDocument()
expect(screen.getAllByText('string')).toHaveLength(3)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.answer',
})
expect(mockPublishedGraphVariablePicker.mock.calls[1][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.score',
})
})
it('should use the current snippet published graph when editing a snippet evaluation', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentSnippetWorkflow = createSnippetWorkflow([
createCodeNode('snippet-node', 'Snippet Node', {
result: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: currentSnippetWorkflow,
})
render(
<CustomMetricEditorCard
resourceType="snippets"
resourceId="snippet-under-test"
metric={createMetric()}
/>,
)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentSnippetWorkflow.graph.nodes,
edges: currentSnippetWorkflow.graph.edges,
})
})
})
})

View File

@ -3,11 +3,12 @@
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import type { Edge, InputVar, Node } from '@/app/components/workflow/types'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { BlockEnum } from '@/app/components/workflow/types'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import MappingRow from './mapping-row'
@ -17,16 +18,16 @@ type CustomMetricEditorCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const getWorkflowTargetVariables = (
const getWorkflowInputVariables = (
nodes?: Array<Node>,
) => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
if (!startNode || !Array.isArray(startNode.data.variables))
return []
return startNode.data.variables.map(variable => ({
return startNode.data.variables.map((variable: InputVar) => ({
id: variable.variable,
label: typeof variable.label === 'string' ? variable.label : variable.variable,
valueType: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
@ -39,9 +40,11 @@ const getWorkflowOutputs = (nodes?: Array<Node>) => {
return []
return endNode.data.outputs
.filter(output => typeof output.variable === 'string' && !!output.variable)
.map(output => ({
variable: output.variable,
valueType: output.value_type,
id: output.variable,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
nodeTitle: typeof endNode.data.title === 'string' && endNode.data.title ? endNode.data.title : 'End',
}))
})
}
@ -54,6 +57,14 @@ const getWorkflowName = (workflow: {
return workflow.marked_name || workflow.app_name || workflow.id
}
const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
const getGraphEdges = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.edges) ? graph.edges as Edge[] : []
}
const CustomMetricEditorCard = ({
resourceType,
resourceId,
@ -61,18 +72,60 @@ const CustomMetricEditorCard = ({
}: CustomMetricEditorCardProps) => {
const { t } = useTranslation('evaluation')
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const addCustomMetricMapping = useEvaluationStore(state => state.addCustomMetricMapping)
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const removeCustomMetricMapping = useEvaluationStore(state => state.removeCustomMetricMapping)
const { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
const targetOptions = useMemo(() => {
return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes)
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputVariables = useMemo(() => {
return getWorkflowInputVariables(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const workflowOutputs = useMemo(() => {
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const publishedGraph = useMemo(() => {
if (resourceType === 'apps') {
return {
nodes: currentAppWorkflow?.graph.nodes ?? [],
edges: currentAppWorkflow?.graph.edges ?? [],
environmentVariables: currentAppWorkflow?.environment_variables ?? [],
conversationVariables: currentAppWorkflow?.conversation_variables ?? [],
}
}
return {
nodes: getGraphNodes(currentSnippetWorkflow?.graph),
edges: getGraphEdges(currentSnippetWorkflow?.graph),
environmentVariables: [],
conversationVariables: [],
}
}, [
currentAppWorkflow?.conversation_variables,
currentAppWorkflow?.environment_variables,
currentAppWorkflow?.graph.edges,
currentAppWorkflow?.graph.nodes,
currentSnippetWorkflow?.graph,
resourceType,
])
const inputVariableIds = useMemo(() => inputVariables.map(variable => variable.id), [inputVariables])
const isConfigured = isCustomMetricConfigured(metric)
useEffect(() => {
if (!metric.customConfig?.workflowId)
return
const currentInputVariableIds = metric.customConfig.mappings
.map(mapping => mapping.inputVariableId)
.filter((value): value is string => !!value)
if (currentInputVariableIds.length === inputVariableIds.length
&& currentInputVariableIds.every((value, index) => value === inputVariableIds[index])) {
return
}
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
}, [inputVariableIds, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
if (!metric.customConfig)
return null
@ -88,6 +141,37 @@ const CustomMetricEditorCard = ({
})}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
</div>
<div className="space-y-2">
{inputVariables.map((inputVariable) => {
const mapping = metric.customConfig?.mappings.find(item => item.inputVariableId === inputVariable.id)
return (
<MappingRow
key={inputVariable.id}
inputVariable={inputVariable}
publishedGraph={publishedGraph}
value={mapping?.outputVariableId ?? null}
onUpdate={(outputVariableId) => {
if (!mapping)
return
updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, { outputVariableId })
}}
/>
)
})}
</div>
{!isConfigured && (
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}
</div>
{!!workflowOutputs.length && (
<div className="mt-4 py-1">
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
@ -95,8 +179,8 @@ const CustomMetricEditorCard = ({
</div>
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
{workflowOutputs.map((output, index) => (
<div key={output.variable} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.variable}</span>
<div key={`${output.nodeTitle}-${output.id}-${index}`} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.id}</span>
{output.valueType && (
<span>{output.valueType}</span>
)}
@ -108,38 +192,6 @@ const CustomMetricEditorCard = ({
</div>
</div>
)}
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
<Button
size="small"
variant="ghost"
className="text-text-accent"
onClick={() => addCustomMetricMapping(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="mr-1 i-ri-add-line 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={targetOptions}
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 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}
</div>
</div>
)
}

View File

@ -1,74 +1,62 @@
'use client'
import type { CustomMetricMapping, EvaluationResourceType } from '../../types'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
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 { groupFieldOptions } from '../../utils'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import PublishedGraphVariablePicker from './published-graph-variable-picker'
type MappingRowProps = {
resourceType: EvaluationResourceType
mapping: CustomMetricMapping
targetOptions: Array<{ id: string, label: string }>
onUpdate: (patch: { sourceFieldId?: string | null, targetVariableId?: string | null }) => void
onRemove: () => void
inputVariable: {
id: string
valueType: string
}
publishedGraph: {
nodes: Node[]
edges: Edge[]
environmentVariables: EnvironmentVariable[]
conversationVariables: ConversationVariable[]
}
value: string | null
onUpdate: (outputVariableId: string | null) => void
}
const MappingRow = ({
resourceType,
mapping,
targetOptions,
inputVariable,
publishedGraph,
value,
onUpdate,
onRemove,
}: MappingRowProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
return (
<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 className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<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 className="flex items-center">
<div className="flex h-8 w-[200px] items-center rounded-md px-2">
<div className="flex min-w-0 items-center gap-0.5 px-1">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="truncate system-xs-medium text-text-secondary">{inputVariable.id}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{inputVariable.valueType}</div>
</div>
</div>
<Select value={mapping.targetVariableId ?? ''} onValueChange={value => onUpdate({ targetVariableId: value })}>
<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>
{targetOptions.map(option => (
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex h-8 w-9 items-center justify-center px-3 system-xs-medium text-text-tertiary">
<span aria-hidden="true"></span>
</div>
<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>
<PublishedGraphVariablePicker
className="grow"
nodes={publishedGraph.nodes}
edges={publishedGraph.edges}
environmentVariables={publishedGraph.environmentVariables}
conversationVariables={publishedGraph.conversationVariables}
value={value}
placeholder={t('metrics.custom.outputPlaceholder')}
onChange={onUpdate}
/>
</div>
)
}

View File

@ -0,0 +1,118 @@
'use client'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
ValueSelector,
} from '@/app/components/workflow/types'
import { useMemo } from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createHooksStore, HooksStoreContext } from '@/app/components/workflow/hooks-store'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils/variable'
type PublishedGraphVariablePickerProps = {
className?: string
nodes: Node[]
edges: Edge[]
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
placeholder: string
value: string | null
onChange: (value: string | null) => void
}
const PICKER_NODE_ID = '__evaluation-variable-picker__'
const createPickerNode = (): Node<EndNodeType> => ({
id: PICKER_NODE_ID,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs: [],
},
})
const PublishedGraphVariablePicker = ({
className,
nodes,
edges,
environmentVariables = [],
conversationVariables = [],
placeholder,
value,
onChange,
}: PublishedGraphVariablePickerProps) => {
const workflowStore = useMemo(() => {
const store = createWorkflowStore({})
store.setState({
isWorkflowDataLoaded: true,
environmentVariables,
conversationVariables,
ragPipelineVariables: [],
dataSourceList: [],
})
return store
}, [conversationVariables, environmentVariables])
const hooksStore = useMemo(() => createHooksStore({}), [])
const pickerNodes = useMemo(() => {
return [...nodes, createPickerNode()]
}, [nodes])
const pickerValue = useMemo<ValueSelector>(() => {
if (!value)
return []
return variableTransformer(value) as ValueSelector
}, [value])
return (
<WorkflowContext.Provider value={workflowStore}>
<HooksStoreContext.Provider value={hooksStore}>
<div id="workflow-container" className={className}>
<ReactFlowProvider>
<div
aria-hidden="true"
className="pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
>
<div style={{ width: 800, height: 600 }}>
<ReactFlow nodes={pickerNodes} edges={edges} fitView />
</div>
</div>
<VarReferencePicker
className="grow"
nodeId={PICKER_NODE_ID}
readonly={!nodes.length}
isShowNodeName
value={pickerValue}
onChange={(nextValue) => {
if (!Array.isArray(nextValue) || !nextValue.length) {
onChange(null)
return
}
onChange(nextValue.join('.'))
}}
availableNodes={nodes}
placeholder={placeholder}
/>
</ReactFlowProvider>
</div>
</HooksStoreContext.Provider>
</WorkflowContext.Provider>
)
}
export default PublishedGraphVariablePicker

View File

@ -208,7 +208,7 @@ describe('MetricSection', () => {
expect(screen.getByText('Custom Evaluator')).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()
expect(screen.getByText('evaluation.metrics.custom.mappingTitle')).toBeInTheDocument()
})
it('should disable adding another custom metric when one already exists', () => {

View File

@ -89,20 +89,16 @@ const normalizeCustomMetricMappings = (
value: EvaluationCustomizedMetric['input_fields'],
): CustomMetricMapping[] => {
if (!value)
return [createCustomMetricMapping()]
return []
const mappings = Object.entries(value)
.filter((entry): entry is [string, string] => {
const [, targetVariableId] = entry
return typeof targetVariableId === 'string' && !!targetVariableId
const [, outputVariableId] = entry
return typeof outputVariableId === 'string' && !!outputVariableId
})
.map(([sourceFieldId, targetVariableId]) => ({
id: createId('mapping'),
sourceFieldId,
targetVariableId,
}))
.map(([inputVariableId, outputVariableId]) => createCustomMetricMapping(inputVariableId, outputVariableId))
return mappings.length > 0 ? mappings : [createCustomMetricMapping()]
return mappings
}
const normalizeCustomMetric = (
@ -228,14 +224,38 @@ export function createBuiltinMetric(
}
}
export function createCustomMetricMapping(): CustomMetricMapping {
function createCustomMetricMapping(
inputVariableId: string | null = null,
outputVariableId: string | null = null,
): CustomMetricMapping {
return {
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
inputVariableId,
outputVariableId,
}
}
export const syncCustomMetricMappings = (
mappings: CustomMetricMapping[],
inputVariableIds: string[],
) => {
const mappingByInputVariableId = new Map(
mappings
.filter(mapping => !!mapping.inputVariableId)
.map(mapping => [mapping.inputVariableId, mapping]),
)
return inputVariableIds.map((inputVariableId) => {
const existingMapping = mappingByInputVariableId.get(inputVariableId)
return existingMapping
? {
...existingMapping,
inputVariableId,
}
: createCustomMetricMapping(inputVariableId, null)
})
}
export function createCustomMetric(): EvaluationMetric {
return {
id: createId('metric'),
@ -247,7 +267,7 @@ export function createCustomMetric(): EvaluationMetric {
workflowId: null,
workflowAppId: null,
workflowName: null,
mappings: [createCustomMetricMapping()],
mappings: [],
},
}
}
@ -362,7 +382,7 @@ export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
&& metric.customConfig.mappings.every(mapping => !!mapping.inputVariableId && !!mapping.outputVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {

View File

@ -15,12 +15,12 @@ import {
createBuiltinMetric,
createConditionGroup,
createCustomMetric,
createCustomMetricMapping,
getAllowedOperators as getAllowedOperatorsFromUtils,
getConditionValue,
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
isEvaluationRunnable as isEvaluationRunnableFromUtils,
requiresConditionValue as requiresConditionValueFromUtils,
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
updateConditionGroup,
updateMetric,
updateResourceState,
@ -41,15 +41,19 @@ type EvaluationStore = {
metricId: string,
workflow: { workflowId: string, workflowAppId: string, workflowName: string },
) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
syncCustomMetricMappings: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
inputVariableIds: string[],
) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
@ -170,7 +174,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
workflowName: workflow.workflowName,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
outputVariableId: null,
})),
}
: metric.customConfig,
@ -178,7 +182,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
})),
}))
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
syncCustomMetricMappings: (resourceType, resourceId, metricId, inputVariableIds) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
@ -187,7 +191,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [...metric.customConfig.mappings, createCustomMetricMapping()],
mappings: syncCustomMetricMappingsFromUtils(metric.customConfig.mappings, inputVariableIds),
}
: metric.customConfig,
})),
@ -210,22 +214,6 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
})),
}))
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
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 => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({

View File

@ -62,8 +62,8 @@ export type EvaluationFieldOption = {
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
inputVariableId: string | null
outputVariableId: string | null
}
export type CustomMetricConfig = {

View File

@ -63,16 +63,14 @@
"metrics.addNode": "Add Node",
"metrics.added": "Added",
"metrics.collapseNodes": "Collapse nodes",
"metrics.custom.addMapping": "Add Mapping",
"metrics.custom.description": "Select an evaluation workflow and map your variables before running tests.",
"metrics.custom.footerDescription": "Connect your published evaluation workflows",
"metrics.custom.footerTitle": "Custom metrics",
"metrics.custom.limitDescription": "Only one custom metric can be added.",
"metrics.custom.mappingTitle": "Variable Mapping",
"metrics.custom.mappingWarning": "Complete the workflow selection and each variable mapping to enable batch tests.",
"metrics.custom.outputPlaceholder": "Select an output variable",
"metrics.custom.outputTitle": "Output",
"metrics.custom.sourcePlaceholder": "Source variable",
"metrics.custom.targetPlaceholder": "Target variable",
"metrics.custom.title": "Custom Evaluator",
"metrics.custom.warningBadge": "Needs setup",
"metrics.custom.workflowLabel": "Evaluation Workflow",