feat: enhance input field dialog with preview functionality and global inputs

This commit is contained in:
twwu 2025-06-04 15:16:02 +08:00
parent cab491795a
commit 3afd5e73c9
14 changed files with 295 additions and 103 deletions

View File

@ -36,7 +36,7 @@ const DialogWrapper = ({
<TransitionChild>
<DialogPanel className={cn(
'relative flex w-[400px] flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl shadow-shadow-shadow-9 transition-all',
'data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:scale-100 data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
'data-[leave]:scale-95 data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
className,

View File

@ -28,7 +28,7 @@ const FieldListContainer = ({
return inputFields.map((content) => {
return ({
id: content.variable,
name: content.variable,
...content,
})
})
}, [inputFields])
@ -40,6 +40,7 @@ const FieldListContainer = ({
setList={onListSortChange}
handle='.handle'
ghostClass='opacity-50'
group='rag-pipeline-input-field'
animation={150}
disabled={readonly}
>

View File

@ -36,9 +36,10 @@ export const useFieldList = (
const handleListSortChange = useCallback((list: SortableItem[]) => {
const newInputFields = list.map((item) => {
return inputFieldsRef.current.find(field => field.variable === item.name)
const { id, ...filed } = item
return filed
})
handleInputFieldsChange(newInputFields as InputVar[])
handleInputFieldsChange(newInputFields)
}, [handleInputFieldsChange])
const [editingField, setEditingField] = useState<InputVar | undefined>()
@ -62,12 +63,12 @@ export const useFieldList = (
setRemoveIndex(index as number)
return
}
const newInputFields = inputFieldsRef.current.splice(index, 1)
const newInputFields = inputFieldsRef.current.filter((_, i) => i !== index)
handleInputFieldsChange(newInputFields)
}, [handleInputFieldsChange, isVarUsedInNodes, nodeId, showRemoveVarConfirm])
const onRemoveVarConfirm = useCallback(() => {
const newInputFields = inputFieldsRef.current.splice(removedIndex, 1)
const newInputFields = inputFieldsRef.current.filter((_, i) => i !== removedIndex)
handleInputFieldsChange(newInputFields)
removeUsedVarInNodes(removedVar)
hideRemoveVarConfirm()

View File

@ -56,16 +56,14 @@ const FieldList = ({
<RiAddLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</div>
{inputFields.length > 0 && (
<FieldListContainer
className='flex flex-col gap-y-1 px-4 pb-2'
inputFields={inputFields}
onEditField={handleOpenInputFieldEditor}
onRemoveField={handleRemoveField}
onListSortChange={handleListSortChange}
readonly={readonly}
/>
)}
<FieldListContainer
className='flex flex-col gap-y-1 px-4 pb-1'
inputFields={inputFields}
onEditField={handleOpenInputFieldEditor}
onRemoveField={handleRemoveField}
onListSortChange={handleListSortChange}
readonly={readonly}
/>
{showInputFieldEditor && (
<InputFieldEditor
show={showInputFieldEditor}

View File

@ -1,4 +1,5 @@
import type { InputVar } from '@/models/pipeline'
export type SortableItem = {
id: string
name: string
}
} & InputVar

View File

@ -2,22 +2,30 @@ import {
memo,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import { useStore } from '@/app/components/workflow/store'
import { RiCloseLine } from '@remixicon/react'
import { RiCloseLine, RiEyeLine } from '@remixicon/react'
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import DialogWrapper from './dialog-wrapper'
import FieldList from './field-list'
import FooterTip from './footer-tip'
import SharedInputs from './label-right-content/shared-inputs'
import GlobalInputs from './label-right-content/global-inputs'
import Datasource from './label-right-content/datasource'
import { useNodes } from 'reactflow'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
// import produce from 'immer'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import type { InputVar, RAGPipelineVariables } from '@/models/pipeline'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import PreviewPanel from './preview'
import { useDebounceFn, useUnmount } from 'ahooks'
type InputFieldDialogProps = {
readonly?: boolean
@ -32,8 +40,33 @@ const InputFieldDialog = ({
const setShowInputFieldDialog = useStore(state => state.setShowInputFieldDialog)
const ragPipelineVariables = useStore(state => state.ragPipelineVariables)
const setRagPipelineVariables = useStore(state => state.setRagPipelineVariables)
const [previewPanelOpen, setPreviewPanelOpen] = useState(false)
const getInputFieldsMap = () => {
const inputFieldsMap: Record<string, InputVar[]> = {}
ragPipelineVariables?.forEach((variable) => {
const { belong_to_node_id: nodeId, ...varConfig } = variable
if (inputFieldsMap[nodeId])
inputFieldsMap[nodeId].push(varConfig)
else
inputFieldsMap[nodeId] = [varConfig]
})
return inputFieldsMap
}
const inputFieldsMap = useRef(getInputFieldsMap())
const { doSyncWorkflowDraft } = useNodesSyncDraft()
useUnmount(async () => {
await doSyncWorkflowDraft()
})
const { run: syncWorkflowDraft } = useDebounceFn(() => {
doSyncWorkflowDraft()
}, {
wait: 500,
})
const datasourceNodeDataMap = useMemo(() => {
const datasourceNodeDataMap: Record<string, DataSourceNodeType> = {}
const datasourceNodes: Node<DataSourceNodeType>[] = nodes.filter(node => node.data.type === BlockEnum.DataSource)
@ -44,25 +77,11 @@ const InputFieldDialog = ({
return datasourceNodeDataMap
}, [nodes])
const inputFieldsMap = useMemo(() => {
const inputFieldsMap: Record<string, InputVar[]> = {}
ragPipelineVariables?.forEach((variable) => {
const { belong_to_node_id: nodeId, ...varConfig } = variable
if (inputFieldsMap[nodeId])
inputFieldsMap[nodeId].push(varConfig)
else
inputFieldsMap[nodeId] = [varConfig]
})
return inputFieldsMap
}, [ragPipelineVariables])
const updateInputFields = useCallback(async (key: string, value: InputVar[]) => {
const NewInputFieldsMap = produce(inputFieldsMap, (draft) => {
draft[key] = value
})
const updateInputFields = useCallback((key: string, value: InputVar[]) => {
inputFieldsMap.current[key] = value
const newRagPipelineVariables: RAGPipelineVariables = []
Object.keys(NewInputFieldsMap).forEach((key) => {
const inputFields = NewInputFieldsMap[key]
Object.keys(inputFieldsMap.current).forEach((key) => {
const inputFields = inputFieldsMap.current[key]
inputFields.forEach((inputField) => {
newRagPipelineVariables.push({
...inputField,
@ -71,65 +90,101 @@ const InputFieldDialog = ({
})
})
setRagPipelineVariables?.(newRagPipelineVariables)
await doSyncWorkflowDraft()
}, [doSyncWorkflowDraft, inputFieldsMap, setRagPipelineVariables])
syncWorkflowDraft()
}, [setRagPipelineVariables, syncWorkflowDraft])
const closePanel = useCallback(() => {
setShowInputFieldDialog?.(false)
}, [setShowInputFieldDialog])
const togglePreviewPanel = useCallback(() => {
setPreviewPanelOpen(prev => !prev)
}, [])
return (
<DialogWrapper
show={!!showInputFieldDialog}
onClose={closePanel}
>
<div className='flex grow flex-col'>
<div className='flex items-center p-4 pb-0'>
<div className='system-xl-semibold grow'>
{t('datasetPipeline.inputFieldPanel.title')}
<>
<DialogWrapper
show={!!showInputFieldDialog}
onClose={closePanel}
>
<div className='flex grow flex-col'>
<div className='flex items-center p-4 pb-0'>
<div className='system-xl-semibold grow'>
{t('datasetPipeline.inputFieldPanel.title')}
</div>
<Button
variant={'ghost'}
size='small'
className={cn(
'shrink-0 gap-x-px px-1.5',
previewPanelOpen && 'bg-state-accent-active text-text-accent',
)}
onClick={togglePreviewPanel}
>
<RiEyeLine className='size-3.5' />
<span className='px-[3px]'>{t('datasetPipeline.operations.preview')}</span>
</Button>
<Divider type='vertical' className='mx-1 h-3' />
<button
type='button'
className='flex size-6 shrink-0 items-center justify-center p-0.5'
onClick={closePanel}
>
<RiCloseLine className='size-4 text-text-tertiary' />
</button>
</div>
<button
type='button'
className='flex size-6 shrink-0 items-center justify-center p-0.5'
onClick={closePanel}
>
<RiCloseLine className='size-4 text-text-tertiary' />
</button>
<div className='system-sm-regular px-4 pb-2 pt-1 text-text-tertiary'>
{t('datasetPipeline.inputFieldPanel.description')}
</div>
<div className='flex grow flex-col overflow-y-auto'>
{/* Unique Inputs for Each Entrance */}
<div className='flex h-6 items-center gap-x-0.5 px-4 pt-2'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('datasetPipeline.inputFieldPanel.uniqueInputs.title')}
</span>
<Tooltip
popupContent={t('datasetPipeline.inputFieldPanel.uniqueInputs.tooltip')}
popupClassName='max-w-[240px]'
/>
</div>
<div className='flex flex-col gap-y-1 py-1'>
{
Object.keys(datasourceNodeDataMap).map((key) => {
const inputFields = inputFieldsMap.current[key] || []
return (
<FieldList
key={key}
nodeId={key}
LabelRightContent={<Datasource nodeData={datasourceNodeDataMap[key]} />}
inputFields={inputFields}
readonly={readonly}
labelClassName='pt-1 pb-1'
handleInputFieldsChange={updateInputFields}
/>
)
})
}
</div>
{/* Global Inputs */}
<FieldList
nodeId='shared'
LabelRightContent={<GlobalInputs />}
inputFields={inputFieldsMap.current.shared || []}
readonly={readonly}
labelClassName='pt-2 pb-1'
handleInputFieldsChange={updateInputFields}
/>
</div>
<FooterTip />
</div>
<div className='system-sm-regular px-4 py-1 text-text-tertiary'>
{t('datasetPipeline.inputFieldPanel.description')}
</div>
<div className='flex grow flex-col overflow-y-auto'>
{/* Datasources Inputs */}
{
Object.keys(datasourceNodeDataMap).map((key) => {
const inputFields = inputFieldsMap[key] || []
return (
<FieldList
key={key}
nodeId={key}
LabelRightContent={<Datasource nodeData={datasourceNodeDataMap[key]} />}
inputFields={inputFields}
readonly={readonly}
labelClassName='pt-2 pb-1'
handleInputFieldsChange={updateInputFields}
/>
)
})
}
{/* Shared Inputs */}
<FieldList
nodeId='shared'
LabelRightContent={<SharedInputs />}
inputFields={inputFieldsMap.shared || []}
readonly={readonly}
labelClassName='pt-1 pb-2'
handleInputFieldsChange={updateInputFields}
/>
</div>
<FooterTip />
</div>
</DialogWrapper>
</DialogWrapper>
{previewPanelOpen && (
<PreviewPanel
show={previewPanelOpen}
onClose={togglePreviewPanel}
/>
)}
</>
)
}

View File

@ -2,20 +2,20 @@ import Tooltip from '@/app/components/base/tooltip'
import React from 'react'
import { useTranslation } from 'react-i18next'
const SharedInputs = () => {
const GlobalInputs = () => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('datasetPipeline.inputFieldPanel.sharedInputs.title')}
{t('datasetPipeline.inputFieldPanel.globalInputs.title')}
</span>
<Tooltip
popupContent={t('datasetPipeline.inputFieldPanel.sharedInputs.tooltip')}
popupClassName='!w-[300px]'
popupContent={t('datasetPipeline.inputFieldPanel.globalInputs.tooltip')}
popupClassName='w-[240px]'
/>
</div>
)
}
export default React.memo(SharedInputs)
export default React.memo(GlobalInputs)

View File

@ -0,0 +1,16 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
const DataSource = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col'>
<div className='system-sm-semibold-uppercase px-4 pt-2 text-text-secondary'>
{t('datasetPipeline.inputFieldPanel.preview.stepOneTitle')}
</div>
</div>
)
}
export default React.memo(DataSource)

View File

@ -0,0 +1,54 @@
import { Fragment, useCallback } from 'react'
import type { ReactNode } from 'react'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import cn from '@/utils/classnames'
type DialogWrapperProps = {
className?: string
panelWrapperClassName?: string
children: ReactNode
show: boolean
onClose?: () => void
}
const DialogWrapper = ({
className,
panelWrapperClassName,
children,
show,
onClose,
}: DialogWrapperProps) => {
const close = useCallback(() => onClose?.(), [onClose])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as='div' className='relative z-40' onClose={close}>
<TransitionChild>
<div className={cn(
'fixed inset-0 bg-black/25',
'data-[closed]:opacity-0',
'data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
'data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
)} />
</TransitionChild>
<div className='fixed inset-0'>
<div className={cn('flex min-h-full flex-col items-end justify-start pb-1 pt-[116px]', panelWrapperClassName)}>
<TransitionChild>
<DialogPanel className={cn(
'relative flex w-[480px] grow flex-col overflow-hidden rounded-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl shadow-shadow-shadow-5 transition-all',
'data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:scale-100 data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
'data-[leave]:scale-95 data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
className,
)}>
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition >
)
}
export default DialogWrapper

View File

@ -0,0 +1,41 @@
import { RiCloseLine } from '@remixicon/react'
import DialogWrapper from './dialog-wrapper'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
type PreviewPanelProps = {
show: boolean
onClose: () => void
}
const PreviewPanel = ({
show,
onClose,
}: PreviewPanelProps) => {
const { t } = useTranslation()
return (
<DialogWrapper
show={show}
onClose={onClose}
panelWrapperClassName='pr-[424px]'
>
<div className='flex items-center gap-x-2 px-4 pt-1'>
<div className='grow py-1'>
<Badge className='border-text-accent-secondary bg-components-badge-bg-dimm text-text-accent-secondary'>
{t('datasetPipeline.operations.preview')}
</Badge>
</div>
<button
type='button'
className='flex size-6 shrink-0 items-center justify-center'
onClick={onClose}
>
<RiCloseLine className='size-4 text-text-tertiary' />
</button>
</div>
</DialogWrapper>
)
}
export default PreviewPanel

View File

@ -27,6 +27,8 @@ import type { PublishWorkflowParams } from '@/types/workflow'
import { useToastContext } from '@/app/components/base/toast'
import { useParams, useRouter } from 'next/navigation'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useInvalid } from '@/service/use-base'
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
const PUBLISH_SHORTCUT = ['⌘', '⇧', 'P']
@ -45,6 +47,8 @@ const Popup = () => {
const { notify } = useToastContext()
const workflowStore = useWorkflowStore()
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
if (await handleCheckBeforePublish()) {
const res = await publishWorkflow({
@ -58,12 +62,13 @@ const Popup = () => {
notify({ type: 'success', message: t('common.api.actionSuccess') })
workflowStore.getState().setPublishedAt(res.created_at)
mutateDatasetRes?.()
invalidPublishedPipelineInfo()
}
}
else {
throw new Error('Checklist failed')
}
}, [handleCheckBeforePublish, publishWorkflow, pipelineId, notify, t, workflowStore, mutateDatasetRes])
}, [handleCheckBeforePublish, publishWorkflow, pipelineId, notify, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()

View File

@ -27,6 +27,7 @@ const translation = {
process: 'Process',
dataSource: 'Data Source',
saveAndProcess: 'Save & Process',
preview: 'Preview',
},
knowledgeNameAndIcon: 'Knowledge name & icon',
knowledgeNameAndIconPlaceholder: 'Please enter the name of the Knowledge Base',
@ -66,12 +67,20 @@ const translation = {
inputFieldPanel: {
title: 'User Input Fields',
description: 'User input fields are used to define and collect variables required during the pipeline execution process. Users can customize the field type and flexibly configure the input value to meet the needs of different data sources or document processing steps.',
sharedInputs: {
title: 'Shared Inputs',
tooltip: 'Shared Inputs are available to all downstream nodes across data sources. For example, variables like delimiter and maximum chunk length can be uniformly applied when processing documents from multiple sources.',
uniqueInputs: {
title: 'Unique Inputs for Each Entrance',
tooltip: 'Unique Inputs are only accessible to the selected data source and its downstream nodes. Users won\'t need to fill it in when choosing other data sources. Only input fields referenced by data source variables will appear in the first step(Data Source). All other fields will be shown in the second step(Process Documents).',
},
globalInputs: {
title: 'Global Inputs for All Entrances',
tooltip: 'Global Inputs are shared across all nodes. Users will need to fill them in when selecting any data source. For example, fields like delimiter and maximum chunk length can be uniformly applied across multiple data sources. Only input fields referenced by Data Source variables appear in the first step (Data Source). All other fields show up in the second step (Process Documents).',
},
addInputField: 'Add Input Field',
editInputField: 'Edit Input Field',
preview: {
stepOneTitle: 'Data Source',
stepTwoTitle: 'Process Documents',
},
},
addDocuments: {
title: 'Add Documents',

View File

@ -27,6 +27,7 @@ const translation = {
process: '处理',
dataSource: '数据源',
saveAndProcess: '保存并处理',
preview: '预览',
},
knowledgeNameAndIcon: '知识库名称和图标',
knowledgeNameAndIconPlaceholder: '请输入知识库名称',
@ -66,12 +67,20 @@ const translation = {
inputFieldPanel: {
title: '用户输入字段',
description: '用户输入字段用于定义和收集流水线执行过程中所需的变量,用户可以自定义字段类型,并灵活配置输入,以满足不同数据源或文档处理的需求。',
sharedInputs: {
title: '共享输入',
tooltip: '共享输入可被数据源中的所有下游节点使用。例如在处理来自多个来源的文档时delimiter分隔符和 maximum chunk length最大分块长度等变量可以统一应用。',
uniqueInputs: {
title: '非共享输入',
tooltip: '非共享输入只能被选定的数据源及其下游节点访问。用户在选择其他数据源时不需要填写它。只有数据源变量引用的输入字段才会出现在第一步数据源中。所有其他字段将在第二步Process Documents中显示。',
},
globalInputs: {
title: '全局共享输入',
tooltip: '全局共享输入在所有节点之间共享。用户在选择任何数据源时都需要填写它们。例如像分隔符delimiter和最大块长度Maximum Chunk Length这样的字段可以跨多个数据源统一应用。只有数据源变量引用的输入字段才会出现在第一步数据源中。所有其他字段都显示在第二步Process Documents中。',
},
addInputField: '添加输入字段',
editInputField: '编辑输入字段',
preview: {
stepOneTitle: '数据源',
stepTwoTitle: '处理文档',
},
},
addDocuments: {
title: '添加文档',

View File

@ -179,9 +179,11 @@ export const useDataSourceList = (enabled: boolean, onSuccess?: (v: DataSourceIt
})
}
export const publishedPipelineInfoQueryKeyPrefix = [NAME_SPACE, 'published-pipeline']
export const usePublishedPipelineInfo = (pipelineId: string) => {
return useQuery<PublishedPipelineInfoResponse>({
queryKey: [NAME_SPACE, 'published-pipeline', pipelineId],
queryKey: [...publishedPipelineInfoQueryKeyPrefix, pipelineId],
queryFn: () => {
return get<PublishedPipelineInfoResponse>(`/rag/pipelines/${pipelineId}/workflows/publish`)
},