feat: enhance dataset creation UI with new pipeline list and edit functionality

This commit is contained in:
twwu 2025-05-03 17:16:00 +08:00
parent 7ed398267f
commit 14ad34af71
15 changed files with 606 additions and 14 deletions

View File

@ -19,20 +19,21 @@ export type AppIconProps = {
imageUrl?: string | null
className?: string
innerIcon?: React.ReactNode
showEditIcon?: boolean
onClick?: () => void
}
const appIconVariants = cva(
'flex items-center justify-center relative text-lg rounded-2xl grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
'flex items-center justify-center relative grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
{
variants: {
size: {
xs: 'w-4 h-4 text-xs',
tiny: 'w-6 h-6 text-base',
small: 'w-8 h-8 text-xl',
medium: 'w-9 h-9 text-[22px]',
large: 'w-10 h-10 text-[24px]',
xl: 'w-12 h-12 text-[28px]',
xxl: 'w-14 h-14 text-[32px]',
xs: 'w-4 h-4 text-xs rounded-[4px]',
tiny: 'w-6 h-6 text-base rounded-md',
small: 'w-8 h-8 text-xl rounded-lg',
medium: 'w-9 h-9 text-[22px] rounded-[10px]',
large: 'w-10 h-10 text-[24px] rounded-[10px]',
xl: 'w-12 h-12 text-[28px] rounded-xl',
xxl: 'w-14 h-14 text-[32px] rounded-2xl',
},
rounded: {
true: 'rounded-full',
@ -43,6 +44,46 @@ const appIconVariants = cva(
rounded: false,
},
})
const EditIconWrapperVariants = cva(
'absolute left-0 top-0 z-10 flex items-center justify-center bg-background-overlay-alt',
{
variants: {
size: {
xs: 'w-4 h-4 rounded-[4px]',
tiny: 'w-6 h-6 rounded-md',
small: 'w-8 h-8 rounded-lg',
medium: 'w-9 h-9 rounded-[10px]',
large: 'w-10 h-10 rounded-[10px]',
xl: 'w-12 h-12 rounded-xl',
xxl: 'w-14 h-14 rounded-2xl',
},
rounded: {
true: 'rounded-full',
},
},
defaultVariants: {
size: 'medium',
rounded: false,
},
})
const EditIconVariants = cva(
'text-text-primary-on-surface',
{
variants: {
size: {
xs: 'size-3',
tiny: 'size-3.5',
small: 'size-5',
medium: 'size-[22px]',
large: 'size-6',
xl: 'size-7',
xxl: 'size-8',
},
},
defaultVariants: {
size: 'medium',
},
})
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
@ -53,6 +94,7 @@ const AppIcon: FC<AppIconProps> = ({
className,
innerIcon,
onClick,
showEditIcon = false,
}) => {
const isValidImageIcon = iconType === 'image' && imageUrl
const Icon = (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
@ -72,9 +114,9 @@ const AppIcon: FC<AppIconProps> = ({
: (innerIcon || Icon)
}
{
isHovering && (
<div className='absolute left-0 top-0 z-10 flex size-14 items-center justify-center rounded-2xl bg-background-overlay-alt'>
<RiEditLine className='size-6 text-text-primary-on-surface' />
showEditIcon && isHovering && (
<div className={EditIconWrapperVariants({ size, rounded })}>
<RiEditLine className={EditIconVariants({ size })} />
</div>
)
}

View File

@ -113,6 +113,7 @@ const CreateFromScratch = ({
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
</div>
<div className='flex flex-col gap-y-1'>

View File

@ -2,6 +2,7 @@
import HeaderEffect from './header-effect'
import Header from './header'
import CreateOptions from './create-options'
import List from './list'
const CreateFromPipeline = () => {
return (
@ -12,6 +13,7 @@ const CreateFromPipeline = () => {
<HeaderEffect />
<Header />
<CreateOptions />
<List />
</div>
)
}

View File

@ -0,0 +1,64 @@
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from './template-card'
export type Pipeline = {
id: string
name: string
icon_type: 'emoji' | 'image'
icon?: string
icon_background?: string
file_id?: string
url?: string
description: string
doc_form: ChunkingMode
}
const BuiltInPipelineList = () => {
const mockData: Pipeline[] = [{
id: '1',
name: 'Pipeline 1',
description: 'This is a description of Pipeline 1. When use the general chunking mode, the chunks retrieved and recalled are the same. When use the general chunking mode, the chunks retrieved and recalled are the same.',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#F0FDF9',
doc_form: ChunkingMode.text,
}, {
id: '2',
name: 'Pipeline 2',
description: 'This is a description of Pipeline 2. When use the general chunking mode, the chunks retrieved and recalled are the same.',
icon_type: 'emoji',
icon: '🏖️',
icon_background: '#FFF4ED',
doc_form: ChunkingMode.parentChild,
}, {
id: '3',
name: 'Pipeline 3',
description: 'This is a description of Pipeline 3',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FEFBE8',
doc_form: ChunkingMode.qa,
}, {
id: '4',
name: 'Pipeline 4',
description: 'This is a description of Pipeline 4',
icon_type: 'emoji',
icon: '🍯',
icon_background: '#F5F3FF',
doc_form: ChunkingMode.graph,
}]
return (
<div className='grid grow grid-cols-1 gap-3 overflow-y-auto px-16 pt-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{mockData.map((pipeline, index) => (
<TemplateCard
key={index}
pipeline={pipeline}
showMoreOperations={false}
/>
))}
</div>
)
}
export default BuiltInPipelineList

View File

@ -0,0 +1,52 @@
import { ChunkingMode } from '@/models/datasets'
import type { Pipeline } from './built-in-pipeline-list'
import TemplateCard from './template-card'
const CustomizedList = () => {
const mockData: Pipeline[] = [{
id: '1',
name: 'Pipeline 1',
description: 'This is a description of Pipeline 1. When use the general chunking mode, the chunks retrieved and recalled are the same. When use the general chunking mode, the chunks retrieved and recalled are the same.',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#F0FDF9',
doc_form: ChunkingMode.text,
}, {
id: '2',
name: 'Pipeline 2',
description: 'This is a description of Pipeline 2. When use the general chunking mode, the chunks retrieved and recalled are the same.',
icon_type: 'emoji',
icon: '🏖️',
icon_background: '#FFF4ED',
doc_form: ChunkingMode.parentChild,
}, {
id: '3',
name: 'Pipeline 3',
description: 'This is a description of Pipeline 3',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FEFBE8',
doc_form: ChunkingMode.qa,
}, {
id: '4',
name: 'Pipeline 4',
description: 'This is a description of Pipeline 4',
icon_type: 'emoji',
icon: '🍯',
icon_background: '#F5F3FF',
doc_form: ChunkingMode.graph,
}]
return (
<div className='grid grow grid-cols-1 gap-3 overflow-y-auto px-16 pt-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{mockData.map((pipeline, index) => (
<TemplateCard
key={index}
pipeline={pipeline}
/>
))}
</div>
)
}
export default CustomizedList

View File

@ -0,0 +1,35 @@
import { useCallback, useState } from 'react'
import Tab from './tab'
import BuiltInPipelineList from './built-in-pipeline-list'
import CustomizedList from './customized-list'
const OPTIONS = [
{ value: 'built-in', label: 'Built-in Pipeline' },
{ value: 'customized', label: 'Customized' },
]
const List = () => {
const [activeTab, setActiveTab] = useState('built-in')
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab)
}, [])
return (
<div className='flex grow flex-col'>
<Tab
activeTab={activeTab}
handleTabChange={handleTabChange}
options={OPTIONS}
/>
{
activeTab === 'built-in' && <BuiltInPipelineList />
}
{
activeTab === 'customized' && <CustomizedList />
}
</div>
)
}
export default List

