diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 8679b2830d..3189eb8990 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,7 +1,6 @@ -import type { AppPublisherProps } from '@/app/components/app/app-publisher' -import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' -import type { FileUpload } from '@/app/components/base/features/types' -import type { PublishWorkflowParams } from '@/types/workflow' +import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher' +import type { Features, FileUpload } from '@/app/components/base/features/types' +import type { ModelConfig } from '@/models/debug' import { AlertDialog, AlertDialogActions, @@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { Resolution } from '@/types/app' +type PublishedModelConfig = ModelConfig & { + resetAppConfig?: () => void +} + type Props = Omit & { - onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise | any - publishedConfig?: any + onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise | unknown + publishedConfig: { + modelConfig: PublishedModelConfig + } resetAppConfig?: () => void } @@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { setRestoreConfirmOpen(false) }, [featuresStore, props]) - const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => { + const handlePublish = useCallback((params?: AppPublisherPublishParams) => { return props.onPublish?.(params, features) }, [features, props]) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 3a38c1b496..0b6b783222 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -3,9 +3,7 @@ import type { ModelAndParameter } from '../configuration/debug/types' import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' -import type { EvaluationWorkflowAssociatedTarget } from '@/types/evaluation' -import type { I18nKeysWithPrefix } from '@/types/i18n' -import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow' +import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' @@ -42,11 +40,9 @@ import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/acces import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { useConvertWorkflowTypeMutation } from '@/service/use-apps' -import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation' import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' -import { AppModeEnum, AppTypeEnum } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' @@ -57,12 +53,20 @@ import { PublisherSummarySection, } from './sections' import SuggestedAction from './suggested-action' +import { useWorkflowTypeSwitch } from './use-workflow-type-switch' import { getDisabledFunctionTooltip, getPublisherAppUrl, isPublisherAccessConfigured, } from './utils' +export type AppPublisherPublishParams + = | ModelAndParameter + | (Pick & { + url?: string + id?: string + }) + export type AppPublisherProps = { disabled?: boolean publishDisabled?: boolean @@ -72,8 +76,8 @@ export type AppPublisherProps = { debugWithMultipleModel?: boolean multipleModelConfigs?: ModelAndParameter[] /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: (params?: any) => Promise | any - onRestore?: () => Promise | any + onPublish?: (params?: AppPublisherPublishParams) => Promise | unknown + onRestore?: () => Promise | unknown onToggle?: (state: boolean) => void crossAxisOffset?: number toolPublished?: boolean @@ -89,32 +93,6 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] -type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'> - -const WORKFLOW_TYPE_SWITCH_CONFIG: Record = { - workflow: { - targetType: 'evaluation', - publishLabelKey: 'common.publishAsEvaluationWorkflow', - switchLabelKey: 'common.switchToEvaluationWorkflow', - tipKey: 'common.switchToEvaluationWorkflowTip', - }, - evaluation: { - targetType: 'workflow', - publishLabelKey: 'common.publishAsStandardWorkflow', - switchLabelKey: 'common.switchToStandardWorkflow', - tipKey: 'common.switchToStandardWorkflowTip', - }, -} as const - -const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => { - return type === 'workflow' || type === 'evaluation' -} - const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -141,8 +119,6 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false) - const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState([]) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) @@ -157,36 +133,10 @@ const AppPublisher = ({ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation() const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) - const workflowTypeSwitchConfig = useMemo(() => { - if (!appDetail?.workflow_kind) - return WORKFLOW_TYPE_SWITCH_CONFIG.workflow - if (!isWorkflowTypeConversionTarget(appDetail?.workflow_kind)) - return undefined - - return WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.workflow_kind] - }, [appDetail?.workflow_kind]) - const isEvaluationWorkflowType = appDetail?.workflow_kind === AppTypeEnum.EVALUATION - const { - refetch: refetchEvaluationWorkflowAssociatedTargets, - isFetching: isFetchingEvaluationWorkflowAssociatedTargets, - } = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false }) - const workflowTypeSwitchDisabledReason = useMemo(() => { - if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION) - return undefined - - if (!canAccessSnippetsAndEvaluation) - return t('compliance.sandboxUpgradeTooltip', { ns: 'common' }) - - if (!hasHumanInputNode && !hasTriggerNode) - return undefined - - return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' }) - }, [canAccessSnippetsAndEvaluation, hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType]) const hiddenLaunchVariables = useMemo( () => (inputs ?? []).filter(input => input.hide === true), [inputs], @@ -230,7 +180,7 @@ const AppPublisher = ({ refetch() }, [open, appDetail, refetch, systemFeatures]) - const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => { + const handlePublish = useCallback(async (params?: AppPublisherPublishParams) => { try { await onPublish?.(params) setPublished(true) @@ -312,109 +262,8 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) - const getWorkflowTypeSwitchPublishUrl = useCallback(() => { - if (!appDetail?.id || !workflowTypeSwitchConfig) - return undefined - - if (workflowTypeSwitchConfig.targetType === AppTypeEnum.EVALUATION) - return `/apps/${appDetail.id}/workflows/publish/evaluation` - - return `/apps/${appDetail.id}/workflows/publish` - }, [appDetail?.id, workflowTypeSwitchConfig]) - - const performWorkflowTypeSwitch = useCallback(async () => { - if (!appDetail?.id || !workflowTypeSwitchConfig) - return false - - try { - if (!publishedAt) { - const publishUrl = getWorkflowTypeSwitchPublishUrl() - if (!publishUrl) - return false - - await handlePublish({ - url: publishUrl, - title: '', - releaseNotes: '', - }) - - const latestAppDetail = await fetchAppDetailDirect({ - url: '/apps', - id: appDetail.id, - }) - setAppDetail(latestAppDetail) - setShowEvaluationWorkflowSwitchConfirm(false) - setEvaluationWorkflowSwitchTargets([]) - return true - } - - await convertWorkflowType({ - params: { - appId: appDetail.id, - }, - query: { - target_type: workflowTypeSwitchConfig.targetType, - }, - }) - - const latestAppDetail = await fetchAppDetailDirect({ - url: '/apps', - id: appDetail.id, - }) - setAppDetail(latestAppDetail) - - if (publishedAt) - setOpen(false) - - setShowEvaluationWorkflowSwitchConfirm(false) - setEvaluationWorkflowSwitchTargets([]) - return true - } - catch { - return false - } - }, [appDetail?.id, convertWorkflowType, getWorkflowTypeSwitchPublishUrl, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig]) - - const handleWorkflowTypeSwitch = useCallback(async () => { - if (!appDetail?.id || !workflowTypeSwitchConfig) - return - if (workflowTypeSwitchDisabledReason) { - toast.error(workflowTypeSwitchDisabledReason) - return - } - - if (appDetail.workflow_kind === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) { - const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets() - - if (associatedTargetsResult.isError) { - toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' })) - return - } - - const associatedTargets = associatedTargetsResult.data?.items ?? [] - if (associatedTargets.length > 0) { - setEvaluationWorkflowSwitchTargets(associatedTargets) - setShowEvaluationWorkflowSwitchConfirm(true) - return - } - } - - await performWorkflowTypeSwitch() - }, [ - appDetail?.id, - appDetail?.workflow_kind, - performWorkflowTypeSwitch, - refetchEvaluationWorkflowAssociatedTargets, - t, - workflowTypeSwitchConfig, - workflowTypeSwitchDisabledReason, - ]) - - const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => { - setShowEvaluationWorkflowSwitchConfirm(nextOpen) - - if (!nextOpen) - setEvaluationWorkflowSwitchTargets([]) + const handlePublishedWorkflowTypeSwitch = useCallback(() => { + setOpen(false) }, []) const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => { @@ -442,6 +291,29 @@ const AppPublisher = ({ window.open(targetUrl, '_blank') setWorkflowLaunchDialogOpen(false) }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) + const { + evaluationWorkflowSwitchTargets, + handleEvaluationWorkflowSwitchConfirmOpenChange, + handleWorkflowTypeSwitch, + isConvertingWorkflowType, + isEvaluationWorkflowType, + performWorkflowTypeSwitch, + showEvaluationWorkflowSwitchConfirm, + workflowTypeSwitchConfig, + workflowTypeSwitchDisabled, + workflowTypeSwitchDisabledReason, + } = useWorkflowTypeSwitch({ + appDetail, + canAccessSnippetsAndEvaluation, + hasHumanInputNode, + hasTriggerNode, + onPublish: handlePublish, + onPublishedSwitch: handlePublishedWorkflowTypeSwitch, + published, + publishedAt, + publishDisabled, + setAppDetail, + }) const handlePublishToMarketplace = useCallback(async () => { if (!appDetail?.id || publishingToMarketplace) @@ -541,7 +413,7 @@ const AppPublisher = ({ startNodeLimitExceeded={startNodeLimitExceeded} upgradeHighlightStyle={upgradeHighlightStyle} workflowTypeSwitchConfig={workflowTypeSwitchConfig} - workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || isFetchingEvaluationWorkflowAssociatedTargets || Boolean(workflowTypeSwitchDisabledReason)} + workflowTypeSwitchDisabled={workflowTypeSwitchDisabled} workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason} onWorkflowTypeSwitch={handleWorkflowTypeSwitch} /> diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 8ecd37d158..e3c39fb55c 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -1,8 +1,8 @@ import type { CSSProperties, ReactNode } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { AppPublisherProps } from './index' -import type { I18nKeysWithPrefix } from '@/types/i18n' -import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow' +import type { WorkflowTypeSwitchConfig } from './use-workflow-type-switch' +import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Tooltip, @@ -23,8 +23,6 @@ import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' import { ACCESS_MODE_MAP } from './utils' -type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'> - type SummarySectionProps = Pick + +export type WorkflowTypeSwitchConfig = { + targetType: WorkflowTypeConversionTarget + publishLabelKey: WorkflowTypeSwitchLabelKey + switchLabelKey: WorkflowTypeSwitchLabelKey + tipKey: WorkflowTypeSwitchLabelKey +} + +const WORKFLOW_TYPE_SWITCH_CONFIG: Record = { + workflow: { + targetType: 'evaluation', + publishLabelKey: 'common.publishAsEvaluationWorkflow', + switchLabelKey: 'common.switchToEvaluationWorkflow', + tipKey: 'common.switchToEvaluationWorkflowTip', + }, + evaluation: { + targetType: 'workflow', + publishLabelKey: 'common.publishAsStandardWorkflow', + switchLabelKey: 'common.switchToStandardWorkflow', + tipKey: 'common.switchToStandardWorkflowTip', + }, +} as const + +const getWorkflowTypeSwitchConfig = (workflowKind?: WorkflowKind | null) => { + if (!workflowKind || workflowKind === 'standard') + return WORKFLOW_TYPE_SWITCH_CONFIG.workflow + + if (workflowKind === 'evaluation') + return WORKFLOW_TYPE_SWITCH_CONFIG.evaluation +} + +type UseWorkflowTypeSwitchParams = { + appDetail?: App & Partial + canAccessSnippetsAndEvaluation: boolean + hasHumanInputNode: boolean + hasTriggerNode: boolean + onPublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + onPublishedSwitch: () => void + published: boolean + publishedAt?: number + publishDisabled: boolean + setAppDetail: (appDetail?: App & Partial) => void +} + +export const useWorkflowTypeSwitch = ({ + appDetail, + canAccessSnippetsAndEvaluation, + hasHumanInputNode, + hasTriggerNode, + onPublish, + onPublishedSwitch, + published, + publishedAt, + publishDisabled, + setAppDetail, +}: UseWorkflowTypeSwitchParams) => { + const { t } = useTranslation() + const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false) + const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState([]) + const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation() + const { + refetch: refetchEvaluationWorkflowAssociatedTargets, + isFetching: isFetchingEvaluationWorkflowAssociatedTargets, + } = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false }) + + const workflowTypeSwitchConfig = useMemo(() => { + return getWorkflowTypeSwitchConfig(appDetail?.workflow_kind) + }, [appDetail?.workflow_kind]) + + const workflowTypeSwitchDisabledReason = useMemo(() => { + if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION) + return undefined + + if (!canAccessSnippetsAndEvaluation) + return t('compliance.sandboxUpgradeTooltip', { ns: 'common' }) + + if (!hasHumanInputNode && !hasTriggerNode) + return undefined + + return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' }) + }, [canAccessSnippetsAndEvaluation, hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType]) + + const getWorkflowTypeSwitchPublishUrl = useCallback(() => { + if (!appDetail?.id || !workflowTypeSwitchConfig) + return undefined + + if (workflowTypeSwitchConfig.targetType === AppTypeEnum.EVALUATION) + return `/apps/${appDetail.id}/workflows/publish/evaluation` + + return `/apps/${appDetail.id}/workflows/publish` + }, [appDetail?.id, workflowTypeSwitchConfig]) + + const resetEvaluationWorkflowSwitchConfirm = useCallback(() => { + setShowEvaluationWorkflowSwitchConfirm(false) + setEvaluationWorkflowSwitchTargets([]) + }, []) + + const performWorkflowTypeSwitch = useCallback(async () => { + if (!appDetail?.id || !workflowTypeSwitchConfig) + return false + + try { + if (!publishedAt) { + const publishUrl = getWorkflowTypeSwitchPublishUrl() + if (!publishUrl) + return false + + await onPublish({ + url: publishUrl, + title: '', + releaseNotes: '', + }) + + const latestAppDetail = await fetchAppDetailDirect({ + url: '/apps', + id: appDetail.id, + }) + setAppDetail(latestAppDetail) + resetEvaluationWorkflowSwitchConfirm() + return true + } + + await convertWorkflowType({ + params: { + appId: appDetail.id, + }, + query: { + target_type: workflowTypeSwitchConfig.targetType, + }, + }) + + const latestAppDetail = await fetchAppDetailDirect({ + url: '/apps', + id: appDetail.id, + }) + setAppDetail(latestAppDetail) + onPublishedSwitch() + resetEvaluationWorkflowSwitchConfirm() + return true + } + catch { + return false + } + }, [ + appDetail?.id, + convertWorkflowType, + getWorkflowTypeSwitchPublishUrl, + onPublish, + onPublishedSwitch, + publishedAt, + resetEvaluationWorkflowSwitchConfirm, + setAppDetail, + workflowTypeSwitchConfig, + ]) + + const handleWorkflowTypeSwitch = useCallback(async () => { + if (!appDetail?.id || !workflowTypeSwitchConfig) + return + + if (workflowTypeSwitchDisabledReason) { + toast.error(workflowTypeSwitchDisabledReason) + return + } + + if (appDetail.workflow_kind === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) { + const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets() + + if (associatedTargetsResult.isError) { + toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' })) + return + } + + const associatedTargets = associatedTargetsResult.data?.items ?? [] + if (associatedTargets.length > 0) { + setEvaluationWorkflowSwitchTargets(associatedTargets) + setShowEvaluationWorkflowSwitchConfirm(true) + return + } + } + + await performWorkflowTypeSwitch() + }, [ + appDetail?.id, + appDetail?.workflow_kind, + performWorkflowTypeSwitch, + refetchEvaluationWorkflowAssociatedTargets, + t, + workflowTypeSwitchConfig, + workflowTypeSwitchDisabledReason, + ]) + + const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => { + setShowEvaluationWorkflowSwitchConfirm(nextOpen) + + if (!nextOpen) + setEvaluationWorkflowSwitchTargets([]) + }, []) + + return { + evaluationWorkflowSwitchTargets, + handleEvaluationWorkflowSwitchConfirmOpenChange, + handleWorkflowTypeSwitch, + isConvertingWorkflowType, + isEvaluationWorkflowType: appDetail?.workflow_kind === AppTypeEnum.EVALUATION, + performWorkflowTypeSwitch, + showEvaluationWorkflowSwitchConfirm, + workflowTypeSwitchConfig, + workflowTypeSwitchDisabled: publishDisabled + || published + || isConvertingWorkflowType + || isFetchingEvaluationWorkflowAssociatedTargets + || Boolean(workflowTypeSwitchDisabledReason), + workflowTypeSwitchDisabledReason, + } +} diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index 943642b545..118e18ec21 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -1,5 +1,6 @@ 'use client' import type { ComponentProps } from 'react' +import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher' import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types' @@ -21,7 +22,6 @@ import type { TextToSpeechConfig, } from '@/models/debug' import type { VisionSettings } from '@/types/app' -import type { PublishWorkflowParams } from '@/types/workflow' import { useBoolean, useGetState } from 'ahooks' import { clone } from 'es-toolkit/object' import { produce } from 'immer' @@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => { resolvedModelModeType, ]) - const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => { + const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => { const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params ? params : undefined diff --git a/web/app/components/evaluation/__tests__/index.spec.tsx b/web/app/components/evaluation/__tests__/index.spec.tsx index 3d1ae351d3..95e295903d 100644 --- a/web/app/components/evaluation/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import ConditionsSection from '../components/conditions-section' import { useEvaluationStore } from '../store' const mockUpload = vi.hoisted(() => vi.fn()) -const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn()) +const mockUseDatasetEvaluationMetrics = vi.hoisted(() => vi.fn()) const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn()) const mockUseEvaluationConfig = vi.hoisted(() => vi.fn()) const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn()) @@ -51,7 +51,7 @@ vi.mock('@/service/base', () => ({ vi.mock('@/service/use-evaluation', () => ({ useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args), - useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args), + useDatasetEvaluationMetrics: (...args: unknown[]) => mockUseDatasetEvaluationMetrics(...args), useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args), useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args), useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args), @@ -119,7 +119,7 @@ describe('Evaluation', () => { data: null, }) - mockUseAvailableEvaluationMetrics.mockReturnValue({ + mockUseDatasetEvaluationMetrics.mockReturnValue({ data: { metrics: ['answer-correctness', 'faithfulness', 'context-precision', 'context-recall', 'context-relevance'], }, @@ -582,6 +582,7 @@ describe('Evaluation', () => { it('should render the pipeline-specific layout without auto-selecting a judge model', () => { renderWithQueryClient() + expect(mockUseDatasetEvaluationMetrics).toHaveBeenCalledWith('dataset-1') expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('empty') expect(screen.getByText('evaluation.history.columns.time')).toBeInTheDocument() expect(screen.getByText('Context Precision')).toBeInTheDocument() @@ -621,6 +622,33 @@ describe('Evaluation', () => { expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeEnabled() }) + it('should download the fixed pipeline template columns', () => { + const createElement = document.createElement.bind(document) + let downloadLink: HTMLAnchorElement | undefined + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => { + const element = createElement(tagName, options) + + if (tagName === 'a') { + downloadLink = element as HTMLAnchorElement + vi.spyOn(downloadLink, 'click').mockImplementation(() => {}) + } + + return element + }) + + renderWithQueryClient() + + fireEvent.click(screen.getByRole('button', { name: 'select-model' })) + fireEvent.click(screen.getByRole('button', { name: /Context Precision/i })) + fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' })) + + expect(downloadLink?.download).toBe('pipeline-evaluation-template.csv') + expect(decodeURIComponent(downloadLink?.href ?? '')).toContain('query,expect_results\n') + expect(decodeURIComponent(downloadLink?.href ?? '')).not.toContain('expected_output') + + createElementSpy.mockRestore() + }) + it('should upload and start a pipeline evaluation run', async () => { const startRun = vi.fn() mockUseStartEvaluationRunMutation.mockReturnValue({ @@ -639,14 +667,14 @@ describe('Evaluation', () => { fireEvent.click(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })) expect(screen.getAllByText('query').length).toBeGreaterThan(0) - expect(screen.getAllByText('Expect Results').length).toBeGreaterThan(0) + expect(screen.getAllByText('expect_results').length).toBeGreaterThan(0) const fileInput = document.querySelector('input[type="file"][accept=".csv"]') expect(fileInput).toBeInTheDocument() fireEvent.change(fileInput!, { target: { - files: [new File(['query,Expect Results'], 'pipeline-evaluation.csv', { type: 'text/csv' })], + files: [new File(['query,expect_results'], 'pipeline-evaluation.csv', { type: 'text/csv' })], }, }) diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts index f390b9a9ad..c4b4b0182b 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts @@ -21,6 +21,7 @@ type UseInputFieldsActionsParams = EvaluationResourceProps & { isInputFieldsLoading: boolean isPanelReady: boolean isRunnable: boolean + templateContent?: string templateFileName: string } @@ -31,6 +32,7 @@ export const useInputFieldsActions = ({ isInputFieldsLoading, isPanelReady, isRunnable, + templateContent, templateFileName, }: UseInputFieldsActionsParams) => { const { t } = useTranslation('evaluation') @@ -79,7 +81,7 @@ export const useInputFieldsActions = ({ return } - const content = buildTemplateCsvContent(inputFields) + const content = templateContent ?? buildTemplateCsvContent(inputFields) const link = document.createElement('a') link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}` link.download = templateFileName diff --git a/web/app/components/evaluation/components/judge-model-selector.tsx b/web/app/components/evaluation/components/judge-model-selector.tsx index 8f9ee4aff6..ac954ed086 100644 --- a/web/app/components/evaluation/components/judge-model-selector.tsx +++ b/web/app/components/evaluation/components/judge-model-selector.tsx @@ -1,39 +1,21 @@ 'use client' import type { EvaluationResourceProps } from '../types' -import { useEffect } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useEvaluationResource, useEvaluationStore } from '../store' import { decodeModelSelection, encodeModelSelection } from '../utils' -type JudgeModelSelectorProps = EvaluationResourceProps & { - autoSelectFirst?: boolean -} - const JudgeModelSelector = ({ resourceType, resourceId, - autoSelectFirst = true, -}: JudgeModelSelectorProps) => { +}: EvaluationResourceProps) => { const { data: modelList } = useModelList(ModelTypeEnum.textGeneration) const resource = useEvaluationResource(resourceType, resourceId) const setJudgeModel = useEvaluationStore(state => state.setJudgeModel) const selectedModel = decodeModelSelection(resource.judgeModelId) - useEffect(() => { - if (!autoSelectFirst || resource.judgeModelId || !modelList.length) - return - - const firstProvider = modelList[0] - const firstModel = firstProvider.models[0] - if (!firstProvider || !firstModel) - return - - setJudgeModel(resourceType, resourceId, encodeModelSelection(firstProvider.provider, firstModel.model)) - }, [autoSelectFirst, modelList, resource.judgeModelId, resourceId, resourceType, setJudgeModel]) - return ( -
+
+
@@ -77,7 +76,7 @@ const PipelineEvaluation = ({
-
+
-
+
+
)}
diff --git a/web/app/components/evaluation/components/pipeline/pipeline-metrics-section.tsx b/web/app/components/evaluation/components/pipeline/pipeline-metrics-section.tsx index 553083f867..568b66b5a6 100644 --- a/web/app/components/evaluation/components/pipeline/pipeline-metrics-section.tsx +++ b/web/app/components/evaluation/components/pipeline/pipeline-metrics-section.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { BlockEnum } from '@/app/components/workflow/types' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useAvailableEvaluationMetrics } from '@/service/use-evaluation' +import { useDatasetEvaluationMetrics } from '@/service/use-evaluation' import { usePublishedPipelineInfo } from '@/service/use-pipeline' import { useEvaluationResource, useEvaluationStore } from '../../store' import { buildMetricOption } from '../metric-selector/utils' @@ -49,7 +49,7 @@ const PipelineMetricsSection = ({ const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric) const removeMetric = useEvaluationStore(state => state.removeMetric) const updateMetricThreshold = useEvaluationStore(state => state.updateMetricThreshold) - const { data: availableMetricsData } = useAvailableEvaluationMetrics() + const { data: datasetMetricsData } = useDatasetEvaluationMetrics(resourceId) const { data: publishedPipeline } = usePublishedPipelineInfo(pipelineId || '') const resource = useEvaluationResource(resourceType, resourceId) const knowledgeIndexNodeInfoList = useMemo( @@ -63,12 +63,12 @@ const PipelineMetricsSection = ({ ), [resource.metrics]) const availableBuiltinMetrics = useMemo(() => { const metricIds = new Set([ - ...(availableMetricsData?.metrics ?? []), + ...(datasetMetricsData?.metrics ?? []), ...builtinMetricMap.keys(), ]) return Array.from(metricIds).map(metricId => buildMetricOption(metricId)) - }, [availableMetricsData?.metrics, builtinMetricMap]) + }, [datasetMetricsData?.metrics, builtinMetricMap]) useEffect(() => { if (!knowledgeIndexNodeInfoList.length) diff --git a/web/app/components/evaluation/components/pipeline/pipeline-results-panel.tsx b/web/app/components/evaluation/components/pipeline/pipeline-results-panel.tsx index c66f3ecc2f..271302da9d 100644 --- a/web/app/components/evaluation/components/pipeline/pipeline-results-panel.tsx +++ b/web/app/components/evaluation/components/pipeline/pipeline-results-panel.tsx @@ -57,7 +57,7 @@ const PipelineResultsPanel = ({ if (isEmpty) { return ( -
+