feat(web): available evaluation workflow selector

This commit is contained in:
JzoNg
2026-04-03 16:06:33 +08:00
parent 9b02ccdd12
commit 5311b5d00d
17 changed files with 570 additions and 38 deletions

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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()
})
})
})

View File

@@ -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)}
/>

View File

@@ -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)

View File

@@ -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 }) => {

View File

@@ -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"
/>
)}

View File

@@ -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 & {

View File

@@ -67,6 +67,8 @@ export const createCustomMetric = (): EvaluationMetric => ({
badges: ['Workflow'],
customConfig: {
workflowId: null,
workflowAppId: null,
workflowName: null,
mappings: [createCustomMetricMapping()],
},
})

View File

@@ -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,

View File

@@ -70,6 +70,8 @@ export type CustomMetricMapping = {
export type CustomMetricConfig = {
workflowId: string | null
workflowAppId: string | null
workflowName: string | null
mappings: CustomMetricMapping[]
}

View File

@@ -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}',

View File

@@ -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,
},

View File

@@ -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",

View File

@@ -51,6 +51,7 @@
"judgeModel.title": "判定模型",
"metrics.add": "添加指标",
"metrics.addCustom": "添加自定义指标",
"metrics.addNode": "添加节点",
"metrics.added": "已添加",
"metrics.custom.addMapping": "添加映射",
"metrics.custom.description": "选择评测工作流并完成变量映射后即可运行测试。",

View File

@@ -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,
})
}

View File

@@ -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[]
}