View File

@ -0,0 +1,32 @@
import React from 'react'
import Item from './item'
type TabProps = {
activeTab: string
handleTabChange: (tab: string) => void
options: { value: string; label: string; }[]
}
const Tab = ({
activeTab,
handleTabChange,
options,
}: TabProps) => {
return (
<div className='px-16 pt-2'>
<div className='relative flex h-10 items-center gap-x-6'>
{options.map((option, index) => (
<Item
key={index}
option={option}
isSelected={activeTab === option.value}
onClick={handleTabChange}
/>
))}
<div className='absolute bottom-0 left-0 h-px w-full bg-divider-subtle' />
</div>
</div>
)
}
export default React.memo(Tab)

View File

@ -0,0 +1,29 @@
import cn from '@/utils/classnames'
import React from 'react'
type ItemProps = {
isSelected: boolean
option: { value: string; label: string }
onClick: (value: string) => void
}
const Item = ({
isSelected,
option,
onClick,
}: ItemProps) => {
return (
<div
className={cn(
'system-sm-semibold-uppercase relative flex h-full cursor-pointer items-center',
isSelected ? 'text-text-primary' : 'text-text-tertiary',
)}
onClick={onClick.bind(null, option.value)}
>
<span>{option.label}</span>
{isSelected && <div className='absolute bottom-0 left-0 h-0.5 w-full bg-util-colors-blue-brand-blue-brand-600' />}
</div>
)
}
export default React.memo(Item)

