mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
feat(web): add output
This commit is contained in:
@ -0,0 +1,171 @@
|
||||
import type { EvaluationMetric } 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 type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import CustomMetricEditorCard from '..'
|
||||
import { useEvaluationStore } from '../../../store'
|
||||
|
||||
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
|
||||
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
|
||||
}))
|
||||
|
||||
const createStartNode = (): Node<StartNodeType> => ({
|
||||
id: 'start-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
variables: [],
|
||||
},
|
||||
})
|
||||
|
||||
const createEndNode = (
|
||||
outputs: EndNodeType['outputs'],
|
||||
): Node<EndNodeType> => ({
|
||||
id: 'end-node',
|
||||
type: 'custom',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
desc: '',
|
||||
outputs,
|
||||
},
|
||||
})
|
||||
|
||||
const createWorkflow = (
|
||||
nodes: Node[],
|
||||
): FetchWorkflowDraftResponse => ({
|
||||
id: 'workflow-1',
|
||||
graph: {
|
||||
nodes,
|
||||
edges: [],
|
||||
},
|
||||
features: {},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
email: 'user-one@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1710000001,
|
||||
updated_by: {
|
||||
id: 'user-2',
|
||||
name: 'User Two',
|
||||
email: 'user-two@example.com',
|
||||
},
|
||||
tool_published: true,
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
version: '1',
|
||||
marked_name: 'Evaluation Workflow',
|
||||
marked_comment: 'Published',
|
||||
})
|
||||
|
||||
const createMetric = (): EvaluationMetric => ({
|
||||
id: 'metric-1',
|
||||
optionId: 'custom-1',
|
||||
kind: 'custom-workflow',
|
||||
label: 'Custom Evaluator',
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
customConfig: {
|
||||
workflowId: 'workflow-1',
|
||||
workflowAppId: 'app-1',
|
||||
workflowName: 'Evaluation Workflow',
|
||||
mappings: [{
|
||||
id: 'mapping-1',
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
describe('CustomMetricEditorCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
|
||||
mockUseInfiniteScroll.mockImplementation(() => undefined)
|
||||
mockUseAvailableEvaluationWorkflows.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
has_more: false,
|
||||
}],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Verify end-node outputs are shown after a workflow is selected.
|
||||
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 },
|
||||
]),
|
||||
]),
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="apps"
|
||||
resourceId="app-1"
|
||||
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()
|
||||
})
|
||||
|
||||
it('should hide the output section when the selected workflow has no end outputs', () => {
|
||||
mockUseAppWorkflow.mockReturnValue({
|
||||
data: createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([]),
|
||||
]),
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="apps"
|
||||
resourceId="app-1"
|
||||
metric={createMetric()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
@ -29,6 +30,22 @@ const getWorkflowTargetVariables = (
|
||||
}))
|
||||
}
|
||||
|
||||
const getWorkflowOutputs = (nodes?: Array<Node>) => {
|
||||
return (nodes ?? [])
|
||||
.filter(node => node.data.type === BlockEnum.End)
|
||||
.flatMap((node) => {
|
||||
const endNode = node as Node<EndNodeType>
|
||||
if (!Array.isArray(endNode.data.outputs))
|
||||
return []
|
||||
|
||||
return endNode.data.outputs
|
||||
.map(output => ({
|
||||
variable: output.variable,
|
||||
valueType: output.value_type,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const getWorkflowName = (workflow: {
|
||||
marked_name?: string
|
||||
app_name?: string
|
||||
@ -51,13 +68,16 @@ const CustomMetricEditorCard = ({
|
||||
const targetOptions = useMemo(() => {
|
||||
return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes)
|
||||
}, [selectedWorkflow?.graph.nodes])
|
||||
const workflowOutputs = useMemo(() => {
|
||||
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
|
||||
}, [selectedWorkflow?.graph.nodes])
|
||||
const isConfigured = isCustomMetricConfigured(metric)
|
||||
|
||||
if (!metric.customConfig)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<div className="px-3 pt-1 pb-3">
|
||||
<WorkflowSelector
|
||||
value={metric.customConfig.workflowId}
|
||||
selectedWorkflowName={metric.customConfig.workflowName ?? (selectedWorkflow ? getWorkflowName(selectedWorkflow) : null)}
|
||||
@ -68,16 +88,37 @@ const CustomMetricEditorCard = ({
|
||||
})}
|
||||
/>
|
||||
|
||||
{!!workflowOutputs.length && (
|
||||
<div className="mt-4 py-1">
|
||||
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('metrics.custom.outputTitle')}
|
||||
</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>
|
||||
{output.valueType && (
|
||||
<span>{output.valueType}</span>
|
||||
)}
|
||||
{index < workflowOutputs.length - 1 && (
|
||||
<span className="pl-0.5">,</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.mappingTitle')}</div>
|
||||
<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="i-ri-add-line mr-1 h-4 w-4" />
|
||||
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
|
||||
{t('metrics.custom.addMapping')}
|
||||
</Button>
|
||||
</div>
|
||||
@ -94,7 +135,7 @@ const CustomMetricEditorCard = ({
|
||||
))}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -25,12 +25,12 @@ const CustomMetricCard = ({
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
|
||||
<div className="flex items-center justify-between gap-3 px-3 pb-1 pt-3">
|
||||
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-1">
|
||||
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="truncate text-text-secondary system-md-medium">{metric.label}</div>
|
||||
<div className="truncate system-md-medium text-text-secondary">{metric.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@ -43,7 +43,7 @@ const CustomMetricCard = ({
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t('metrics.remove')}
|
||||
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity hover:text-text-secondary focus-visible:opacity-100 group-hover:opacity-100"
|
||||
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
|
||||
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||
|
||||
@ -70,6 +70,7 @@
|
||||
"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.outputTitle": "Output",
|
||||
"metrics.custom.sourcePlaceholder": "Source variable",
|
||||
"metrics.custom.targetPlaceholder": "Target variable",
|
||||
"metrics.custom.title": "Custom Evaluator",
|
||||
|
||||
Reference in New Issue
Block a user