dify
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
GeneralChunk,
|
||||
ParentChildChunk,
|
||||
QuestionAndAnswer,
|
||||
} from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { EffectColor, type Option } from './types'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useChunkStructure = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const GeneralOption: Option = {
|
||||
id: ChunkingMode.text,
|
||||
icon: <GeneralChunk className='size-[18px]' />,
|
||||
iconActiveColor: 'text-util-colors-indigo-indigo-600',
|
||||
title: 'General',
|
||||
description: t('datasetCreation.stepTwo.generalTip'),
|
||||
effectColor: EffectColor.indigo,
|
||||
showEffectColor: true,
|
||||
}
|
||||
const ParentChildOption: Option = {
|
||||
id: ChunkingMode.parentChild,
|
||||
icon: <ParentChildChunk className='size-[18px]' />,
|
||||
iconActiveColor: 'text-util-colors-blue-light-blue-light-500',
|
||||
title: 'Parent-Child',
|
||||
description: t('datasetCreation.stepTwo.parentChildTip'),
|
||||
effectColor: EffectColor.blueLight,
|
||||
showEffectColor: true,
|
||||
}
|
||||
const QuestionAnswerOption: Option = {
|
||||
id: ChunkingMode.qa,
|
||||
icon: <QuestionAndAnswer className='size-[18px]' />,
|
||||
title: 'Q&A',
|
||||
description: t('datasetCreation.stepTwo.qaTip'),
|
||||
}
|
||||
|
||||
const options = [
|
||||
GeneralOption,
|
||||
ParentChildOption,
|
||||
QuestionAnswerOption,
|
||||
]
|
||||
|
||||
return {
|
||||
options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ChunkingMode } from '@/models/datasets'
|
||||
import React from 'react'
|
||||
import { useChunkStructure } from './hooks'
|
||||
import OptionCard from '../option-card'
|
||||
|
||||
type ChunkStructureProps = {
|
||||
chunkStructure: ChunkingMode
|
||||
}
|
||||
|
||||
const ChunkStructure = ({
|
||||
chunkStructure,
|
||||
}: ChunkStructureProps) => {
|
||||
const {
|
||||
options,
|
||||
} = useChunkStructure()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-1'>
|
||||
{
|
||||
options.map(option => (
|
||||
<OptionCard
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
icon={option.icon}
|
||||
iconActiveColor={option.iconActiveColor}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
isActive={chunkStructure === option.id}
|
||||
effectColor={option.effectColor}
|
||||
showEffectColor
|
||||
className='gap-x-1.5 p-3 pr-4'
|
||||
disabled
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ChunkStructure)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
export enum EffectColor {
|
||||
indigo = 'indigo',
|
||||
blueLight = 'blue-light',
|
||||
orange = 'orange',
|
||||
purple = 'purple',
|
||||
}
|
||||
|
||||
export type Option = {
|
||||
id: ChunkingMode
|
||||
icon?: React.ReactNode
|
||||
iconActiveColor?: string
|
||||
title: string
|
||||
description?: string
|
||||
effectColor?: EffectColor
|
||||
showEffectColor?: boolean
|
||||
}
|
||||
479
dify/web/app/components/datasets/settings/form/index.tsx
Normal file
479
dify/web/app/components/datasets/settings/form/index.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
'use client'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useMount } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PermissionSelector from '../permission-selector'
|
||||
import IndexMethod from '../index-method'
|
||||
import RetrievalSettings from '../../external-knowledge-base/create/RetrievalSettings'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
|
||||
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { ChunkingMode, DatasetPermission } from '@/models/datasets'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { AppIconType, RetrievalConfig } from '@/types/app'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import {
|
||||
useModelList,
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import type { Member } from '@/models/common'
|
||||
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 Divider from '@/app/components/base/divider'
|
||||
import ChunkStructure from '../chunk-structure'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
|
||||
const rowClass = 'flex gap-x-1'
|
||||
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
|
||||
|
||||
const DEFAULT_APP_ICON: IconInfo = {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
const Form = () => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
|
||||
const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [name, setName] = useState(currentDataset?.name ?? '')
|
||||
const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [description, setDescription] = useState(currentDataset?.description ?? '')
|
||||
const [permission, setPermission] = useState(currentDataset?.permission)
|
||||
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
|
||||
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
|
||||
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
|
||||
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset?.partial_member_list || [])
|
||||
const [memberList, setMemberList] = useState<Member[]>([])
|
||||
const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
|
||||
const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10)
|
||||
const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
|
||||
const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
|
||||
currentDataset?.embedding_model
|
||||
? {
|
||||
provider: currentDataset.embedding_model_provider,
|
||||
model: currentDataset.embedding_model,
|
||||
}
|
||||
: {
|
||||
provider: '',
|
||||
model: '',
|
||||
},
|
||||
)
|
||||
const {
|
||||
modelList: rerankModelList,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const previousAppIcon = useRef(DEFAULT_APP_ICON)
|
||||
|
||||
const getMembers = async () => {
|
||||
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
|
||||
if (!accounts)
|
||||
setMemberList([])
|
||||
else
|
||||
setMemberList(accounts)
|
||||
}
|
||||
|
||||
const handleOpenAppIconPicker = useCallback(() => {
|
||||
setShowAppIconPicker(true)
|
||||
previousAppIcon.current = iconInfo
|
||||
}, [iconInfo])
|
||||
|
||||
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
|
||||
const iconInfo: IconInfo = {
|
||||
icon_type: icon.type,
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'emoji' ? undefined : icon.url,
|
||||
}
|
||||
setIconInfo(iconInfo)
|
||||
setShowAppIconPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleCloseAppIconPicker = useCallback(() => {
|
||||
setIconInfo(previousAppIcon.current)
|
||||
setShowAppIconPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleSettingsChange = useCallback((data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => {
|
||||
if (data.top_k !== undefined)
|
||||
setTopK(data.top_k)
|
||||
if (data.score_threshold !== undefined)
|
||||
setScoreThreshold(data.score_threshold)
|
||||
if (data.score_threshold_enabled !== undefined)
|
||||
setScoreThresholdEnabled(data.score_threshold_enabled)
|
||||
}, [])
|
||||
|
||||
useMount(() => {
|
||||
getMembers()
|
||||
})
|
||||
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const handleSave = async () => {
|
||||
if (loading)
|
||||
return
|
||||
if (!name?.trim()) {
|
||||
Toast.notify({ type: 'error', message: t('datasetSettings.form.nameError') })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!isReRankModelSelected({
|
||||
rerankModelList,
|
||||
retrievalConfig,
|
||||
indexMethod,
|
||||
})
|
||||
) {
|
||||
Toast.notify({ type: 'error', message: t('appDebug.datasetConfig.rerankModelRequired') })
|
||||
return
|
||||
}
|
||||
if (retrievalConfig.weights) {
|
||||
retrievalConfig.weights.vector_setting.embedding_provider_name = embeddingModel.provider || ''
|
||||
retrievalConfig.weights.vector_setting.embedding_model_name = embeddingModel.model || ''
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const requestParams = {
|
||||
datasetId: currentDataset!.id,
|
||||
body: {
|
||||
name,
|
||||
icon_info: iconInfo,
|
||||
doc_form: currentDataset?.doc_form,
|
||||
description,
|
||||
permission,
|
||||
indexing_technique: indexMethod,
|
||||
retrieval_model: {
|
||||
...retrievalConfig,
|
||||
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
|
||||
},
|
||||
embedding_model: embeddingModel.model,
|
||||
embedding_model_provider: embeddingModel.provider,
|
||||
...(currentDataset!.provider === 'external' && {
|
||||
external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id,
|
||||
external_knowledge_api_id: currentDataset!.external_knowledge_info.external_knowledge_api_id,
|
||||
external_retrieval_model: {
|
||||
top_k: topK,
|
||||
score_threshold: scoreThreshold,
|
||||
score_threshold_enabled: scoreThresholdEnabled,
|
||||
},
|
||||
}),
|
||||
keyword_number: keywordNumber,
|
||||
},
|
||||
} as any
|
||||
if (permission === DatasetPermission.partialMembers) {
|
||||
requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
|
||||
return {
|
||||
user_id: id,
|
||||
role: memberList.find(member => member.id === id)?.role,
|
||||
}
|
||||
})
|
||||
}
|
||||
await updateDatasetSetting(requestParams)
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
if (mutateDatasets) {
|
||||
await mutateDatasets()
|
||||
invalidDatasetList()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isShowIndexMethod = currentDataset && currentDataset.doc_form !== ChunkingMode.parentChild && currentDataset.indexing_technique && indexMethod
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-y-4 px-20 py-8 sm:w-[960px]'>
|
||||
{/* Dataset name and icon */}
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.nameAndIcon')}</div>
|
||||
</div>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<AppIcon
|
||||
size='small'
|
||||
onClick={handleOpenAppIconPicker}
|
||||
className='cursor-pointer'
|
||||
iconType={iconInfo.icon_type as AppIconType}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
showEditIcon
|
||||
/>
|
||||
<Input
|
||||
disabled={!currentDataset?.embedding_available}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dataset description */}
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.desc')}</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<Textarea
|
||||
disabled={!currentDataset?.embedding_available}
|
||||
className='resize-none'
|
||||
placeholder={t('datasetSettings.form.descPlaceholder') || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Permissions */}
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.permissions')}</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<PermissionSelector
|
||||
disabled={!currentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
|
||||
permission={permission}
|
||||
value={selectedMemberIDs}
|
||||
onChange={v => setPermission(v)}
|
||||
onMemberSelect={setSelectedMemberIDs}
|
||||
memberList={memberList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
currentDataset?.doc_form && (
|
||||
<>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
{/* Chunk Structure */}
|
||||
<div className={rowClass}>
|
||||
<div className='flex w-[180px] shrink-0 flex-col'>
|
||||
<div className='system-sm-semibold flex h-8 items-center text-text-secondary'>
|
||||
{t('datasetSettings.form.chunkStructure.title')}
|
||||
</div>
|
||||
<div className='body-xs-regular text-text-tertiary'>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/chunking-and-cleaning-text')}
|
||||
className='text-text-accent'
|
||||
>
|
||||
{t('datasetSettings.form.chunkStructure.learnMore')}
|
||||
</a>
|
||||
{t('datasetSettings.form.chunkStructure.description')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<ChunkStructure
|
||||
chunkStructure={currentDataset?.doc_form}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{(isShowIndexMethod || indexMethod === 'high_quality') && (
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
)}
|
||||
{isShowIndexMethod && (
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.indexMethod')}</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<IndexMethod
|
||||
value={indexMethod}
|
||||
disabled={!currentDataset?.embedding_available}
|
||||
onChange={v => setIndexMethod(v!)}
|
||||
currentValue={currentDataset.indexing_technique}
|
||||
keywordNumber={keywordNumber}
|
||||
onKeywordNumberChange={setKeywordNumber}
|
||||
/>
|
||||
{currentDataset.indexing_technique === IndexingType.ECONOMICAL && indexMethod === IndexingType.QUALIFIED && (
|
||||
<div className='relative mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs shadow-shadow-shadow-3'>
|
||||
<div className='absolute left-0 top-0 flex h-full w-full items-center bg-toast-warning-bg opacity-40' />
|
||||
<div className='p-1'>
|
||||
<RiAlertFill className='size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<span className='system-xs-medium text-text-primary'>
|
||||
{t('datasetSettings.form.upgradeHighQualityTip')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{indexMethod === IndexingType.QUALIFIED && (
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>
|
||||
{t('datasetSettings.form.embeddingModel')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<ModelSelector
|
||||
defaultModel={embeddingModel}
|
||||
modelList={embeddingModelList}
|
||||
onSelect={setEmbeddingModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Retrieval Method Config */}
|
||||
{currentDataset?.provider === 'external'
|
||||
? (
|
||||
<>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
</div>
|
||||
<RetrievalSettings
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
onChange={handleSettingsChange}
|
||||
isInRetrievalSetting={true}
|
||||
/>
|
||||
</div>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
|
||||
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
|
||||
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>·</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{currentDataset?.external_knowledge_info.external_knowledge_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: indexMethod
|
||||
? (
|
||||
<>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='flex w-[180px] shrink-0 flex-col'>
|
||||
<div className='system-sm-semibold flex h-7 items-center pt-1 text-text-secondary'>
|
||||
{t('datasetSettings.form.retrievalSetting.title')}
|
||||
</div>
|
||||
<div className='body-xs-regular text-text-tertiary'>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting', {
|
||||
'zh-Hans': '/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#指定检索方式',
|
||||
'ja-JP': '/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#検索方法の指定',
|
||||
})}
|
||||
className='text-text-accent'
|
||||
>
|
||||
{t('datasetSettings.form.retrievalSetting.learnMore')}
|
||||
</a>
|
||||
{t('datasetSettings.form.retrievalSetting.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
{indexMethod === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={setRetrievalConfig}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EconomicalRetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={setRetrievalConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: null
|
||||
}
|
||||
<Divider
|
||||
type='horizontal'
|
||||
className='my-1 h-px bg-divider-subtle'
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass} />
|
||||
<div className='grow'>
|
||||
<Button
|
||||
className='min-w-24'
|
||||
variant='primary'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('datasetSettings.form.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
onSelect={handleSelectAppIcon}
|
||||
onClose={handleCloseAppIconPicker}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Form
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef } from 'react'
|
||||
import { useHover } from 'ahooks'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import classNames from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Economic, HighQuality } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { EffectColor } from '../chunk-structure/types'
|
||||
import OptionCard from '../option-card'
|
||||
import KeywordNumber from './keyword-number'
|
||||
|
||||
type IndexMethodProps = {
|
||||
value: IndexingType
|
||||
onChange: (id: IndexingType) => void
|
||||
disabled?: boolean
|
||||
currentValue?: IndexingType
|
||||
keywordNumber: number
|
||||
onKeywordNumberChange: (value: number) => void
|
||||
}
|
||||
|
||||
const IndexMethod = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
currentValue,
|
||||
keywordNumber,
|
||||
onKeywordNumberChange,
|
||||
}: IndexMethodProps) => {
|
||||
const { t } = useTranslation()
|
||||
const economyDomRef = useRef<HTMLDivElement>(null)
|
||||
const isHoveringEconomy = useHover(economyDomRef)
|
||||
const isEconomyDisabled = currentValue === IndexingType.QUALIFIED
|
||||
|
||||
return (
|
||||
<div className={classNames('flex flex-col gap-y-2')}>
|
||||
{/* High Quality */}
|
||||
<OptionCard
|
||||
id={IndexingType.QUALIFIED}
|
||||
isActive={value === IndexingType.QUALIFIED}
|
||||
onClick={onChange}
|
||||
icon={<HighQuality className='size-[18px]' />}
|
||||
iconActiveColor='text-util-colors-orange-orange-500'
|
||||
title={t('datasetCreation.stepTwo.qualified')}
|
||||
description={t('datasetSettings.form.indexMethodHighQualityTip')}
|
||||
disabled={disabled}
|
||||
isRecommended
|
||||
effectColor={EffectColor.orange}
|
||||
showEffectColor
|
||||
className='gap-x-2'
|
||||
/>
|
||||
{/* Economy */}
|
||||
<PortalToFollowElem
|
||||
open={isHoveringEconomy}
|
||||
offset={4}
|
||||
placement={'right'}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<OptionCard
|
||||
ref={economyDomRef}
|
||||
id={IndexingType.ECONOMICAL}
|
||||
isActive={value === IndexingType.ECONOMICAL}
|
||||
onClick={onChange}
|
||||
icon={<Economic className='size-[18px]' />}
|
||||
iconActiveColor='text-util-colors-indigo-indigo-600'
|
||||
title={t('datasetSettings.form.indexMethodEconomy')}
|
||||
description={t('datasetSettings.form.indexMethodEconomyTip', { count: keywordNumber })}
|
||||
disabled={disabled || isEconomyDisabled}
|
||||
effectColor={EffectColor.indigo}
|
||||
showEffectColor
|
||||
showChildren
|
||||
className='gap-x-2'
|
||||
>
|
||||
<KeywordNumber
|
||||
keywordNumber={keywordNumber}
|
||||
onKeywordNumberChange={onKeywordNumberChange}
|
||||
/>
|
||||
</OptionCard>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 60 }}>
|
||||
<div className='rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg'>
|
||||
{t('datasetSettings.form.indexMethodChangeToEconomyDisabledTip')}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexMethod
|
||||
@@ -0,0 +1,52 @@
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type KeyWordNumberProps = {
|
||||
keywordNumber: number
|
||||
onKeywordNumberChange: (value: number) => void
|
||||
}
|
||||
|
||||
const KeyWordNumber = ({
|
||||
keywordNumber,
|
||||
onKeywordNumberChange,
|
||||
}: KeyWordNumberProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInputChange = useCallback((value: number | undefined) => {
|
||||
if (value)
|
||||
onKeywordNumberChange(value)
|
||||
}, [onKeywordNumberChange])
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<div className='flex grow items-center gap-x-0.5'>
|
||||
<div className='system-xs-medium truncate text-text-secondary'>
|
||||
{t('datasetSettings.form.numberOfKeywords')}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent='number of keywords'
|
||||
>
|
||||
<RiQuestionLine className='h-3.5 w-3.5 text-text-quaternary' />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Slider
|
||||
className='mr-3 w-[206px] shrink-0'
|
||||
value={keywordNumber}
|
||||
max={50}
|
||||
onChange={onKeywordNumberChange}
|
||||
/>
|
||||
<InputNumber
|
||||
wrapperClassName='shrink-0 w-12'
|
||||
type='number'
|
||||
value={keywordNumber}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(KeyWordNumber)
|
||||
120
dify/web/app/components/datasets/settings/option-card.tsx
Normal file
120
dify/web/app/components/datasets/settings/option-card.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EffectColor } from './chunk-structure/types'
|
||||
import { ArrowShape } from '../../base/icons/src/vender/knowledge'
|
||||
|
||||
const HEADER_EFFECT_MAP: Record<EffectColor, string> = {
|
||||
[EffectColor.indigo]: 'bg-util-colors-indigo-indigo-600 opacity-50',
|
||||
[EffectColor.blueLight]: 'bg-util-colors-blue-light-blue-light-600 opacity-80',
|
||||
[EffectColor.orange]: 'bg-util-colors-orange-orange-500 opacity-50',
|
||||
[EffectColor.purple]: 'bg-util-colors-purple-purple-600 opacity-80',
|
||||
}
|
||||
type OptionCardProps<T> = {
|
||||
id: T
|
||||
className?: string
|
||||
isActive?: boolean
|
||||
icon?: ReactNode
|
||||
iconActiveColor?: string
|
||||
title: string
|
||||
description?: string
|
||||
isRecommended?: boolean
|
||||
effectColor?: EffectColor
|
||||
showEffectColor?: boolean
|
||||
disabled?: boolean
|
||||
onClick?: (id: T) => void
|
||||
children?: ReactNode
|
||||
showChildren?: boolean
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
const OptionCard = <T,>({
|
||||
id,
|
||||
className,
|
||||
isActive,
|
||||
icon,
|
||||
iconActiveColor,
|
||||
title,
|
||||
description,
|
||||
isRecommended,
|
||||
effectColor,
|
||||
showEffectColor,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
showChildren,
|
||||
ref,
|
||||
}: OptionCardProps<T>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg',
|
||||
isActive && 'border border-components-option-card-option-selected-border ring-[1px] ring-components-option-card-option-selected-border',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isActive || disabled) return
|
||||
onClick?.(id)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative flex rounded-t-xl p-2',
|
||||
className,
|
||||
)}>
|
||||
{
|
||||
effectColor && showEffectColor && (
|
||||
<div className={cn(
|
||||
'absolute left-[-2px] top-[-2px] h-14 w-14 rounded-full blur-[80px]',
|
||||
`${HEADER_EFFECT_MAP[effectColor]}`,
|
||||
)} />
|
||||
)
|
||||
}
|
||||
{
|
||||
icon && (
|
||||
<div className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center text-text-tertiary',
|
||||
isActive && iconActiveColor,
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='flex grow flex-col gap-y-0.5 py-px'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='system-sm-medium text-text-secondary'>
|
||||
{title}
|
||||
</span>
|
||||
{
|
||||
isRecommended && (
|
||||
<Badge className='h-[18px] border-text-accent-secondary text-text-accent-secondary'>
|
||||
{t('datasetCreation.stepTwo.recommend')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
description && (
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{description}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
children && showChildren && (
|
||||
<div className='relative rounded-b-xl bg-components-panel-bg p-4'>
|
||||
<ArrowShape className='absolute left-[14px] top-[-11px] size-4 text-components-panel-bg' />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionCard) as typeof OptionCard
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import type { Member } from '@/models/common'
|
||||
import Item from './permission-item'
|
||||
import MemberItem from './member-item'
|
||||
|
||||
export type RoleSelectorProps = {
|
||||
disabled?: boolean
|
||||
permission?: DatasetPermission
|
||||
value: string[]
|
||||
memberList: Member[]
|
||||
onChange: (permission?: DatasetPermission) => void
|
||||
onMemberSelect: (v: string[]) => void
|
||||
}
|
||||
|
||||
const PermissionSelector = ({
|
||||
disabled,
|
||||
permission,
|
||||
value,
|
||||
memberList,
|
||||
onChange,
|
||||
onMemberSelect,
|
||||
}: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAppContextWithSelector(state => state.userProfile)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
const selectMember = useCallback((member: Member) => {
|
||||
if (value.includes(member.id))
|
||||
onMemberSelect(value.filter(v => v !== member.id))
|
||||
else
|
||||
onMemberSelect([...value, member.id])
|
||||
}, [value, onMemberSelect])
|
||||
|
||||
const selectedMembers = useMemo(() => {
|
||||
return [
|
||||
userProfile,
|
||||
...memberList.filter(member => member.id !== userProfile.id).filter(member => value.includes(member.id)),
|
||||
]
|
||||
}, [userProfile, value, memberList])
|
||||
|
||||
const showMe = useMemo(() => {
|
||||
return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
|
||||
}, [searchKeywords, userProfile])
|
||||
|
||||
const filteredMemberList = useMemo(() => {
|
||||
return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id && ['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role))
|
||||
}, [memberList, searchKeywords, userProfile])
|
||||
|
||||
const onSelectOnlyMe = useCallback(() => {
|
||||
onChange(DatasetPermission.onlyMe)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectAllMembers = useCallback(() => {
|
||||
onChange(DatasetPermission.allTeamMembers)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectPartialMembers = useCallback(() => {
|
||||
onChange(DatasetPermission.partialMembers)
|
||||
onMemberSelect([userProfile.id])
|
||||
}, [onChange, onMemberSelect, userProfile])
|
||||
|
||||
const isOnlyMe = permission === DatasetPermission.onlyMe
|
||||
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
|
||||
const isPartialMembers = permission === DatasetPermission.partialMembers
|
||||
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<div className='relative'>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && setOpen(v => !v)}
|
||||
className='block'
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
|
||||
open && 'bg-state-base-hover-alt',
|
||||
disabled && '!cursor-not-allowed !bg-components-input-bg-disabled hover:!bg-components-input-bg-disabled',
|
||||
)}>
|
||||
{
|
||||
isOnlyMe && (
|
||||
<>
|
||||
<div className='flex size-6 shrink-0 items-center justify-center'>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={20} />
|
||||
</div>
|
||||
<div className='system-sm-regular grow p-1 text-components-input-text-filled'>
|
||||
{t('datasetSettings.form.permissionsOnlyMe')}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAllTeamMembers && (
|
||||
<>
|
||||
<div className='flex size-6 shrink-0 items-center justify-center'>
|
||||
<RiGroup2Line className='size-4 text-text-secondary' />
|
||||
</div>
|
||||
<div className='system-sm-regular grow p-1 text-components-input-text-filled'>
|
||||
{t('datasetSettings.form.permissionsAllMember')}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isPartialMembers && (
|
||||
<>
|
||||
<div className='relative flex size-6 shrink-0 items-center justify-center'>
|
||||
{
|
||||
selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0].avatar_url}
|
||||
name={selectedMembers[0].name}
|
||||
size={20}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={selectedMembers[0].avatar_url}
|
||||
name={selectedMembers[0].name}
|
||||
className='absolute left-0 top-0 z-0'
|
||||
size={16}
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1].avatar_url}
|
||||
name={selectedMembers[1].name}
|
||||
className='absolute bottom-0 right-0 z-10'
|
||||
size={16}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className='system-sm-regular grow truncate p-1 text-components-input-text-filled'
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && '!text-components-input-text-placeholder',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1002]'>
|
||||
<div className='relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5'>
|
||||
<div className='p-1'>
|
||||
{/* Only me */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} />
|
||||
}
|
||||
text={t('datasetSettings.form.permissionsOnlyMe')}
|
||||
onClick={onSelectOnlyMe}
|
||||
isSelected={isOnlyMe}
|
||||
/>
|
||||
{/* All team members */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className='flex size-6 shrink-0 items-center justify-center'>
|
||||
<RiGroup2Line className='size-4 text-text-secondary' />
|
||||
</div>
|
||||
}
|
||||
text={t('datasetSettings.form.permissionsAllMember')}
|
||||
onClick={onSelectAllMembers}
|
||||
isSelected={isAllTeamMembers}
|
||||
/>
|
||||
{/* Partial members */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className='flex size-6 shrink-0 items-center justify-center'>
|
||||
<RiLock2Line className='size-4 text-text-secondary' />
|
||||
</div>
|
||||
}
|
||||
text={t('datasetSettings.form.permissionsInvitedMembers')}
|
||||
onClick={onSelectPartialMembers}
|
||||
isSelected={isPartialMembers}
|
||||
/>
|
||||
</div>
|
||||
{isPartialMembers && (
|
||||
<div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular pb-1 pl-1 pr-1'>
|
||||
<div className='sticky left-0 top-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1'>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col p-1'>
|
||||
{showMe && (
|
||||
<MemberItem
|
||||
leftIcon={
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} />
|
||||
}
|
||||
name={userProfile.name}
|
||||
email={userProfile.email}
|
||||
isSelected
|
||||
isMe
|
||||
/>
|
||||
)}
|
||||
{filteredMemberList.map(member => (
|
||||
<MemberItem
|
||||
leftIcon={
|
||||
<Avatar avatar={member.avatar_url} name={member.name} className='shrink-0' size={24} />
|
||||
}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
isSelected={value.includes(member.id)}
|
||||
onClick={selectMember.bind(null, member)}
|
||||
/>
|
||||
))}
|
||||
{
|
||||
!showMe && filteredMemberList.length === 0 && (
|
||||
<div className='system-xs-regular flex items-center justify-center whitespace-pre-wrap px-1 py-6 text-center text-text-tertiary'>
|
||||
{t('datasetSettings.form.onSearchResults')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionSelector
|
||||
@@ -0,0 +1,45 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MemberItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
name: string
|
||||
email: string
|
||||
isSelected: boolean
|
||||
isMe?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const MemberItem = ({
|
||||
leftIcon,
|
||||
name,
|
||||
email,
|
||||
isSelected,
|
||||
isMe = false,
|
||||
onClick,
|
||||
}: MemberItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-[10px] hover:bg-state-base-hover'
|
||||
onClick={onClick}
|
||||
>
|
||||
{leftIcon}
|
||||
<div className='grow'>
|
||||
<div className='system-sm-medium truncate text-text-secondary'>
|
||||
{name}
|
||||
{isMe && <span className='system-xs-regular text-text-tertiary'>
|
||||
{t('datasetSettings.form.me')}
|
||||
</span>}
|
||||
</div>
|
||||
<div className='system-xs-regular truncate text-text-tertiary'>{email}</div>
|
||||
</div>
|
||||
{isSelected && <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MemberItem)
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
|
||||
type PermissionItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
text: string
|
||||
onClick: () => void
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
const PermissionItem = ({
|
||||
leftIcon,
|
||||
text,
|
||||
onClick,
|
||||
isSelected,
|
||||
}: PermissionItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover'
|
||||
onClick={onClick}
|
||||
>
|
||||
{leftIcon}
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>
|
||||
{text}
|
||||
</div>
|
||||
{isSelected && <RiCheckLine className='size-4 text-text-accent' />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PermissionItem)
|
||||
Reference in New Issue
Block a user