mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 04:59:23 +08:00
feat(web): available evaluation workflow selector
This commit is contained in:
@@ -9,8 +9,6 @@ import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const Select = BaseSelect.Root
|
||||
export const SelectGroup = BaseSelect.Group
|
||||
export const SelectGroupLabel = BaseSelect.GroupLabel
|
||||
export const SelectValue = BaseSelect.Value
|
||||
/** @public */
|
||||
export const SelectGroup = BaseSelect.Group
|
||||
|
||||
@@ -24,7 +24,11 @@ describe('evaluation store', () => {
|
||||
expect(initialMetric).toBeDefined()
|
||||
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
|
||||
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, {
|
||||
workflowId: config.workflowOptions[0].id,
|
||||
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,
|
||||
@@ -32,6 +36,8 @@ describe('evaluation store', () => {
|
||||
|
||||
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
|
||||
expect(configuredMetric!.customConfig!.workflowAppId).toBe('custom-workflow-app-id')
|
||||
expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label)
|
||||
})
|
||||
|
||||
it('should add and remove builtin metrics', () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import WorkflowSelector from '../workflow-selector'
|
||||
|
||||
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
|
||||
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
|
||||
|
||||
let loadMoreHandler: (() => Promise<{ list: unknown[] }>) | null = null
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
|
||||
}))
|
||||
|
||||
const createWorkflow = (
|
||||
overrides: Partial<AvailableEvaluationWorkflow> = {},
|
||||
): AvailableEvaluationWorkflow => ({
|
||||
id: 'workflow-1',
|
||||
app_id: 'app-1',
|
||||
app_name: 'Review Workflow App',
|
||||
type: 'evaluation',
|
||||
version: '1',
|
||||
marked_name: 'Review Workflow',
|
||||
marked_comment: 'Production release',
|
||||
hash: 'hash-1',
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
email: 'user-one@example.com',
|
||||
},
|
||||
created_at: 1710000000,
|
||||
updated_by: null,
|
||||
updated_at: 1710000000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setupWorkflowQueryMock = (overrides?: {
|
||||
workflows?: AvailableEvaluationWorkflow[]
|
||||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
}) => {
|
||||
const fetchNextPage = vi.fn()
|
||||
|
||||
mockUseAvailableEvaluationWorkflows.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
items: overrides?.workflows ?? [createWorkflow()],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
has_more: overrides?.hasNextPage ?? false,
|
||||
}],
|
||||
},
|
||||
fetchNextPage,
|
||||
hasNextPage: overrides?.hasNextPage ?? false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: overrides?.isFetchingNextPage ?? false,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
return { fetchNextPage }
|
||||
}
|
||||
|
||||
const renderWorkflowSelector = (props?: Partial<ComponentProps<typeof WorkflowSelector>>) => {
|
||||
return render(
|
||||
<WorkflowSelector
|
||||
value={null}
|
||||
onSelect={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
loadMoreHandler = null
|
||||
|
||||
setupWorkflowQueryMock()
|
||||
mockUseInfiniteScroll.mockImplementation((handler) => {
|
||||
loadMoreHandler = handler as () => Promise<{ list: unknown[] }>
|
||||
})
|
||||
})
|
||||
|
||||
// Cover trigger rendering and selected label fallback.
|
||||
describe('Rendering', () => {
|
||||
it('should render the workflow placeholder when value is empty', () => {
|
||||
renderWorkflowSelector()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the selected workflow name from props when value is set', () => {
|
||||
setupWorkflowQueryMock({ workflows: [] })
|
||||
|
||||
renderWorkflowSelector({
|
||||
value: 'workflow-1',
|
||||
selectedWorkflowName: 'Saved Review Workflow',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Saved Review Workflow')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Cover opening the popover and choosing one workflow option.
|
||||
describe('Interactions', () => {
|
||||
it('should call onSelect with the clicked workflow', async () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
renderWorkflowSelector({ onSelect })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
|
||||
|
||||
const option = await screen.findByRole('option', { name: 'Review Workflow' })
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(createWorkflow())
|
||||
})
|
||||
})
|
||||
|
||||
// Cover the infinite-scroll callback used by the ScrollArea viewport.
|
||||
describe('Pagination', () => {
|
||||
it('should fetch the next page when the load-more callback runs and more pages exist', async () => {
|
||||
const { fetchNextPage } = setupWorkflowQueryMock({ hasNextPage: true })
|
||||
|
||||
renderWorkflowSelector()
|
||||
|
||||
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
|
||||
|
||||
await act(async () => {
|
||||
await loadMoreHandler?.()
|
||||
})
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not fetch the next page when the current request is already fetching', async () => {
|
||||
const { fetchNextPage } = setupWorkflowQueryMock({
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: true,
|
||||
})
|
||||
|
||||
renderWorkflowSelector()
|
||||
|
||||
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
|
||||
|
||||
await act(async () => {
|
||||
await loadMoreHandler?.()
|
||||
})
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../types'
|
||||
import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../../types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
@@ -12,10 +15,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../store'
|
||||
import { groupFieldOptions } from '../utils'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
|
||||
import { groupFieldOptions } from '../../utils'
|
||||
import WorkflowSelector from './workflow-selector'
|
||||
|
||||
type CustomMetricEditorCardProps = EvaluationResourceProps & {
|
||||
metric: EvaluationMetric
|
||||
@@ -29,6 +35,27 @@ type MappingRowProps = {
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const getWorkflowTargetVariables = (
|
||||
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 => ({
|
||||
id: variable.variable,
|
||||
label: typeof variable.label === 'string' ? variable.label : variable.variable,
|
||||
}))
|
||||
}
|
||||
|
||||
const getWorkflowName = (workflow: {
|
||||
marked_name?: string
|
||||
app_name?: string
|
||||
id: string
|
||||
}) => {
|
||||
return workflow.marked_name || workflow.app_name || workflow.id
|
||||
}
|
||||
|
||||
function MappingRow({
|
||||
resourceType,
|
||||
mapping,
|
||||
@@ -82,12 +109,14 @@ const CustomMetricEditorCard = ({
|
||||
metric,
|
||||
}: CustomMetricEditorCardProps) => {
|
||||
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 { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
|
||||
const targetOptions = useMemo(() => {
|
||||
return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes)
|
||||
}, [selectedWorkflow?.graph.nodes])
|
||||
const isConfigured = isCustomMetricConfigured(metric)
|
||||
|
||||
if (!metric.customConfig)
|
||||
@@ -95,27 +124,15 @@ const CustomMetricEditorCard = ({
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<Select value={metric.customConfig.workflowId ?? ''} onValueChange={value => value && setCustomMetricWorkflow(resourceType, resourceId, metric.id, value)}>
|
||||
<SelectTrigger className="h-auto rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 px-1 py-1 text-left">
|
||||
<div className={cn('truncate system-sm-regular', selectedWorkflow ? 'text-text-secondary' : 'text-components-input-text-placeholder')}>
|
||||
{selectedWorkflow?.label ?? t('metrics.custom.workflowPlaceholder')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.workflowOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<WorkflowSelector
|
||||
value={metric.customConfig.workflowId}
|
||||
selectedWorkflowName={metric.customConfig.workflowName ?? (selectedWorkflow ? getWorkflowName(selectedWorkflow) : null)}
|
||||
onSelect={workflow => setCustomMetricWorkflow(resourceType, resourceId, metric.id, {
|
||||
workflowId: workflow.id,
|
||||
workflowAppId: workflow.app_id,
|
||||
workflowName: getWorkflowName(workflow),
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
@@ -136,7 +153,7 @@ const CustomMetricEditorCard = ({
|
||||
key={mapping.id}
|
||||
resourceType={resourceType}
|
||||
mapping={mapping}
|
||||
targetOptions={selectedWorkflow?.targetVariables ?? []}
|
||||
targetOptions={targetOptions}
|
||||
onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)}
|
||||
onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)}
|
||||
/>
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
|
||||
import { useInfiniteScroll } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useDeferredValue, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@/app/components/base/ui/scroll-area'
|
||||
import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type WorkflowSelectorProps = {
|
||||
value: string | null
|
||||
selectedWorkflowName?: string | null
|
||||
onSelect: (workflow: AvailableEvaluationWorkflow) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const getWorkflowName = (workflow: AvailableEvaluationWorkflow) => {
|
||||
return workflow.marked_name || workflow.app_name || workflow.id
|
||||
}
|
||||
|
||||
const WorkflowSelector = ({
|
||||
value,
|
||||
selectedWorkflowName,
|
||||
onSelect,
|
||||
}: WorkflowSelectorProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const keyword = deferredSearchText.trim() || undefined
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useAvailableEvaluationWorkflows(
|
||||
{
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
keyword,
|
||||
},
|
||||
{ enabled: isOpen },
|
||||
)
|
||||
|
||||
const workflows = useMemo(() => {
|
||||
return (data?.pages ?? []).flatMap(page => page.items)
|
||||
}, [data?.pages])
|
||||
|
||||
const currentWorkflowName = useMemo(() => {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
const selectedWorkflow = workflows.find(workflow => workflow.id === value)
|
||||
if (selectedWorkflow)
|
||||
return getWorkflowName(selectedWorkflow)
|
||||
|
||||
return selectedWorkflowName ?? null
|
||||
}, [selectedWorkflowName, value, workflows])
|
||||
|
||||
const isNoMore = hasNextPage === false
|
||||
|
||||
useInfiniteScroll(
|
||||
async () => {
|
||||
if (!hasNextPage || isFetchingNextPage)
|
||||
return { list: [] }
|
||||
|
||||
await fetchNextPage()
|
||||
return { list: [] }
|
||||
},
|
||||
{
|
||||
target: viewportRef,
|
||||
isNoMore: () => isNoMore,
|
||||
reloadDeps: [isFetchingNextPage, isNoMore, keyword],
|
||||
},
|
||||
)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setIsOpen(nextOpen)
|
||||
|
||||
if (!nextOpen)
|
||||
setSearchText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 text-left outline-hidden hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"
|
||||
aria-label={t('metrics.custom.workflowLabel')}
|
||||
>
|
||||
<div className="flex min-w-0 grow items-center gap-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 px-1 py-1 text-left">
|
||||
<div className={cn(
|
||||
'truncate system-sm-regular',
|
||||
currentWorkflowName ? 'text-text-secondary' : 'text-components-input-text-placeholder',
|
||||
)}>
|
||||
{currentWorkflowName ?? t('metrics.custom.workflowPlaceholder')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 px-1 text-text-quaternary transition-colors group-hover:text-text-secondary">
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[360px] overflow-hidden p-0"
|
||||
>
|
||||
<div className="bg-components-panel-bg">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={searchText}
|
||||
onChange={event => setSearchText(event.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(isLoading || (isFetching && workflows.length === 0))
|
||||
? (
|
||||
<div className="flex h-[120px] items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
: !workflows.length
|
||||
? (
|
||||
<div className="flex h-[120px] items-center justify-center text-text-tertiary system-sm-regular">
|
||||
{t('noData', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ScrollAreaRoot className="relative max-h-[240px] overflow-hidden">
|
||||
<ScrollAreaViewport ref={viewportRef}>
|
||||
<ScrollAreaContent className="p-1" role="listbox" aria-label={t('metrics.custom.workflowLabel')}>
|
||||
{workflows.map(workflow => (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={workflow.id === value}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onSelect(workflow)
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate px-1 py-1 text-text-secondary system-sm-medium">
|
||||
{getWorkflowName(workflow)}
|
||||
</div>
|
||||
{workflow.id === value && (
|
||||
<span aria-hidden="true" className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkflowSelector)
|
||||
@@ -1,11 +1,14 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import MetricSection from '..'
|
||||
import { useEvaluationStore } from '../../../store'
|
||||
|
||||
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
|
||||
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
|
||||
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
|
||||
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
|
||||
}))
|
||||
@@ -14,7 +17,19 @@ const resourceType = 'workflow' as const
|
||||
const resourceId = 'metric-section-resource'
|
||||
|
||||
const renderMetricSection = () => {
|
||||
return render(<MetricSection resourceType={resourceType} resourceId={resourceId} />)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MetricSection resourceType={resourceType} resourceId={resourceId} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('MetricSection', () => {
|
||||
@@ -29,6 +44,17 @@ describe('MetricSection', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseAvailableEvaluationWorkflows.mockReturnValue({
|
||||
data: {
|
||||
pages: [{ items: [], page: 1, limit: 20, has_more: false }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
|
||||
@@ -114,6 +114,7 @@ const BuiltinMetricCard = ({
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('metrics.addNode')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-background-default-hover text-text-tertiary transition-colors hover:bg-state-base-hover"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
|
||||
import CustomMetricEditorCard from '../custom-metric-editor-card'
|
||||
import CustomMetricEditorCard from '../custom-metric-editor'
|
||||
import { getToneClasses } from '../metric-selector/utils'
|
||||
|
||||
type CustomMetricCardProps = EvaluationResourceProps & {
|
||||
|
||||
@@ -67,6 +67,8 @@ export const createCustomMetric = (): EvaluationMetric => ({
|
||||
badges: ['Workflow'],
|
||||
customConfig: {
|
||||
workflowId: null,
|
||||
workflowAppId: null,
|
||||
workflowName: null,
|
||||
mappings: [createCustomMetricMapping()],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +32,12 @@ type EvaluationStore = {
|
||||
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void
|
||||
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
|
||||
setCustomMetricWorkflow: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
workflow: { workflowId: string, workflowAppId: string, workflowName: string },
|
||||
) => void
|
||||
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
@@ -122,7 +127,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflow) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
@@ -131,7 +136,9 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
workflowId,
|
||||
workflowId: workflow.workflowId,
|
||||
workflowAppId: workflow.workflowAppId,
|
||||
workflowName: workflow.workflowName,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
targetVariableId: null,
|
||||
|
||||
@@ -70,6 +70,8 @@ export type CustomMetricMapping = {
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
workflowId: string | null
|
||||
workflowAppId: string | null
|
||||
workflowName: string | null
|
||||
mappings: CustomMetricMapping[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AvailableEvaluationWorkflowsResponse,
|
||||
EvaluationConfig,
|
||||
EvaluationConfigData,
|
||||
EvaluationFileInfo,
|
||||
@@ -274,6 +275,21 @@ export const availableEvaluationMetricsContract = base
|
||||
})
|
||||
.output(type<EvaluationMetricsListResponse>())
|
||||
|
||||
export const availableEvaluationWorkflowsContract = base
|
||||
.route({
|
||||
path: '/workspaces/current/available-evaluation-workflows',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
query: {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
user_id?: string
|
||||
}
|
||||
}>())
|
||||
.output(type<AvailableEvaluationWorkflowsResponse>())
|
||||
|
||||
export const evaluationFileContract = base
|
||||
.route({
|
||||
path: '/{targetType}/{targetId}/evaluation/files/{fileId}',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { appDeleteContract, appWorkflowTypeConvertContract } from './console/app
|
||||
import { bindPartnerStackContract, invoicesContract } from './console/billing'
|
||||
import {
|
||||
availableEvaluationMetricsContract,
|
||||
availableEvaluationWorkflowsContract,
|
||||
cancelDatasetEvaluationRunContract,
|
||||
cancelEvaluationRunContract,
|
||||
datasetEvaluationConfigContract,
|
||||
@@ -132,6 +133,7 @@ export const consoleRouterContract = {
|
||||
metrics: evaluationMetricsContract,
|
||||
nodeInfo: evaluationNodeInfoContract,
|
||||
availableMetrics: availableEvaluationMetricsContract,
|
||||
availableWorkflows: availableEvaluationWorkflowsContract,
|
||||
file: evaluationFileContract,
|
||||
versionDetail: evaluationVersionDetailContract,
|
||||
},
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"judgeModel.title": "Judge Model",
|
||||
"metrics.add": "Add Metric",
|
||||
"metrics.addCustom": "Add Custom Metrics",
|
||||
"metrics.addNode": "Add Node",
|
||||
"metrics.added": "Added",
|
||||
"metrics.collapseNodes": "Collapse nodes",
|
||||
"metrics.custom.addMapping": "Add Mapping",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"judgeModel.title": "判定模型",
|
||||
"metrics.add": "添加指标",
|
||||
"metrics.addCustom": "添加自定义指标",
|
||||
"metrics.addNode": "添加节点",
|
||||
"metrics.added": "已添加",
|
||||
"metrics.custom.addMapping": "添加映射",
|
||||
"metrics.custom.description": "选择评测工作流并完成变量映射后即可运行测试。",
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import type { AvailableEvaluationWorkflowsResponse } from '@/types/evaluation'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
type AvailableEvaluationWorkflowsParams = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
const normalizeAvailableEvaluationWorkflowsParams = (params: AvailableEvaluationWorkflowsParams = {}) => {
|
||||
const page = params.page ?? 1
|
||||
const limit = params.limit ?? 20
|
||||
const keyword = params.keyword?.trim()
|
||||
const userId = params.userId?.trim()
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
...(keyword ? { keyword } : {}),
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const useAvailableEvaluationMetrics = (enabled = true) => {
|
||||
return useQuery(consoleQuery.evaluation.availableMetrics.queryOptions({
|
||||
@@ -10,3 +37,30 @@ export const useAvailableEvaluationMetrics = (enabled = true) => {
|
||||
export const useEvaluationNodeInfoMutation = () => {
|
||||
return useMutation(consoleQuery.evaluation.nodeInfo.mutationOptions())
|
||||
}
|
||||
|
||||
export const useAvailableEvaluationWorkflows = (
|
||||
params: AvailableEvaluationWorkflowsParams = {},
|
||||
options?: { enabled?: boolean },
|
||||
) => {
|
||||
const queryParams = normalizeAvailableEvaluationWorkflowsParams(params)
|
||||
|
||||
return useInfiniteQuery<AvailableEvaluationWorkflowsResponse>({
|
||||
queryKey: consoleQuery.evaluation.availableWorkflows.queryKey({
|
||||
input: {
|
||||
query: queryParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = queryParams.page }) => {
|
||||
return consoleClient.evaluation.availableWorkflows({
|
||||
query: {
|
||||
...queryParams,
|
||||
page: Number(pageParam),
|
||||
},
|
||||
})
|
||||
},
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: queryParams.page,
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,34 @@ export type EvaluationMetricsListResponse = {
|
||||
metrics: string[]
|
||||
}
|
||||
|
||||
export type EvaluationWorkflowOperator = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type AvailableEvaluationWorkflow = {
|
||||
id: string
|
||||
app_id: string
|
||||
app_name: string
|
||||
type: string
|
||||
version: string
|
||||
marked_name: string
|
||||
marked_comment: string
|
||||
hash: string
|
||||
created_by: EvaluationWorkflowOperator
|
||||
created_at: number
|
||||
updated_by: EvaluationWorkflowOperator | null
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type AvailableEvaluationWorkflowsResponse = {
|
||||
items: AvailableEvaluationWorkflow[]
|
||||
page: number
|
||||
limit: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type EvaluationNodeInfoRequest = {
|
||||
metrics?: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user