mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
refactor: input fields
This commit is contained in:
parent
acef9630d5
commit
2df79c0404
@ -1,58 +1,17 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { InputVar, Node } from '@/app/components/workflow/types'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { upload } from '@/service/base'
|
||||
import { useStartEvaluationRunMutation } from '@/service/use-evaluation'
|
||||
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import { buildEvaluationRunRequest } from '../../store-utils'
|
||||
import InputFieldsRequirements from './input-fields/input-fields-requirements'
|
||||
import UploadRunPopover from './input-fields/upload-run-popover'
|
||||
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
|
||||
import { usePublishedInputFields } from './input-fields/use-published-input-fields'
|
||||
|
||||
type InputFieldsTabProps = EvaluationResourceProps & {
|
||||
isPanelReady: boolean
|
||||
isRunnable: boolean
|
||||
}
|
||||
|
||||
type InputField = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const getGraphNodes = (graph?: Record<string, unknown>) => {
|
||||
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
|
||||
}
|
||||
|
||||
const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
|
||||
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
|
||||
const variables = startNode?.data.variables
|
||||
|
||||
if (!Array.isArray(variables))
|
||||
return []
|
||||
|
||||
return variables
|
||||
.filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable)
|
||||
.map(variable => ({
|
||||
name: variable.variable,
|
||||
type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
|
||||
}))
|
||||
}
|
||||
|
||||
const escapeCsvCell = (value: string) => {
|
||||
if (!/[",\n\r]/.test(value))
|
||||
return value
|
||||
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
const InputFieldsTab = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
@ -61,181 +20,50 @@ const InputFieldsTab = ({
|
||||
}: InputFieldsTabProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
|
||||
const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
|
||||
const inputFields = useMemo(() => {
|
||||
if (resourceType === 'apps')
|
||||
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
|
||||
|
||||
if (resourceType === 'snippets')
|
||||
return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph))
|
||||
|
||||
return []
|
||||
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType])
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const uploadedFileId = resource.uploadedFileId
|
||||
const uploadedFileName = resource.uploadedFileName
|
||||
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
|
||||
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
|
||||
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
|
||||
const startRunMutation = useStartEvaluationRunMutation()
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
})
|
||||
},
|
||||
onSuccess: (uploadedFile) => {
|
||||
setUploadedFile(resourceType, resourceId, {
|
||||
id: uploadedFile.id,
|
||||
name: typeof uploadedFile.name === 'string' ? uploadedFile.name : uploadedFileName ?? uploadedFile.id,
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
setUploadedFile(resourceType, resourceId, null)
|
||||
toast.error(t('batch.uploadError'))
|
||||
},
|
||||
const { inputFields, isInputFieldsLoading } = usePublishedInputFields(resourceType, resourceId)
|
||||
const actions = useInputFieldsActions({
|
||||
resourceType,
|
||||
resourceId,
|
||||
inputFields,
|
||||
isInputFieldsLoading,
|
||||
isPanelReady,
|
||||
isRunnable,
|
||||
templateFileName: config.templateFileName,
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const isFileUploading = uploadMutation.isPending
|
||||
const isRunning = startRunMutation.isPending
|
||||
const isInputFieldsLoading = (resourceType === 'apps' && isAppWorkflowLoading)
|
||||
|| (resourceType === 'snippets' && isSnippetWorkflowLoading)
|
||||
const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0
|
||||
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
if (!inputFields.length) {
|
||||
toast.warning(t('batch.noInputFields'))
|
||||
return
|
||||
}
|
||||
|
||||
const content = `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n`
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
|
||||
link.download = config.templateFileName
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRun = () => {
|
||||
if (!isRunnable) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileUploading) {
|
||||
toast.warning(t('batch.uploading'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!uploadedFileId) {
|
||||
toast.warning(t('batch.fileRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const body = buildEvaluationRunRequest(resource, uploadedFileId)
|
||||
|
||||
if (!body) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
startRunMutation.mutate({
|
||||
params: {
|
||||
targetType: resourceType,
|
||||
targetId: resourceId,
|
||||
},
|
||||
body,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('batch.runStarted'))
|
||||
setBatchTab(resourceType, resourceId, 'history')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('batch.runFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
setUploadedFile(resourceType, resourceId, null)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadedFileName(resourceType, resourceId, file.name)
|
||||
uploadMutation.mutate(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="system-md-semibold text-text-primary">{t('batch.requirementsTitle')}</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">{t('batch.requirementsDescription')}</div>
|
||||
<div className="mt-3 rounded-xl bg-background-section p-3">
|
||||
{isInputFieldsLoading && (
|
||||
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
|
||||
{t('batch.loadingInputFields')}
|
||||
</div>
|
||||
)}
|
||||
{!isInputFieldsLoading && inputFields.length === 0 && (
|
||||
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
|
||||
{t('batch.noInputFields')}
|
||||
</div>
|
||||
)}
|
||||
{!isInputFieldsLoading && inputFields.map(field => (
|
||||
<div key={field.name} className="flex items-center py-1">
|
||||
<div className="rounded px-1 py-0.5 system-xs-medium text-text-tertiary">
|
||||
{field.name}
|
||||
</div>
|
||||
<div className="text-[10px] leading-3 text-text-quaternary">
|
||||
{field.type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<InputFieldsRequirements
|
||||
inputFields={inputFields}
|
||||
isLoading={isInputFieldsLoading}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<Button variant="secondary" className="w-full justify-center" disabled={!canDownloadTemplate} onClick={handleDownloadTemplate}>
|
||||
<Button variant="secondary" className="w-full justify-center" disabled={!actions.canDownloadTemplate} onClick={actions.handleDownloadTemplate}>
|
||||
<span aria-hidden="true" className="mr-1 i-ri-download-line h-4 w-4" />
|
||||
{t('batch.downloadTemplate')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={handleFileChange}
|
||||
<UploadRunPopover
|
||||
open={actions.isUploadPopoverOpen}
|
||||
onOpenChange={actions.setIsUploadPopoverOpen}
|
||||
triggerDisabled={actions.uploadButtonDisabled}
|
||||
inputFields={inputFields}
|
||||
currentFileName={actions.currentFileName}
|
||||
currentFileExtension={actions.currentFileExtension}
|
||||
currentFileSize={actions.currentFileSize}
|
||||
isFileUploading={actions.isFileUploading}
|
||||
isRunDisabled={actions.isRunDisabled}
|
||||
isRunning={actions.isRunning}
|
||||
onUploadFile={actions.handleUploadFile}
|
||||
onClearUploadedFile={actions.handleClearUploadedFile}
|
||||
onDownloadTemplate={actions.handleDownloadTemplate}
|
||||
onRun={actions.handleRun}
|
||||
/>
|
||||
{isPanelReady && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center hover:border-components-button-secondary-border"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-file-upload-line h-5 w-5 text-text-tertiary" />
|
||||
<div className="mt-2 system-sm-semibold text-text-primary">{t('batch.uploadTitle')}</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{isFileUploading ? t('batch.uploading') : uploadedFileName ?? t('batch.uploadHint')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isRunnable && (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
|
||||
{t('batch.validation')}
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full justify-center" variant="primary" disabled={isRunDisabled} loading={isRunning} onClick={handleRun}>
|
||||
{t('batch.run')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import type { InputField } from './input-fields-utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type InputFieldsRequirementsProps = {
|
||||
inputFields: InputField[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const InputFieldsRequirements = ({
|
||||
inputFields,
|
||||
isLoading,
|
||||
}: InputFieldsRequirementsProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="system-md-semibold text-text-primary">{t('batch.requirementsTitle')}</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">{t('batch.requirementsDescription')}</div>
|
||||
<div className="mt-3 rounded-xl bg-background-section p-3">
|
||||
{isLoading && (
|
||||
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
|
||||
{t('batch.loadingInputFields')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && inputFields.length === 0 && (
|
||||
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
|
||||
{t('batch.noInputFields')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && inputFields.map(field => (
|
||||
<div key={field.name} className="flex items-center py-1">
|
||||
<div className="rounded px-1 py-0.5 system-xs-medium text-text-tertiary">
|
||||
{field.name}
|
||||
</div>
|
||||
<div className="text-[10px] leading-3 text-text-quaternary">
|
||||
{field.type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputFieldsRequirements
|
||||
@ -0,0 +1,54 @@
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { InputVar, Node } from '@/app/components/workflow/types'
|
||||
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
export type InputField = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export const getGraphNodes = (graph?: Record<string, unknown>) => {
|
||||
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
|
||||
}
|
||||
|
||||
export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
|
||||
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
|
||||
const variables = startNode?.data.variables
|
||||
|
||||
if (!Array.isArray(variables))
|
||||
return []
|
||||
|
||||
return variables
|
||||
.filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable)
|
||||
.map(variable => ({
|
||||
name: variable.variable,
|
||||
type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
|
||||
}))
|
||||
}
|
||||
|
||||
const escapeCsvCell = (value: string) => {
|
||||
if (!/[",\n\r]/.test(value))
|
||||
return value
|
||||
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
export const buildTemplateCsvContent = (inputFields: InputField[]) => {
|
||||
return `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n`
|
||||
}
|
||||
|
||||
export const getFileExtension = (fileName: string) => {
|
||||
const extension = fileName.split('.').pop()
|
||||
return extension && extension !== fileName ? extension.toUpperCase() : ''
|
||||
}
|
||||
|
||||
export const getExampleValue = (field: InputField, booleanLabel: string) => {
|
||||
if (field.type === 'number')
|
||||
return '0.7'
|
||||
|
||||
if (field.type === 'boolean')
|
||||
return booleanLabel
|
||||
|
||||
return field.name
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
import type { ChangeEvent, DragEvent } from 'react'
|
||||
import type { InputField } from './input-fields-utils'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getExampleValue } from './input-fields-utils'
|
||||
|
||||
type UploadRunPopoverProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerDisabled: boolean
|
||||
inputFields: InputField[]
|
||||
currentFileName: string | null | undefined
|
||||
currentFileExtension: string
|
||||
currentFileSize: string | number
|
||||
isFileUploading: boolean
|
||||
isRunDisabled: boolean
|
||||
isRunning: boolean
|
||||
onUploadFile: (file: File | undefined) => void
|
||||
onClearUploadedFile: () => void
|
||||
onDownloadTemplate: () => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
const UploadRunPopover = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerDisabled,
|
||||
inputFields,
|
||||
currentFileName,
|
||||
currentFileExtension,
|
||||
currentFileSize,
|
||||
isFileUploading,
|
||||
isRunDisabled,
|
||||
isRunning,
|
||||
onUploadFile,
|
||||
onClearUploadedFile,
|
||||
onDownloadTemplate,
|
||||
onRun,
|
||||
}: UploadRunPopoverProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const previewFields = inputFields.slice(0, 3)
|
||||
const booleanExampleValue = t('conditions.boolean.true')
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onUploadFile(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleDropFile = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
onUploadFile(event.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button className="w-full justify-center" variant="primary" disabled={triggerDisabled}>
|
||||
{t('batch.uploadAndRun')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={8}
|
||||
popupClassName="w-[402px] overflow-hidden rounded-lg border border-components-panel-border p-0 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
|
||||
>
|
||||
<div className="flex flex-col bg-components-panel-bg">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{currentFileName
|
||||
? (
|
||||
<div className="flex h-20 items-center gap-3 rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3">
|
||||
<div className="flex p-3">
|
||||
<span aria-hidden="true" className="i-ri-file-excel-fill h-6 w-6 text-util-colors-green-green-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-1 pr-2">
|
||||
<div className="truncate system-xs-medium text-text-secondary">
|
||||
{currentFileName}
|
||||
</div>
|
||||
<div className="mt-0.5 flex h-3 items-center gap-1 system-2xs-medium text-text-tertiary">
|
||||
{!!currentFileExtension && <span className="uppercase">{currentFileExtension}</span>}
|
||||
{!!currentFileExtension && !!currentFileSize && <span className="text-text-quaternary">·</span>}
|
||||
{!!currentFileSize && <span>{currentFileSize}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-3">
|
||||
{isFileUploading && (
|
||||
<span aria-hidden="true" className="i-ri-loader-4-line h-4 w-4 animate-spin text-text-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={onClearUploadedFile}
|
||||
aria-label={t('batch.removeUploadedFile')}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="flex h-20 w-full items-center justify-center gap-3 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg p-3 text-left hover:border-components-button-secondary-border"
|
||||
onDragOver={event => event.preventDefault()}
|
||||
onDrop={handleDropFile}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 p-3"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-file-upload-line h-6 w-6 text-text-tertiary" />
|
||||
<span className="sr-only">{t('batch.uploadTitle')}</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
{t('batch.uploadDropzonePrefix')}
|
||||
{' '}
|
||||
<span className="system-md-semibold">{t('batch.uploadDropzoneEmphasis')}</span>
|
||||
{' '}
|
||||
{t('batch.uploadDropzoneSuffix')}
|
||||
</div>
|
||||
<div className="mt-0.5 system-xs-regular text-text-tertiary">
|
||||
{t('batch.uploadDropzoneDownloadPrefix')}
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-accent hover:underline"
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
{t('batch.uploadDropzoneDownloadLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!previewFields.length && (
|
||||
<div className="space-y-1">
|
||||
<div className="system-md-semibold text-text-secondary">{t('batch.example')}</div>
|
||||
<div className="flex overflow-hidden rounded-lg border border-divider-regular">
|
||||
{previewFields.map((field, index) => (
|
||||
<div key={field.name} className={cn('min-w-0 flex-1', index < previewFields.length - 1 && 'border-r border-divider-subtle')}>
|
||||
<div className="min-h-8 border-b border-divider-regular px-3 py-2 system-xs-medium-uppercase text-text-tertiary">
|
||||
{field.name}
|
||||
</div>
|
||||
<div className="min-h-8 px-3 py-2 system-sm-regular text-text-secondary">
|
||||
{getExampleValue(field, booleanExampleValue)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end justify-end gap-2 border-t border-components-panel-border px-4 py-4">
|
||||
<Button variant="secondary" className="rounded-lg" onClick={() => onOpenChange(false)}>
|
||||
{tCommon('operation.cancel')}
|
||||
</Button>
|
||||
<Button className="flex-1 justify-center rounded-lg" variant="primary" disabled={isRunDisabled} loading={isRunning} onClick={onRun}>
|
||||
<span aria-hidden="true" className="mr-1 i-ri-play-fill h-5 w-5" />
|
||||
{t('batch.run')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadRunPopover
|
||||
@ -0,0 +1,165 @@
|
||||
import type { EvaluationResourceProps } from '../../../types'
|
||||
import type { InputField } from './input-fields-utils'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { upload } from '@/service/base'
|
||||
import { useStartEvaluationRunMutation } from '@/service/use-evaluation'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../../../store'
|
||||
import { buildEvaluationRunRequest } from '../../../store-utils'
|
||||
import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils'
|
||||
|
||||
type UploadedFileMeta = {
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type UseInputFieldsActionsParams = EvaluationResourceProps & {
|
||||
inputFields: InputField[]
|
||||
isInputFieldsLoading: boolean
|
||||
isPanelReady: boolean
|
||||
isRunnable: boolean
|
||||
templateFileName: string
|
||||
}
|
||||
|
||||
export const useInputFieldsActions = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
inputFields,
|
||||
isInputFieldsLoading,
|
||||
isPanelReady,
|
||||
isRunnable,
|
||||
templateFileName,
|
||||
}: UseInputFieldsActionsParams) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
|
||||
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
|
||||
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
|
||||
const startRunMutation = useStartEvaluationRunMutation()
|
||||
const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false)
|
||||
const [uploadedFileMeta, setUploadedFileMeta] = useState<UploadedFileMeta | null>(null)
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
})
|
||||
},
|
||||
onSuccess: (uploadedFile, file) => {
|
||||
setUploadedFile(resourceType, resourceId, {
|
||||
id: uploadedFile.id,
|
||||
name: typeof uploadedFile.name === 'string' ? uploadedFile.name : file.name,
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
setUploadedFileMeta(null)
|
||||
setUploadedFile(resourceType, resourceId, null)
|
||||
toast.error(t('batch.uploadError'))
|
||||
},
|
||||
})
|
||||
|
||||
const isFileUploading = uploadMutation.isPending
|
||||
const isRunning = startRunMutation.isPending
|
||||
const uploadedFileId = resource.uploadedFileId
|
||||
const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName
|
||||
const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0
|
||||
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
|
||||
const uploadButtonDisabled = !isPanelReady || isInputFieldsLoading || isRunning
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
if (!inputFields.length) {
|
||||
toast.warning(t('batch.noInputFields'))
|
||||
return
|
||||
}
|
||||
|
||||
const content = buildTemplateCsvContent(inputFields)
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
|
||||
link.download = templateFileName
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRun = () => {
|
||||
if (!isRunnable) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileUploading) {
|
||||
toast.warning(t('batch.uploading'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!uploadedFileId) {
|
||||
toast.warning(t('batch.fileRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const body = buildEvaluationRunRequest(resource, uploadedFileId)
|
||||
|
||||
if (!body) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
startRunMutation.mutate({
|
||||
params: {
|
||||
targetType: resourceType,
|
||||
targetId: resourceId,
|
||||
},
|
||||
body,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('batch.runStarted'))
|
||||
setIsUploadPopoverOpen(false)
|
||||
setBatchTab(resourceType, resourceId, 'history')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('batch.runFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploadFile = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
setUploadedFileMeta(null)
|
||||
setUploadedFile(resourceType, resourceId, null)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadedFileMeta({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
})
|
||||
setUploadedFileName(resourceType, resourceId, file.name)
|
||||
uploadMutation.mutate(file)
|
||||
}
|
||||
|
||||
const handleClearUploadedFile = () => {
|
||||
setUploadedFileMeta(null)
|
||||
setUploadedFile(resourceType, resourceId, null)
|
||||
}
|
||||
|
||||
return {
|
||||
canDownloadTemplate,
|
||||
currentFileExtension: currentFileName ? getFileExtension(currentFileName) : '',
|
||||
currentFileName,
|
||||
currentFileSize: uploadedFileMeta ? formatFileSize(uploadedFileMeta.size) : '',
|
||||
handleClearUploadedFile,
|
||||
handleDownloadTemplate,
|
||||
handleRun,
|
||||
handleUploadFile,
|
||||
isFileUploading,
|
||||
isRunning,
|
||||
isRunDisabled,
|
||||
isUploadPopoverOpen,
|
||||
setIsUploadPopoverOpen,
|
||||
uploadButtonDisabled,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import type { EvaluationResourceType } from '../../../types'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { getGraphNodes, getStartNodeInputFields } from './input-fields-utils'
|
||||
|
||||
export const usePublishedInputFields = (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
|
||||
const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
|
||||
|
||||
const inputFields = useMemo(() => {
|
||||
if (resourceType === 'apps')
|
||||
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
|
||||
|
||||
if (resourceType === 'snippets')
|
||||
return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph))
|
||||
|
||||
return []
|
||||
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType])
|
||||
|
||||
return {
|
||||
inputFields,
|
||||
isInputFieldsLoading: (resourceType === 'apps' && isAppWorkflowLoading)
|
||||
|| (resourceType === 'snippets' && isSnippetWorkflowLoading),
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,13 @@
|
||||
"batch.description": "Execute batch evaluations and track performance history.",
|
||||
"batch.downloadTemplate": "Download Excel Template",
|
||||
"batch.emptyHistory": "No test history yet.",
|
||||
"batch.example": "Example:",
|
||||
"batch.fileRequired": "Upload an evaluation dataset file before running the test.",
|
||||
"batch.loadingInputFields": "Loading input fields...",
|
||||
"batch.noInputFields": "No published start node input fields found.",
|
||||
"batch.noticeDescription": "Configuration incomplete. Select the Judge Model and Metrics on the left to generate your batch test template.",
|
||||
"batch.noticeTitle": "Quick start",
|
||||
"batch.removeUploadedFile": "Remove uploaded file",
|
||||
"batch.requirementsDescription": "The input variables required to run this batch test. Ensure your uploaded dataset matches these fields.",
|
||||
"batch.requirementsTitle": "Data requirements",
|
||||
"batch.run": "Run Test",
|
||||
@ -18,6 +20,12 @@
|
||||
"batch.tabs.history": "Test History",
|
||||
"batch.tabs.input-fields": "Input Fields",
|
||||
"batch.title": "Batch Test",
|
||||
"batch.uploadAndRun": "Upload & Run Test",
|
||||
"batch.uploadDropzoneDownloadLink": "here",
|
||||
"batch.uploadDropzoneDownloadPrefix": "Don't have the template? Download",
|
||||
"batch.uploadDropzoneEmphasis": "filled",
|
||||
"batch.uploadDropzonePrefix": "Drag and drop your",
|
||||
"batch.uploadDropzoneSuffix": "Excel Template",
|
||||
"batch.uploadError": "Failed to upload file.",
|
||||
"batch.uploadHint": "Select a .csv or .xlsx file",
|
||||
"batch.uploadTitle": "Upload test file",
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
"batch.description": "执行批量评测并追踪性能历史。",
|
||||
"batch.downloadTemplate": "下载 Excel 模板",
|
||||
"batch.emptyHistory": "还没有测试历史。",
|
||||
"batch.example": "示例:",
|
||||
"batch.fileRequired": "请先上传评估数据集文件,再运行测试。",
|
||||
"batch.loadingInputFields": "正在加载输入字段...",
|
||||
"batch.noInputFields": "未找到已发布 Start 节点的输入字段。",
|
||||
"batch.noticeDescription": "配置尚未完成。请先在左侧选择判定模型和指标,以生成批量测试模板。",
|
||||
"batch.noticeTitle": "快速开始",
|
||||
"batch.removeUploadedFile": "移除已上传文件",
|
||||
"batch.requirementsDescription": "运行此批量测试所需的输入变量。请确保上传的数据集包含这些字段。",
|
||||
"batch.requirementsTitle": "数据要求",
|
||||
"batch.run": "运行测试",
|
||||
@ -18,6 +20,12 @@
|
||||
"batch.tabs.history": "测试历史",
|
||||
"batch.tabs.input-fields": "输入字段",
|
||||
"batch.title": "批量测试",
|
||||
"batch.uploadAndRun": "上传并运行测试",
|
||||
"batch.uploadDropzoneDownloadLink": "下载",
|
||||
"batch.uploadDropzoneDownloadPrefix": "还没有模板?",
|
||||
"batch.uploadDropzoneEmphasis": "已填写的",
|
||||
"batch.uploadDropzonePrefix": "拖拽你的",
|
||||
"batch.uploadDropzoneSuffix": "Excel 模板",
|
||||
"batch.uploadError": "文件上传失败。",
|
||||
"batch.uploadHint": "选择 .csv 或 .xlsx 文件",
|
||||
"batch.uploadTitle": "上传测试文件",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user