View File

@ -0,0 +1,147 @@
import AppIcon from '@/app/components/base/app-icon'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import type { AppIconType } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import React, { useCallback, useRef, useState } from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import type { Pipeline } from '../built-in-pipeline-list'
type EditPipelineInfoProps = {
onClose: () => void
onSave: () => void
pipeline: Pipeline
}
const EditPipelineInfo = ({
onClose,
onSave,
pipeline,
}: EditPipelineInfoProps) => {
const { t } = useTranslation()
const [name, setName] = useState(pipeline.name)
const [appIcon, setAppIcon] = useState<AppIconSelection>(
pipeline.icon_type === 'image'
? { type: 'image' as const, url: pipeline.url || '', fileId: pipeline.file_id || '' }
: { type: 'emoji' as const, icon: pipeline.icon || '', background: pipeline.icon_background || '' },
)
const [description, setDescription] = useState(pipeline.description)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef<AppIconSelection>(
pipeline.icon_type === 'image'
? { type: 'image' as const, url: pipeline.url || '', fileId: pipeline.file_id || '' }
: { type: 'emoji' as const, icon: pipeline.icon || '', background: pipeline.icon_background || '' },
)
const handleAppNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setName(value)
}, [])
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = appIcon
}, [appIcon])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
setAppIcon(icon)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setAppIcon(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
setDescription(value)
}, [])
const handleSave = useCallback(() => {
if (!name) {
Toast.notify({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
})
return
}
onSave()
onClose()
}, [name, onSave, onClose])
return (
<div className='relative flex flex-col'>
{/* Header */}
<div className='pb-3 pl-6 pr-14 pt-6'>
<span className='title-2xl-semi-bold text-text-primary'>
Edit Pipeline Info
</span>
</div>
<button
className='absolute right-5 top-5 flex size-8 items-center justify-center'
onClick={onClose}
>
<RiCloseLine className='size-5 text-text-tertiary' />
</button>
{/* Form */}
<div className='flex flex-col gap-y-5 px-6 py-3'>
<div className='flex items-end gap-x-3 self-stretch'>
<div className='flex grow flex-col gap-y-1 pb-1'>
<label className='system-sm-medium flex h-6 items-center text-text-secondary'>Pipeline name & icon</label>
<Input
onChange={handleAppNameChange}
value={name}
placeholder='Please enter the name of the Knowledge Base'
/>
</div>
<AppIcon
size='xxl'
onClick={handleOpenAppIconPicker}
className='cursor-pointer'
iconType={appIcon.type as AppIconType}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
</div>
<div className='flex flex-col gap-y-1'>
<label className='system-sm-medium flex h-6 items-center text-text-secondary'>Knowledge description</label>
<Textarea
onChange={handleDescriptionChange}
value={description}
placeholder='Describe what is in this Knowledge Base. A detailed description allows AI to access the content of the dataset more accurately. If empty, Dify will use the default hit strategy. (Optional)'
/>
</div>
</div>
{/* Actions */}
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
<Button
variant='secondary'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
onClick={handleSave}
>
{t('common.operation.save')}
</Button>
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</div>
)
}
export default React.memo(EditPipelineInfo)

