This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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
}

View 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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)