View File

@ -0,0 +1,122 @@
import React, { useCallback, useState } from 'react'
import type { Pipeline } from '../built-in-pipeline-list'
import AppIcon from '@/app/components/base/app-icon'
import { DOC_FORM_ICON, DOC_FORM_TEXT } from '../../../list/dataset-card'
import { General } from '@/app/components/base/icons/src/public/knowledge'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { RiAddLine, RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
import CustomPopover from '@/app/components/base/popover'
import Operations from './operations'
import Modal from '@/app/components/base/modal'
import EditPipelineInfo from './edit-pipeline-info'
type TemplateCardProps = {
pipeline: Pipeline
showMoreOperations?: boolean
}
const TemplateCard = ({
pipeline,
showMoreOperations = true,
}: TemplateCardProps) => {
const { t } = useTranslation()
const [showEditModal, setShowEditModal] = useState(false)
const openEditModal = useCallback(() => {
setShowEditModal(true)
}, [])
const closeEditModal = useCallback(() => {
setShowEditModal(false)
}, [])
const Icon = DOC_FORM_ICON[pipeline.doc_form] || General
return (
<div className='group relative flex h-[132px] cursor-pointer flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3'>
<div className='flex items-center gap-x-3 p-4 pb-2'>
<div className='relative shrink-0'>
<AppIcon
size='large'
iconType={pipeline.icon_type}
icon={pipeline.icon_type === 'image' ? pipeline.file_id : pipeline.icon}
background={pipeline.icon_type === 'image' ? undefined : pipeline.icon_background}
imageUrl={pipeline.icon_type === 'image' ? pipeline.url : undefined}
/>
<div className='absolute -bottom-1 -right-1 z-10'>
<Icon className='size-4' />
</div>
</div>
<div className='flex grow flex-col gap-y-1 py-px'>
<div className='system-md-semibold truncate text-text-secondary' title={pipeline.name}>{pipeline.name}</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[pipeline.doc_form]}`)}
</div>
</div>
</div>
<p className='system-xs-regular line-clamp-3 grow px-4 py-1 text-text-tertiary' title={pipeline.description}>
{pipeline.description}
</p>
<div className='absolute bottom-0 left-0 z-10 hidden w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8 group-hover:flex'>
<Button
variant='primary'
onClick={() => {
console.log('Choose', pipeline)
}}
className='grow gap-x-0.5'
>
<RiAddLine className='size-4' />
<span className='px-0.5'>Choose</span>
</Button>
<Button
variant='secondary'
onClick={() => {
console.log('details', pipeline)
}}
className='grow gap-x-0.5'
>
<RiArrowRightUpLine className='size-4' />
<span className='px-0.5'>Details</span>
</Button>
{
showMoreOperations && (
<CustomPopover
htmlContent={
<Operations
openEditModal={openEditModal}
onDelete={() => {
console.log('Delete', pipeline)
}}
/>
}
className={'z-20 min-w-[160px]'}
popupClassName={'rounded-xl bg-none shadow-none ring-0 min-w-[160px]'}
position='br'
trigger='click'
btnElement={
<RiMoreFill className='size-4 text-text-tertiary' />
}
btnClassName='size-8 cursor-pointer justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3'
/>
)
}
</div>
<Modal
isShow={showEditModal}
onClose={closeEditModal}
className='max-w-[520px] p-0'
>
<EditPipelineInfo
pipeline={pipeline}
onClose={closeEditModal}
onSave={() => {
console.log('Save', pipeline)
}}
/>
</Modal>
</div>
)
}
export default React.memo(TemplateCard)

View File

@ -0,0 +1,63 @@
import Divider from '@/app/components/base/divider'
import React from 'react'
import { useTranslation } from 'react-i18next'
type OperationsProps = {
openEditModal: () => void
onDelete: () => void
}
const Operations = ({
openEditModal,
onDelete,
}: OperationsProps) => {
const { t } = useTranslation()
const onClickEdit = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
openEditModal()
}
const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onDelete()
}
return (
<div className='relative flex w-full flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5'>
<div className='flex flex-col p-1'>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={onClickEdit}
>
<span className='system-md-regular px-1 text-text-secondary'>
Edit Info
</span>
</div>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => { console.log('Export DSL') }}
>
<span className='system-md-regular px-1 text-text-secondary'>
Export DSL
</span>
</div>
</div>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<div
className='group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover'
onClick={onClickDelete}
>
<span className='system-md-regular px-1 text-text-secondary group-hover:text-text-destructive'>
{t('common.operation.delete')}
</span>
</div>
</div>
</div>
)
}
export default React.memo(Operations)

View File

@ -24,14 +24,14 @@ import Operations from './operations'
const EXTERNAL_PROVIDER = 'external'
const DOC_FORM_ICON: Record<ChunkingMode, React.ComponentType<{ className: string }>> = {
export const DOC_FORM_ICON: Record<ChunkingMode, React.ComponentType<{ className: string }>> = {
[ChunkingMode.text]: General,
[ChunkingMode.qa]: Qa,
[ChunkingMode.parentChild]: ParentChild,
[ChunkingMode.graph]: Graph,
}
const DOC_FORM_TEXT: Record<ChunkingMode, string> = {
export const DOC_FORM_TEXT: Record<ChunkingMode, string> = {
[ChunkingMode.text]: 'general',
[ChunkingMode.qa]: 'qa',
[ChunkingMode.parentChild]: 'parentChild',
@ -216,7 +216,7 @@ const DatasetCard = ({
}
btnClassName={open =>
cn(
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 p-0.5 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border hover:bg-state-base-hover',
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border hover:bg-state-base-hover',
open ? 'border-components-actionbar-border bg-state-base-hover' : '',
)
}

View File

@ -123,6 +123,7 @@ const config = {
'node-data-source-bg': 'var(--color-node-data-source-bg)',
'tag-selector-mask-bg': 'var(--color-tag-selector-mask-bg)',
'tag-selector-mask-hover-bg': 'var(--color-tag-selector-mask-hover-bg)',
'pipeline-template-card-hover-bg': 'var(--color-pipeline-template-card-hover-bg)',
},
animation: {
'spin-slow': 'spin 2s linear infinite',

View File

@ -66,4 +66,5 @@ html[data-theme="dark"] {
--color-node-data-source-bg: linear-gradient(100deg, var(--workflow-block-wrapper-bg-1, #E9EBF0) 0%, var(--workflow-block-wrapper-bg-2, rgba(233, 235, 240, 0.20)) 100%);
--color-tag-selector-mask-bg: linear-gradient(90deg, rgba(34, 34, 37, 0) 0%, rgba(34, 34, 37, 1) 100%);
--color-tag-selector-mask-hover-bg: linear-gradient(90deg, rgba(39, 39, 43, 0) 0%, rgba(39, 39, 43, 1) 100%);
--color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(58, 58, 64, 1) 60.27%, rgba(58, 58, 64, 0) 100%);
}

View File

@ -66,4 +66,5 @@ html[data-theme="light"] {
--color-node-data-source-bg: linear-gradient(100deg, var(--workflow-block-wrapper-bg-1, #E9EBF0) 0%, var(--workflow-block-wrapper-bg-2, rgba(233, 235, 240, 0.20)) 100%);
--color-tag-selector-mask-bg: linear-gradient(90deg, rgba(252, 252, 253, 0) 0%, rgba(252, 252, 253, 1) 100%);
--color-tag-selector-mask-hover-bg: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
--color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(249, 250, 251, 1) 60.27%, rgba(249, 250, 251, 0) 100%);
}