monorepo packages (#344)

This commit is contained in:
Archer
2023-09-24 18:02:09 +08:00
committed by GitHub
parent a4ff5a3f73
commit 3d7178d06f
535 changed files with 12048 additions and 227 deletions

View File

@@ -0,0 +1,256 @@
import React, { useCallback, useState, useRef, useMemo } from 'react';
import { Box, Card, IconButton, Flex, Grid, Image } from '@chakra-ui/react';
import type { PgDataItemType } from '@/types/core/dataset/data';
import { usePagination } from '@/hooks/usePagination';
import {
getDatasetDataList,
delOneDatasetDataById,
getTrainingData
} from '@/api/core/dataset/data';
import { getFileInfoById } from '@/api/core/dataset/file';
import { DeleteIcon, RepeatIcon } from '@chakra-ui/icons';
import { useQuery } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import InputModal, { FormData as InputDataType } from './InputDataModal';
import { debounce } from 'lodash';
import { getErrText } from '@/utils/tools';
import { useConfirm } from '@/hooks/useConfirm';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/router';
import MyIcon from '@/components/Icon';
import MyTooltip from '@/components/MyTooltip';
import MyInput from '@/components/MyInput';
import { fileImgs } from '@/constants/common';
const DataCard = ({ kbId }: { kbId: string }) => {
const BoxRef = useRef<HTMLDivElement>(null);
const lastSearch = useRef('');
const router = useRouter();
const { fileId = '' } = router.query as { fileId: string };
const { t } = useTranslation();
const [searchText, setSearchText] = useState('');
const { toast } = useToast();
const [isDeleting, setIsDeleting] = useState(false);
const { openConfirm, ConfirmModal } = useConfirm({
content: t('dataset.Confirm to delete the data')
});
const {
data: kbDataList,
isLoading,
Pagination,
total,
getData,
pageNum,
pageSize
} = usePagination<PgDataItemType>({
api: getDatasetDataList,
pageSize: 24,
params: {
kbId,
searchText,
fileId
},
onChange() {
if (BoxRef.current) {
BoxRef.current.scrollTop = 0;
}
}
});
const [editInputData, setEditInputData] = useState<InputDataType>();
// get first page data
const getFirstData = useCallback(
debounce(() => {
getData(1);
lastSearch.current = searchText;
}, 300),
[]
);
// get file info
const { data: fileInfo } = useQuery(['getFileInfo', fileId], () => getFileInfoById(fileId));
const fileIcon = useMemo(
() =>
fileImgs.find((item) => new RegExp(item.suffix, 'gi').test(fileInfo?.filename || ''))?.src,
[fileInfo?.filename]
);
return (
<Box ref={BoxRef} position={'relative'} px={5} py={[1, 5]} h={'100%'} overflow={'overlay'}>
<Flex alignItems={'center'}>
<IconButton
mr={3}
icon={<MyIcon name={'backFill'} w={'18px'} color={'myBlue.600'} />}
bg={'white'}
boxShadow={'1px 1px 9px rgba(0,0,0,0.15)'}
h={'28px'}
size={'sm'}
borderRadius={'50%'}
aria-label={''}
onClick={() =>
router.replace({
query: {
kbId,
currentTab: 'dataset'
}
})
}
/>
<Flex
className="textEllipsis"
flex={'1 0 0'}
mr={[3, 5]}
fontSize={['sm', 'md']}
alignItems={'center'}
>
<Image src={fileIcon || '/imgs/files/file.svg'} w={'16px'} mr={2} alt={''} />
{t(fileInfo?.filename || 'Filename')}
</Flex>
<Box>
<MyTooltip label={'刷新'}>
<IconButton
icon={<RepeatIcon />}
size={['sm', 'md']}
aria-label={'refresh'}
variant={'base'}
isLoading={isLoading}
onClick={() => {
getData(pageNum);
getTrainingData({ kbId, init: true });
}}
/>
</MyTooltip>
</Box>
</Flex>
<Flex my={3} alignItems={'center'}>
<Box>
<Box as={'span'} fontSize={['md', 'lg']}>
{total}
</Box>
</Box>
<Box flex={1} mr={1} />
<MyInput
leftIcon={
<MyIcon name="searchLight" position={'absolute'} w={'14px'} color={'myGray.500'} />
}
w={['200px', '300px']}
placeholder="根据匹配知识,预期答案和来源进行搜索"
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
getFirstData();
}}
onBlur={() => {
if (searchText === lastSearch.current) return;
getFirstData();
}}
onKeyDown={(e) => {
if (searchText === lastSearch.current) return;
if (e.key === 'Enter') {
getFirstData();
}
}}
/>
</Flex>
<Grid
minH={'100px'}
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)']}
gridGap={4}
>
{kbDataList.map((item) => (
<Card
key={item.id}
cursor={'pointer'}
pt={3}
userSelect={'none'}
boxShadow={'none'}
_hover={{ boxShadow: 'lg', '& .delete': { display: 'flex' } }}
border={'1px solid '}
borderColor={'myGray.200'}
onClick={() =>
setEditInputData({
dataId: item.id,
...item
})
}
>
<Box
h={'95px'}
overflow={'hidden'}
wordBreak={'break-all'}
px={3}
py={1}
fontSize={'13px'}
>
<Box color={'myGray.1000'} mb={2}>
{item.q}
</Box>
<Box color={'myGray.600'}>{item.a}</Box>
</Box>
<Flex py={2} px={4} h={'36px'} alignItems={'flex-end'} fontSize={'sm'}>
<Box className={'textEllipsis'} flex={1} color={'myGray.500'}>
ID:{item.id}
</Box>
<IconButton
className="delete"
display={['flex', 'none']}
icon={<DeleteIcon />}
variant={'base'}
colorScheme={'gray'}
aria-label={'delete'}
size={'xs'}
borderRadius={'md'}
_hover={{ color: 'red.600' }}
isLoading={isDeleting}
onClick={(e) => {
e.stopPropagation();
openConfirm(async () => {
try {
setIsDeleting(true);
await delOneDatasetDataById(item.id);
getData(pageNum);
} catch (error) {
toast({
title: getErrText(error),
status: 'error'
});
}
setIsDeleting(false);
})();
}}
/>
</Flex>
</Card>
))}
</Grid>
{total > pageSize && (
<Flex mt={2} justifyContent={'center'}>
<Pagination />
</Flex>
)}
{total === 0 && (
<Flex flexDirection={'column'} alignItems={'center'} pt={'10vh'}>
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
<Box mt={2} color={'myGray.500'}>
</Box>
</Flex>
)}
{editInputData !== undefined && (
<InputModal
kbId={kbId}
defaultValues={editInputData}
onClose={() => setEditInputData(undefined)}
onSuccess={() => getData(pageNum)}
/>
)}
<ConfirmModal />
</Box>
);
};
export default React.memo(DataCard);

View File

@@ -0,0 +1,341 @@
import React, { useCallback, useState, useRef, useMemo } from 'react';
import {
Box,
Flex,
TableContainer,
Table,
Thead,
Tr,
Th,
Td,
Tbody,
Image,
MenuButton
} from '@chakra-ui/react';
import { getTrainingData } from '@/api/core/dataset/data';
import { getDatasetFiles, delDatasetFileById, updateDatasetFile } from '@/api/core/dataset/file';
import { useQuery } from '@tanstack/react-query';
import { debounce } from 'lodash';
import { formatFileSize } from '@/utils/tools';
import { useConfirm } from '@/hooks/useConfirm';
import { useTranslation } from 'react-i18next';
import MyIcon from '@/components/Icon';
import MyInput from '@/components/MyInput';
import dayjs from 'dayjs';
import { fileImgs } from '@/constants/common';
import { useRequest } from '@/hooks/useRequest';
import { useLoading } from '@/hooks/useLoading';
import { FileStatusEnum, OtherFileId } from '@/constants/dataset';
import { useRouter } from 'next/router';
import { usePagination } from '@/hooks/usePagination';
import type { DatasetFileItemType } from '@/types/core/dataset/file';
import { useGlobalStore } from '@/store/global';
import MyMenu from '@/components/MyMenu';
import { useEditTitle } from '@/hooks/useEditTitle';
const FileCard = ({ kbId }: { kbId: string }) => {
const BoxRef = useRef<HTMLDivElement>(null);
const lastSearch = useRef('');
const router = useRouter();
const { t } = useTranslation();
const { Loading } = useLoading();
const [searchText, setSearchText] = useState('');
const { setLoading } = useGlobalStore();
const { openConfirm, ConfirmModal } = useConfirm({
content: t('kb.Confirm to delete the file')
});
const {
data: files,
Pagination,
total,
getData,
isLoading,
pageNum,
pageSize
} = usePagination<DatasetFileItemType>({
api: getDatasetFiles,
pageSize: 20,
params: {
kbId,
searchText
},
onChange() {
if (BoxRef.current) {
BoxRef.current.scrollTop = 0;
}
}
});
// change search
const debounceRefetch = useCallback(
debounce(() => {
getData(1);
lastSearch.current = searchText;
}, 300),
[]
);
// add file icon
const formatFiles = useMemo(
() =>
files.map((file) => ({
...file,
icon: fileImgs.find((item) => new RegExp(item.suffix, 'gi').test(file.filename))?.src
})),
[files]
);
const { mutate: onDeleteFile } = useRequest({
mutationFn: (fileId: string) => {
setLoading(true);
return delDatasetFileById({
fileId,
kbId
});
},
onSuccess() {
getData(pageNum);
},
onSettled() {
setLoading(false);
},
successToast: t('common.Delete Success'),
errorToast: t('common.Delete Failed')
});
const { mutate: onUpdateFilename } = useRequest({
mutationFn: (data: { id: string; name: string }) => {
setLoading(true);
return updateDatasetFile(data);
},
onSuccess() {
getData(pageNum);
},
onSettled() {
setLoading(false);
},
successToast: t('common.Delete Success'),
errorToast: t('common.Delete Failed')
});
const { onOpenModal, EditModal: EditTitleModal } = useEditTitle({
title: t('Rename')
});
const statusMap = {
[FileStatusEnum.embedding]: {
color: 'myGray.500',
text: t('file.Embedding')
},
[FileStatusEnum.ready]: {
color: 'green.500',
text: t('file.Ready')
}
};
// training data
const { data: { qaListLen = 0, vectorListLen = 0 } = {}, refetch: refetchTrainingData } =
useQuery(['getModelSplitDataList', kbId], () => getTrainingData({ kbId, init: false }), {
onError(err) {
console.log(err);
}
});
useQuery(
['refetchTrainingData', kbId],
() => Promise.all([refetchTrainingData(), getData(pageNum)]),
{
refetchInterval: 8000,
enabled: qaListLen > 0 || vectorListLen > 0
}
);
return (
<Box ref={BoxRef} py={[1, 5]} h={'100%'} overflow={'overlay'}>
<Flex justifyContent={'space-between'} px={[2, 5]}>
<Box>
<Box fontWeight={'bold'} fontSize={['md', 'lg']} mr={2}>
{t('kb.Files', { total })}
</Box>
<Box as={'span'} fontSize={'sm'}>
{(qaListLen > 0 || vectorListLen > 0) && (
<>
({qaListLen > 0 ? `${qaListLen}条数据正在拆分,` : ''}
{vectorListLen > 0 ? `${vectorListLen}条数据正在生成索引,` : ''}
... )
</>
)}
</Box>
</Box>
<Flex alignItems={'center'}>
<MyInput
leftIcon={
<MyIcon name="searchLight" position={'absolute'} w={'14px'} color={'myGray.500'} />
}
w={['100%', '250px']}
size={['sm', 'md']}
placeholder={t('common.Search') || ''}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
debounceRefetch();
}}
onBlur={() => {
if (searchText === lastSearch.current) return;
getData(1);
}}
onKeyDown={(e) => {
if (searchText === lastSearch.current) return;
if (e.key === 'Enter') {
getData(1);
}
}}
/>
</Flex>
</Flex>
<TableContainer mt={[0, 3]} position={'relative'} minH={'70vh'}>
<Table variant={'simple'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('kb.Filename')}</Th>
<Th>{t('kb.Chunk Length')}</Th>
<Th>{t('kb.Upload Time')}</Th>
<Th>{t('kb.File Size')}</Th>
<Th>{t('common.Status')}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{formatFiles.map((file) => (
<Tr
key={file.id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
title={'点击查看数据详情'}
onClick={() =>
router.replace({
query: {
kbId,
fileId: file.id,
currentTab: 'dataCard'
}
})
}
>
<Td>
<Flex alignItems={'center'}>
<Image src={file.icon} w={'16px'} mr={2} alt={''} />
<Box maxW={['300px', '400px']} className="textEllipsis">
{t(file.filename)}
</Box>
</Flex>
</Td>
<Td fontSize={'md'} fontWeight={'bold'}>
{file.chunkLength}
</Td>
<Td>{dayjs(file.uploadTime).format('YYYY/MM/DD HH:mm')}</Td>
<Td>{formatFileSize(file.size)}</Td>
<Td>
<Flex
alignItems={'center'}
_before={{
content: '""',
w: '10px',
h: '10px',
mr: 2,
borderRadius: 'lg',
bg: statusMap[file.status].color
}}
>
{statusMap[file.status].text}
</Flex>
</Td>
<Td onClick={(e) => e.stopPropagation()}>
<MyMenu
width={100}
Button={
<MenuButton
w={'22px'}
h={'22px'}
borderRadius={'md'}
_hover={{
color: 'myBlue.600',
'& .icon': {
bg: 'myGray.100'
}
}}
>
<MyIcon
className="icon"
name={'more'}
h={'16px'}
w={'16px'}
px={1}
py={1}
borderRadius={'md'}
cursor={'pointer'}
/>
</MenuButton>
}
menuList={[
...(file.id !== OtherFileId
? [
{
child: (
<Flex alignItems={'center'}>
<MyIcon name={'edit'} w={'14px'} mr={2} />
{t('Rename')}
</Flex>
),
onClick: () =>
onOpenModal({
defaultVal: file.filename,
onSuccess: (newName) => {
onUpdateFilename({
id: file.id,
name: newName
});
}
})
}
]
: []),
{
child: (
<Flex alignItems={'center'}>
<MyIcon
mr={1}
name={'delete'}
w={'14px'}
_hover={{ color: 'red.600' }}
/>
<Box>{t('common.Delete')}</Box>
</Flex>
),
onClick: () =>
openConfirm(() => {
onDeleteFile(file.id);
})()
}
]}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Loading loading={isLoading && files.length === 0} fixed={false} />
{total > pageSize && (
<Flex mt={2} justifyContent={'center'}>
<Pagination />
</Flex>
)}
</TableContainer>
<ConfirmModal />
<EditTitleModal />
</Box>
);
};
export default React.memo(FileCard);

View File

@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { Box, type BoxProps, Flex, Textarea, useTheme } from '@chakra-ui/react';
import MyRadio from '@/components/Radio/index';
import dynamic from 'next/dynamic';
import ManualImport from './Import/Manual';
const ChunkImport = dynamic(() => import('./Import/Chunk'), {
ssr: true
});
const QAImport = dynamic(() => import('./Import/QA'), {
ssr: true
});
const CsvImport = dynamic(() => import('./Import/Csv'), {
ssr: true
});
enum ImportTypeEnum {
manual = 'manual',
index = 'index',
qa = 'qa',
csv = 'csv'
}
const ImportData = ({ kbId }: { kbId: string }) => {
const theme = useTheme();
const [importType, setImportType] = useState<`${ImportTypeEnum}`>(ImportTypeEnum.manual);
const TitleStyle: BoxProps = {
fontWeight: 'bold',
fontSize: ['md', 'xl'],
mb: [3, 5]
};
return (
<Flex flexDirection={'column'} h={'100%'} pt={[1, 5]}>
<Box {...TitleStyle} px={[4, 8]}>
</Box>
<Box pb={[5, 7]} px={[4, 8]} borderBottom={theme.borders.base}>
<MyRadio
gridTemplateColumns={['repeat(1,1fr)', 'repeat(2, 350px)']}
list={[
{
icon: 'manualImport',
title: '手动输入',
desc: '手动输入问答对,是最精准的数据',
value: ImportTypeEnum.manual
},
{
icon: 'indexImport',
title: '直接分段',
desc: '选择文本文件,直接将其按分段进行处理',
value: ImportTypeEnum.index
},
{
icon: 'qaImport',
title: 'QA拆分',
desc: '选择文本文件,让大模型自动生成问答对',
value: ImportTypeEnum.qa
},
{
icon: 'csvImport',
title: 'CSV 导入',
desc: '批量导入问答对,是最精准的数据',
value: ImportTypeEnum.csv
}
]}
value={importType}
onChange={(e) => setImportType(e as `${ImportTypeEnum}`)}
/>
</Box>
<Box flex={'1 0 0'} h={0}>
{importType === ImportTypeEnum.manual && <ManualImport kbId={kbId} />}
{importType === ImportTypeEnum.index && <ChunkImport kbId={kbId} />}
{importType === ImportTypeEnum.qa && <QAImport kbId={kbId} />}
{importType === ImportTypeEnum.csv && <CsvImport kbId={kbId} />}
</Box>
</Flex>
);
};
export default ImportData;

View File

@@ -0,0 +1,433 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Box,
Flex,
Button,
useTheme,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Image
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useConfirm } from '@/hooks/useConfirm';
import { useRouter } from 'next/router';
import { useMutation } from '@tanstack/react-query';
import { splitText2Chunks } from '@/utils/file';
import { getErrText } from '@/utils/tools';
import { formatPrice } from '@fastgpt/common/bill/index';
import MyIcon from '@/components/Icon';
import CloseIcon from '@/components/Icon/close';
import DeleteIcon, { hoverDeleteStyles } from '@/components/Icon/delete';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { TrainingModeEnum } from '@/constants/plugin';
import FileSelect, { type FileItemType } from './FileSelect';
import { useDatasetStore } from '@/store/dataset';
import { putMarkFilesUsed } from '@/api/core/dataset/file';
import { chunksUpload } from '@/utils/web/core/dataset';
const fileExtension = '.txt, .doc, .docx, .pdf, .md';
const ChunkImport = ({ kbId }: { kbId: string }) => {
const { kbDetail } = useDatasetStore();
const vectorModel = kbDetail.vectorModel;
const unitPrice = vectorModel?.price || 0.2;
const theme = useTheme();
const router = useRouter();
const { toast } = useToast();
const [chunkLen, setChunkLen] = useState(vectorModel?.defaultToken || 300);
const [showRePreview, setShowRePreview] = useState(false);
const [files, setFiles] = useState<FileItemType[]>([]);
const [previewFile, setPreviewFile] = useState<FileItemType>();
const [successChunks, setSuccessChunks] = useState(0);
const totalChunk = useMemo(
() => files.reduce((sum, file) => sum + file.chunks.length, 0),
[files]
);
const emptyFiles = useMemo(() => files.length === 0, [files]);
// price count
const price = useMemo(() => {
return formatPrice(files.reduce((sum, file) => sum + file.tokens, 0) * unitPrice);
}, [files, unitPrice]);
const { openConfirm, ConfirmModal } = useConfirm({
content: `该任务无法终止,需要一定时间生成索引,请确认导入。如果余额不足,未完成的任务会被暂停,充值后可继续进行。`
});
const { mutate: onclickUpload, isLoading: uploading } = useMutation({
mutationFn: async () => {
const chunks = files.map((file) => file.chunks).flat();
// mark the file is used
await putMarkFilesUsed({ fileIds: files.map((file) => file.id) });
// upload data
const { insertLen } = await chunksUpload({
kbId,
chunks,
mode: TrainingModeEnum.index,
onUploading: (insertLen) => {
setSuccessChunks(insertLen);
}
});
toast({
title: `去重后共导入 ${insertLen} 条数据,请耐心等待训练.`,
status: 'success'
});
router.replace({
query: {
kbId,
currentTab: 'dataset'
}
});
},
onError(err) {
toast({
title: getErrText(err, '导入文件失败'),
status: 'error'
});
}
});
const onRePreview = useCallback(async () => {
try {
setFiles((state) =>
state.map((file) => {
const splitRes = splitText2Chunks({
text: file.text,
maxLen: chunkLen
});
return {
...file,
tokens: splitRes.tokens,
chunks: splitRes.chunks.map((chunk) => ({
a: '',
source: file.filename,
file_id: file.id,
q: chunk
}))
};
})
);
setPreviewFile(undefined);
setShowRePreview(false);
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, '文本分段异常')
});
}
}, [chunkLen, toast]);
const filenameStyles = {
className: 'textEllipsis',
maxW: '400px'
};
return (
<Box display={['block', 'flex']} h={['auto', '100%']} overflow={'overlay'}>
<Flex
flexDirection={'column'}
flex={'1 0 0'}
h={'100%'}
minW={['auto', '400px']}
w={['100%', 0]}
p={[4, 8]}
>
<FileSelect
fileExtension={fileExtension}
onPushFiles={(files) => {
setFiles((state) => files.concat(state));
}}
chunkLen={chunkLen}
py={emptyFiles ? '100px' : 5}
/>
{!emptyFiles && (
<>
<Box py={4} px={2} minH={['auto', '100px']} maxH={'400px'} overflow={'auto'}>
{files.map((item) => (
<Flex
key={item.id}
w={'100%'}
_notLast={{ mb: 5 }}
px={5}
py={2}
boxShadow={'1px 1px 5px rgba(0,0,0,0.15)'}
borderRadius={'md'}
cursor={'pointer'}
position={'relative'}
alignItems={'center'}
_hover={{
bg: 'myBlue.100',
'& .delete': {
display: 'block'
}
}}
onClick={() => setPreviewFile(item)}
>
<Image src={item.icon} w={'16px'} alt={''} />
<Box ml={2} flex={'1 0 0'} pr={3} {...filenameStyles}>
{item.filename}
</Box>
<MyIcon
position={'absolute'}
right={3}
className="delete"
name={'delete'}
w={'16px'}
_hover={{ color: 'red.600' }}
display={['block', 'none']}
onClick={(e) => {
e.stopPropagation();
setFiles((state) => state.filter((file) => file.id !== item.id));
}}
/>
</Flex>
))}
</Box>
{/* chunk size */}
<Flex py={5} alignItems={'center'}>
<Box>
<MyTooltip
label={
'按结束标点符号进行分段。前后段落会有 30% 的内容重叠。\n中文文档建议不要超过800英文不要超过1500'
}
forceShow
>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Box
flex={1}
css={{
'& > span': {
display: 'block'
}
}}
>
<MyTooltip label={`范围: 100~${kbDetail.vectorModel.maxToken}`}>
<NumberInput
ml={4}
defaultValue={chunkLen}
min={100}
max={kbDetail.vectorModel.maxToken}
step={10}
onChange={(e) => {
setChunkLen(+e);
setShowRePreview(true);
}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</MyTooltip>
</Box>
</Flex>
{/* price */}
<Flex py={5} alignItems={'center'}>
<Box>
<MyTooltip
label={`索引生成计费为: ${formatPrice(unitPrice, 1000)}/1k tokens`}
forceShow
>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Box ml={4}>{price}</Box>
</Flex>
<Flex mt={3}>
{showRePreview && (
<Button variant={'base'} mr={4} onClick={onRePreview}>
</Button>
)}
<Button isDisabled={uploading} onClick={openConfirm(onclickUpload)}>
{uploading ? (
<Box>{Math.round((successChunks / totalChunk) * 100)}%</Box>
) : (
'确认导入'
)}
</Button>
</Flex>
</>
)}
</Flex>
{!emptyFiles && (
<Box flex={'2 0 0'} w={['100%', 0]} h={'100%'}>
{previewFile ? (
<Box
position={'relative'}
display={['block', 'flex']}
h={'100%'}
flexDirection={'column'}
pt={[4, 8]}
bg={'myWhite.400'}
>
<Box px={[4, 8]} fontSize={['lg', 'xl']} fontWeight={'bold'} {...filenameStyles}>
{previewFile.filename}
</Box>
<CloseIcon
position={'absolute'}
right={[4, 8]}
top={4}
onClick={() => setPreviewFile(undefined)}
/>
<Box
flex={'1 0 0'}
h={['auto', 0]}
overflow={'overlay'}
px={[4, 8]}
my={4}
contentEditable
dangerouslySetInnerHTML={{ __html: previewFile.text }}
fontSize={'sm'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
onBlur={(e) => {
// @ts-ignore
const val = e.target.innerText;
setShowRePreview(true);
setFiles((state) =>
state.map((file) =>
file.id === previewFile.id
? {
...file,
text: val
}
: file
)
);
}}
/>
</Box>
) : (
<Box h={'100%'} pt={[4, 8]} overflow={'overlay'}>
<Flex px={[4, 8]} alignItems={'center'}>
<Box fontSize={['lg', 'xl']} fontWeight={'bold'}>
({totalChunk})
</Box>
{totalChunk > 100 && (
<Box ml={2} fontSize={'sm'} color={'myhGray.500'}>
</Box>
)}
</Flex>
<Box px={[4, 8]} overflow={'overlay'}>
{files.map((file) =>
file.chunks.slice(0, 50).map((chunk, i) => (
<Box
key={i}
py={4}
bg={'myWhite.500'}
my={2}
borderRadius={'md'}
fontSize={'sm'}
_hover={{ ...hoverDeleteStyles }}
>
<Flex mb={1} px={4} userSelect={'none'}>
<Box
flexShrink={0}
px={3}
py={'1px'}
border={theme.borders.base}
borderRadius={'md'}
>
# {i + 1}
</Box>
<Box ml={2} fontSize={'sm'} color={'myhGray.500'} {...filenameStyles}>
{file.filename}
</Box>
<Box flex={1} />
<DeleteIcon
onClick={() => {
setFiles((state) =>
state.map((stateFile) =>
stateFile.id === file.id
? {
...file,
chunks: [
...file.chunks.slice(0, i),
...file.chunks.slice(i + 1)
]
}
: stateFile
)
);
}}
/>
</Flex>
<Box
px={4}
fontSize={'sm'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
contentEditable
dangerouslySetInnerHTML={{ __html: chunk.q }}
onBlur={(e) => {
// @ts-ignore
const val = e.target.innerText;
/* delete file */
if (val === '') {
setFiles((state) =>
state.map((stateFile) =>
stateFile.id === file.id
? {
...file,
chunks: [
...file.chunks.slice(0, i),
...file.chunks.slice(i + 1)
]
}
: stateFile
)
);
} else {
// update file
setFiles((stateFiles) =>
stateFiles.map((stateFile) =>
file.id === stateFile.id
? {
...stateFile,
chunks: stateFile.chunks.map((chunk, index) => ({
...chunk,
q: i === index ? val : chunk.q
}))
}
: stateFile
)
);
}
}}
/>
</Box>
))
)}
</Box>
</Box>
)}
</Box>
)}
<ConfirmModal />
</Box>
);
};
export default ChunkImport;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyModal from '@/components/MyModal';
import { Box, Input, Textarea, ModalBody, ModalFooter, Button } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
const CreateFileModal = ({
onClose,
onSuccess
}: {
onClose: () => void;
onSuccess: (e: { filename: string; content: string }) => void;
}) => {
const { t } = useTranslation();
const { register, handleSubmit } = useForm({
defaultValues: {
filename: '',
content: ''
}
});
return (
<MyModal title={t('file.Create File')} isOpen onClose={() => {}} w={'600px'} top={'15vh'}>
<ModalBody>
<Box mb={1} fontSize={'sm'}>
</Box>
<Input
mb={5}
{...register('filename', {
required: '文件名不能为空'
})}
/>
<Box mb={1} fontSize={'sm'}>
</Box>
<Textarea
{...register('content', {
required: '文件内容不能为空'
})}
rows={12}
whiteSpace={'nowrap'}
resize={'both'}
/>
</ModalBody>
<ModalFooter>
<Button variant={'base'} mr={4} onClick={onClose}>
</Button>
<Button
onClick={() => {
handleSubmit(onSuccess)();
onClose();
}}
>
</Button>
</ModalFooter>
</MyModal>
);
};
export default CreateFileModal;

View File

@@ -0,0 +1,222 @@
import React, { useState, useMemo } from 'react';
import { Box, Flex, Button, useTheme, Image } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useConfirm } from '@/hooks/useConfirm';
import { useMutation } from '@tanstack/react-query';
import { getErrText } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import DeleteIcon, { hoverDeleteStyles } from '@/components/Icon/delete';
import { TrainingModeEnum } from '@/constants/plugin';
import FileSelect, { type FileItemType } from './FileSelect';
import { useRouter } from 'next/router';
import { useDatasetStore } from '@/store/dataset';
import { putMarkFilesUsed } from '@/api/core/dataset/file';
import { chunksUpload } from '@/utils/web/core/dataset';
const fileExtension = '.csv';
const CsvImport = ({ kbId }: { kbId: string }) => {
const { kbDetail } = useDatasetStore();
const maxToken = kbDetail.vectorModel?.maxToken || 2000;
const theme = useTheme();
const router = useRouter();
const { toast } = useToast();
const [files, setFiles] = useState<FileItemType[]>([]);
const [successChunks, setSuccessChunks] = useState(0);
const totalChunk = useMemo(
() => files.reduce((sum, file) => sum + file.chunks.length, 0),
[files]
);
const emptyFiles = useMemo(() => files.length === 0, [files]);
const { openConfirm, ConfirmModal } = useConfirm({
content: `该任务无法终止,需要一定时间生成索引,请确认导入。如果余额不足,未完成的任务会被暂停,充值后可继续进行。`
});
const { mutate: onclickUpload, isLoading: uploading } = useMutation({
mutationFn: async () => {
// mark the file is used
await putMarkFilesUsed({ fileIds: files.map((file) => file.id) });
const chunks = files
.map((file) => file.chunks)
.flat()
.filter((item) => item?.q);
const filterChunks = chunks.filter((item) => item.q.length < maxToken * 1.5);
if (filterChunks.length !== chunks.length) {
toast({
title: `${chunks.length - filterChunks.length}条数据超出长度,已被过滤`,
status: 'info'
});
}
// upload data
const { insertLen } = await chunksUpload({
kbId,
chunks,
mode: TrainingModeEnum.index,
onUploading: (insertLen) => {
setSuccessChunks(insertLen);
}
});
toast({
title: `去重后共导入 ${insertLen} 条数据,请耐心等待训练.`,
status: 'success'
});
router.replace({
query: {
kbId,
currentTab: 'dataset'
}
});
},
onError(err) {
toast({
title: getErrText(err, '导入文件失败'),
status: 'error'
});
}
});
const filenameStyles = {
className: 'textEllipsis',
maxW: '400px'
};
return (
<Box display={['block', 'flex']} h={['auto', '100%']} overflow={'overlay'}>
<Flex
flexDirection={'column'}
flex={'1 0 0'}
h={'100%'}
minW={['auto', '400px']}
w={['100%', 0]}
p={[4, 8]}
>
<FileSelect
fileExtension={fileExtension}
tipText={
'file.If the imported file is garbled, please convert CSV to UTF-8 encoding format'
}
onPushFiles={(files) => setFiles((state) => files.concat(state))}
showUrlFetch={false}
showCreateFile={false}
py={emptyFiles ? '100px' : 5}
isCsv
/>
{!emptyFiles && (
<>
<Box py={4} minH={['auto', '100px']} px={2} maxH={'400px'} overflow={'auto'}>
{files.map((item) => (
<Flex
key={item.id}
w={'100%'}
_notLast={{ mb: 5 }}
px={5}
py={2}
boxShadow={'1px 1px 5px rgba(0,0,0,0.15)'}
borderRadius={'md'}
position={'relative'}
alignItems={'center'}
_hover={{ ...hoverDeleteStyles }}
>
<Image src={'/imgs/files/csv.svg'} w={'16px'} alt={''} />
<Box ml={2} flex={'1 0 0'} pr={3} {...filenameStyles}>
{item.filename}
</Box>
<MyIcon
position={'absolute'}
right={3}
className="delete"
name={'delete'}
w={'16px'}
_hover={{ color: 'red.600' }}
display={['block', 'none']}
onClick={(e) => {
e.stopPropagation();
setFiles((state) => state.filter((file) => file.id !== item.id));
}}
/>
</Flex>
))}
</Box>
<Flex mt={3}>
<Button isDisabled={uploading} onClick={openConfirm(onclickUpload)}>
{uploading ? (
<Box>{Math.round((successChunks / totalChunk) * 100)}%</Box>
) : (
'确认导入'
)}
</Button>
</Flex>
</>
)}
</Flex>
{!emptyFiles && (
<Box flex={'2 0 0'} w={['100%', 0]} h={'100%'} pt={[4, 8]} overflow={'overlay'}>
<Flex px={[4, 8]} alignItems={'center'}>
<Box fontSize={['lg', 'xl']} fontWeight={'bold'}>
({totalChunk})
</Box>
{totalChunk > 100 && (
<Box ml={2} fontSize={'sm'} color={'myhGray.500'}>
</Box>
)}
</Flex>
<Box px={[4, 8]} overflow={'overlay'}>
{files.map((file) =>
file.chunks.slice(0, 100).map((item, i) => (
<Box
key={i}
py={4}
bg={'myWhite.500'}
my={2}
borderRadius={'md'}
fontSize={'sm'}
_hover={{ ...hoverDeleteStyles }}
>
<Flex mb={1} px={4} userSelect={'none'}>
<Box px={3} py={'1px'} border={theme.borders.base} borderRadius={'md'}>
# {i + 1}
</Box>
{item.source && <Box ml={1}>({item.source})</Box>}
<Box flex={1} />
<DeleteIcon
onClick={() => {
setFiles((state) =>
state.map((stateFile) =>
stateFile.id === file.id
? {
...file,
chunks: [...file.chunks.slice(0, i), ...file.chunks.slice(i + 1)]
}
: stateFile
)
);
}}
/>
</Flex>
<Box px={4} fontSize={'sm'} whiteSpace={'pre-wrap'} wordBreak={'break-all'}>
{`${item.q}\n${item.a}`}
</Box>
</Box>
))
)}
</Box>
</Box>
)}
<ConfirmModal />
</Box>
);
};
export default CsvImport;

View File

@@ -0,0 +1,393 @@
import MyIcon from '@/components/Icon';
import { useLoading } from '@/hooks/useLoading';
import { useSelectFile } from '@/hooks/useSelectFile';
import { useToast } from '@/hooks/useToast';
import { simpleText, splitText2Chunks } from '@/utils/file';
import {
uploadFiles,
fileDownload,
readCsvContent,
readTxtContent,
readPdfContent,
readDocContent
} from '@/utils/web/file';
import { Box, Flex, useDisclosure, type BoxProps } from '@chakra-ui/react';
import { fileImgs } from '@/constants/common';
import { DragEvent, useCallback, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { customAlphabet } from 'nanoid';
import dynamic from 'next/dynamic';
import MyTooltip from '@/components/MyTooltip';
import { FetchResultItem } from '@/types/plugin';
import type { DatasetDataItemType } from '@/types/core/dataset/data';
import { getErrText } from '@/utils/tools';
import { useDatasetStore } from '@/store/dataset';
const UrlFetchModal = dynamic(() => import('./UrlFetchModal'));
const CreateFileModal = dynamic(() => import('./CreateFileModal'));
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 12);
const csvTemplate = `index,content,source\n"被索引的内容","对应的答案。CSV 中请注意内容不能包含双引号,双引号是列分割符号","来源,可选。"\n"什么是 laf","laf 是一个云函数开发平台……",""\n"什么是 sealos","Sealos 是以 kubernetes 为内核的云操作系统发行版,可以……",""`;
export type FileItemType = {
id: string;
filename: string;
chunks: DatasetDataItemType[];
text: string;
icon: string;
tokens: number;
};
interface Props extends BoxProps {
fileExtension: string;
onPushFiles: (files: FileItemType[]) => void;
tipText?: string;
chunkLen?: number;
isCsv?: boolean;
showUrlFetch?: boolean;
showCreateFile?: boolean;
}
const FileSelect = ({
fileExtension,
onPushFiles,
tipText,
chunkLen = 500,
isCsv = false,
showUrlFetch = true,
showCreateFile = true,
...props
}: Props) => {
const { kbDetail } = useDatasetStore();
const { Loading: FileSelectLoading } = useLoading();
const { t } = useTranslation();
const { toast } = useToast();
const { File, onOpen } = useSelectFile({
fileType: fileExtension,
multiple: true
});
const [isDragging, setIsDragging] = useState(false);
const [selectingText, setSelectingText] = useState<string>();
const {
isOpen: isOpenUrlFetch,
onOpen: onOpenUrlFetch,
onClose: onCloseUrlFetch
} = useDisclosure();
const {
isOpen: isOpenCreateFile,
onOpen: onOpenCreateFile,
onClose: onCloseCreateFile
} = useDisclosure();
const onSelectFile = useCallback(
async (files: File[]) => {
try {
// Parse file by file
const chunkFiles: FileItemType[] = [];
for await (let file of files) {
const extension = file?.name?.split('.')?.pop()?.toLowerCase();
/* text file */
const icon = fileImgs.find((item) => new RegExp(item.suffix, 'gi').test(file.name))?.src;
if (!icon) {
continue;
}
// parse and upload files
let [text, filesId] = await Promise.all([
(async () => {
switch (extension) {
case 'txt':
case 'md':
return readTxtContent(file);
case 'pdf':
return readPdfContent(file);
case 'doc':
case 'docx':
return readDocContent(file);
}
return '';
})(),
uploadFiles([file], { kbId: kbDetail._id }, (percent) => {
if (percent < 100) {
setSelectingText(
t('file.Uploading', { name: file.name.slice(0, 20), percent }) || ''
);
} else {
setSelectingText(t('file.Parse', { name: file.name.slice(0, 20) }) || '');
}
})
]);
if (text) {
text = simpleText(text);
const splitRes = splitText2Chunks({
text,
maxLen: chunkLen
});
const fileItem: FileItemType = {
id: filesId[0],
filename: file.name,
icon,
text,
tokens: splitRes.tokens,
chunks: splitRes.chunks.map((chunk) => ({
q: chunk,
a: '',
source: file.name,
file_id: filesId[0]
}))
};
chunkFiles.unshift(fileItem);
continue;
}
/* csv file */
if (extension === 'csv') {
const { header, data } = await readCsvContent(file);
if (header[0] !== 'index' || header[1] !== 'content') {
throw new Error('csv 文件格式有误,请确保 index 和 content 两列');
}
const fileItem: FileItemType = {
id: filesId[0],
filename: file.name,
icon,
tokens: 0,
text: '',
chunks: data
.filter((item) => item[0])
.map((item) => ({
q: item[0] || '',
a: item[1] || '',
source: item[2] || file.name || '',
file_id: filesId[0]
}))
};
chunkFiles.unshift(fileItem);
}
}
onPushFiles(chunkFiles);
} catch (error: any) {
console.log(error);
toast({
title: getErrText(error, '解析文件失败'),
status: 'error'
});
}
setSelectingText(undefined);
},
[chunkLen, kbDetail._id, onPushFiles, t, toast]
);
const onUrlFetch = useCallback(
(e: FetchResultItem[]) => {
const result = e.map(({ url, content }) => {
const splitRes = splitText2Chunks({
text: content,
maxLen: chunkLen
});
return {
id: nanoid(),
filename: url,
icon: '/imgs/files/url.svg',
text: content,
tokens: splitRes.tokens,
chunks: splitRes.chunks.map((chunk) => ({
q: chunk,
a: '',
source: url
}))
};
});
onPushFiles(result);
},
[chunkLen, onPushFiles]
);
const onCreateFile = useCallback(
({ filename, content }: { filename: string; content: string }) => {
content = simpleText(content);
const splitRes = splitText2Chunks({
text: content,
maxLen: chunkLen
});
onPushFiles([
{
id: nanoid(),
filename,
icon: '/imgs/files/txt.svg',
text: content,
tokens: splitRes.tokens,
chunks: splitRes.chunks.map((chunk) => ({
q: chunk,
a: '',
source: filename
}))
}
]);
},
[chunkLen, onPushFiles]
);
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = useCallback(
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const items = e.dataTransfer.items;
const fileList: File[] = [];
if (e.dataTransfer.items.length <= 1) {
const traverseFileTree = async (item: any) => {
return new Promise<void>((resolve, reject) => {
if (item.isFile) {
item.file((file: File) => {
fileList.push(file);
resolve();
});
} else if (item.isDirectory) {
const dirReader = item.createReader();
dirReader.readEntries(async (entries: any[]) => {
for (let i = 0; i < entries.length; i++) {
await traverseFileTree(entries[i]);
}
resolve();
});
}
});
};
for (let i = 0; i < items.length; i++) {
const item = items[i].webkitGetAsEntry();
if (item) {
await traverseFileTree(item);
}
}
} else {
const files = Array.from(e.dataTransfer.files);
let isErr = files.some((item) => item.type === '');
if (isErr) {
return toast({
title: t('file.upload error description'),
status: 'error'
});
}
for (let i = 0; i < files.length; i++) {
fileList.push(files[i]);
}
}
onSelectFile(fileList);
},
[onSelectFile, t, toast]
);
const SelectTextStyles: BoxProps = {
ml: 1,
as: 'span',
cursor: 'pointer',
color: 'myBlue.700',
_hover: {
textDecoration: 'underline'
}
};
return (
<Box
display={'inline-block'}
textAlign={'center'}
bg={'myWhite.400'}
p={5}
borderRadius={'lg'}
border={'1px dashed'}
borderColor={'myGray.300'}
w={'100%'}
position={'relative'}
{...props}
onDragEnter={handleDragEnter}
onDragOver={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Flex justifyContent={'center'} alignItems={'center'}>
<MyIcon mr={1} name={'uploadFile'} w={'16px'} />
{isDragging ? (
t('file.Release the mouse to upload the file')
) : (
<Box>
{t('file.Drag and drop')},
<MyTooltip label={t('file.max 10')}>
<Box {...SelectTextStyles} onClick={onOpen}>
{t('file.select a document')}
</Box>
</MyTooltip>
{showUrlFetch && (
<>
,
<Box {...SelectTextStyles} onClick={onOpenUrlFetch}>
{t('file.Fetch Url')}
</Box>
</>
)}
{showCreateFile && (
<>
,
<Box {...SelectTextStyles} onClick={onOpenCreateFile}>
{t('file.Create file')}
</Box>
</>
)}
</Box>
)}
</Flex>
<Box mt={1}>{t('file.support', { fileExtension: fileExtension })}</Box>
{tipText && (
<Box mt={1} fontSize={'sm'} color={'myGray.600'}>
{t(tipText)}
</Box>
)}
{isCsv && (
<Box
mt={1}
cursor={'pointer'}
textDecoration={'underline'}
color={'myBlue.600'}
fontSize={'12px'}
onClick={() =>
fileDownload({
text: csvTemplate,
type: 'text/csv',
filename: 'template.csv'
})
}
>
{t('file.Click to download CSV template')}
</Box>
)}
{selectingText !== undefined && (
<FileSelectLoading loading text={selectingText} fixed={false} />
)}
<File onSelect={onSelectFile} />
{isOpenUrlFetch && <UrlFetchModal onClose={onCloseUrlFetch} onSuccess={onUrlFetch} />}
{isOpenCreateFile && <CreateFileModal onClose={onCloseCreateFile} onSuccess={onCreateFile} />}
</Box>
);
};
export default FileSelect;

View File

@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { Box, Textarea, Button, Flex } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { useToast } from '@/hooks/useToast';
import { useRequest } from '@/hooks/useRequest';
import { getErrText } from '@/utils/tools';
import { postChunks2Dataset } from '@/api/core/dataset/data';
import { TrainingModeEnum } from '@/constants/plugin';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { useDatasetStore } from '@/store/dataset';
type ManualFormType = { q: string; a: string };
const ManualImport = ({ kbId }: { kbId: string }) => {
const { kbDetail } = useDatasetStore();
const maxToken = kbDetail.vectorModel?.maxToken || 2000;
const { register, handleSubmit, reset } = useForm({
defaultValues: { q: '', a: '' }
});
const { toast } = useToast();
const [qLen, setQLen] = useState(0);
const { mutate: onImportData, isLoading } = useRequest({
mutationFn: async (e: ManualFormType) => {
if (e.a.length + e.q.length >= 3000) {
toast({
title: '总长度超长了',
status: 'warning'
});
return;
}
try {
const data = {
a: e.a,
q: e.q,
source: '手动录入'
};
const { insertLen } = await postChunks2Dataset({
kbId,
mode: TrainingModeEnum.index,
data: [data]
});
if (insertLen === 0) {
toast({
title: '已存在完全一致的数据',
status: 'warning'
});
} else {
toast({
title: '导入数据成功,需要一段时间训练',
status: 'success'
});
reset({
a: '',
q: ''
});
}
} catch (err: any) {
toast({
title: getErrText(err, '出现了点意外~'),
status: 'error'
});
}
}
});
return (
<Box p={[4, 8]} h={'100%'} overflow={'overlay'}>
<Box display={'flex'} flexDirection={['column', 'row']}>
<Box flex={1} mr={[0, 4]} mb={[4, 0]} h={['50%', '100%']} position={'relative'}>
<Flex>
<Box h={'30px'}>{'匹配的知识点'}</Box>
<MyTooltip label={'被向量化的部分,通常是问题,也可以是一段陈述描述'}>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Flex>
<Textarea
placeholder={`匹配的知识点。这部分内容会被搜索,请把控内容的质量。最多 ${maxToken} 字。`}
maxLength={maxToken}
h={['250px', '500px']}
{...register(`q`, {
required: true,
onChange(e) {
setQLen(e.target.value.length);
}
})}
/>
<Box position={'absolute'} color={'myGray.500'} right={5} bottom={3} zIndex={99}>
{qLen}
</Box>
</Box>
<Box flex={1} h={['50%', '100%']}>
<Flex>
<Box h={'30px'}>{'预期答案'}</Box>
<MyTooltip
label={'匹配的知识点被命中后,这部分内容会随匹配知识点一起注入模型,引导模型回答'}
>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Flex>
<Textarea
placeholder={
'预期答案。这部分内容不会被搜索,但会作为"匹配的知识点"的内容补充,通常是问题的答案。总和最多 3000 字。'
}
h={['250px', '500px']}
maxLength={3000}
{...register('a')}
/>
</Box>
</Box>
<Button mt={5} isLoading={isLoading} onClick={handleSubmit((data) => onImportData(data))}>
</Button>
</Box>
);
};
export default React.memo(ManualImport);

View File

@@ -0,0 +1,404 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Box, Flex, Button, useTheme, Image, Input } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useConfirm } from '@/hooks/useConfirm';
import { useMutation } from '@tanstack/react-query';
import { splitText2Chunks } from '@/utils/file';
import { getErrText } from '@/utils/tools';
import { formatPrice } from '@fastgpt/common/bill/index';
import { qaModel } from '@/store/static';
import MyIcon from '@/components/Icon';
import CloseIcon from '@/components/Icon/close';
import DeleteIcon, { hoverDeleteStyles } from '@/components/Icon/delete';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon, InfoOutlineIcon } from '@chakra-ui/icons';
import { TrainingModeEnum } from '@/constants/plugin';
import FileSelect, { type FileItemType } from './FileSelect';
import { useRouter } from 'next/router';
import { putMarkFilesUsed } from '@/api/core/dataset/file';
import { Prompt_AgentQA } from '@/prompts/core/agent';
import { replaceVariable } from '@/utils/common/tools/text';
import { chunksUpload } from '@/utils/web/core/dataset';
const fileExtension = '.txt, .doc, .docx, .pdf, .md';
const QAImport = ({ kbId }: { kbId: string }) => {
const unitPrice = qaModel.price || 3;
const chunkLen = qaModel.maxToken * 0.45;
const theme = useTheme();
const router = useRouter();
const { toast } = useToast();
const [files, setFiles] = useState<FileItemType[]>([]);
const [showRePreview, setShowRePreview] = useState(false);
const [previewFile, setPreviewFile] = useState<FileItemType>();
const [successChunks, setSuccessChunks] = useState(0);
const [prompt, setPrompt] = useState('');
const totalChunk = useMemo(
() => files.reduce((sum, file) => sum + file.chunks.length, 0),
[files]
);
const emptyFiles = useMemo(() => files.length === 0, [files]);
// price count
const price = useMemo(() => {
const filesToken = files.reduce((sum, file) => sum + file.tokens, 0);
const promptTokens = files.reduce((sum, file) => sum + file.chunks.length, 0) * 139;
const totalToken = (filesToken + promptTokens) * 2;
return formatPrice(totalToken * unitPrice);
}, [files, unitPrice]);
const { openConfirm, ConfirmModal } = useConfirm({
content: `该任务无法终止!导入后会自动调用大模型生成问答对,会有一些细节丢失,请确认!如果余额不足,未完成的任务会被暂停。`
});
const previewQAPrompt = useMemo(() => {
return replaceVariable(Prompt_AgentQA.prompt, {
theme: prompt || Prompt_AgentQA.defaultTheme
});
}, [prompt]);
const { mutate: onclickUpload, isLoading: uploading } = useMutation({
mutationFn: async () => {
const chunks = files.map((file) => file.chunks).flat();
// mark the file is used
await putMarkFilesUsed({ fileIds: files.map((file) => file.id) });
// upload data
const { insertLen } = await chunksUpload({
kbId,
chunks,
mode: TrainingModeEnum.qa,
prompt: previewQAPrompt,
rate: 10,
onUploading: (insertLen) => {
setSuccessChunks(insertLen);
}
});
toast({
title: `共导入 ${insertLen} 条数据,请耐心等待训练.`,
status: 'success'
});
router.replace({
query: {
kbId,
currentTab: 'dataset'
}
});
},
onError(err) {
toast({
title: getErrText(err, '导入文件失败'),
status: 'error'
});
}
});
const onRePreview = useCallback(async () => {
try {
setFiles((state) =>
state.map((file) => {
const splitRes = splitText2Chunks({
text: file.text,
maxLen: chunkLen
});
return {
...file,
tokens: splitRes.tokens,
chunks: splitRes.chunks.map((chunk) => ({
a: '',
source: file.filename,
file_id: file.id,
q: chunk
}))
};
})
);
setPreviewFile(undefined);
setShowRePreview(false);
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, '文本分段异常')
});
}
}, [chunkLen, toast]);
const filenameStyles = {
className: 'textEllipsis',
maxW: '400px'
};
return (
<Box display={['block', 'flex']} h={['auto', '100%']} overflow={'overlay'}>
<Flex
flexDirection={'column'}
flex={'1 0 0'}
h={'100%'}
minW={['auto', '400px']}
w={['100%', 0]}
p={[4, 8]}
>
<FileSelect
fileExtension={fileExtension}
onPushFiles={(files) => {
setFiles((state) => files.concat(state));
}}
chunkLen={chunkLen}
py={emptyFiles ? '100px' : 5}
/>
{!emptyFiles && (
<>
<Box py={4} px={2} minH={['auto', '100px']} maxH={'400px'} overflow={'auto'}>
{files.map((item) => (
<Flex
key={item.id}
w={'100%'}
_notLast={{ mb: 5 }}
px={5}
py={2}
boxShadow={'1px 1px 5px rgba(0,0,0,0.15)'}
borderRadius={'md'}
cursor={'pointer'}
position={'relative'}
alignItems={'center'}
_hover={{
bg: 'myBlue.100',
'& .delete': {
display: 'block'
}
}}
onClick={() => setPreviewFile(item)}
>
<Image src={item.icon} w={'16px'} alt={''} />
<Box ml={2} flex={'1 0 0'} pr={3} {...filenameStyles}>
{item.filename}
</Box>
<MyIcon
position={'absolute'}
right={3}
className="delete"
name={'delete'}
w={'16px'}
_hover={{ color: 'red.600' }}
display={['block', 'none']}
onClick={(e) => {
e.stopPropagation();
setFiles((state) => state.filter((file) => file.id !== item.id));
}}
/>
</Flex>
))}
</Box>
{/* prompt */}
<Box py={5}>
<Box mb={2}>
QA {' '}
<MyTooltip label={previewQAPrompt} forceShow>
<InfoOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Flex alignItems={'center'} fontSize={'sm'}>
<Box mr={2}></Box>
<Input
fontSize={'sm'}
flex={1}
placeholder={Prompt_AgentQA.defaultTheme}
bg={'myWhite.500'}
defaultValue={prompt}
onChange={(e) => setPrompt(e.target.value || '')}
/>
</Flex>
</Box>
{/* price */}
<Flex py={5} alignItems={'center'}>
<Box>
<MyTooltip
label={`索引生成计费为: ${formatPrice(unitPrice, 1000)}/1k tokens`}
forceShow
>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Box ml={4}>{price}</Box>
</Flex>
<Flex mt={3}>
{showRePreview && (
<Button variant={'base'} mr={4} onClick={onRePreview}>
</Button>
)}
<Button isDisabled={uploading} onClick={openConfirm(onclickUpload)}>
{uploading ? (
<Box>{Math.round((successChunks / totalChunk) * 100)}%</Box>
) : (
'确认导入'
)}
</Button>
</Flex>
</>
)}
</Flex>
{!emptyFiles && (
<Box flex={'2 0 0'} w={['100%', 0]} h={'100%'}>
{previewFile ? (
<Box
position={'relative'}
display={['block', 'flex']}
h={'100%'}
flexDirection={'column'}
pt={[4, 8]}
bg={'myWhite.400'}
>
<Box px={[4, 8]} fontSize={['lg', 'xl']} fontWeight={'bold'} {...filenameStyles}>
{previewFile.filename}
</Box>
<CloseIcon
position={'absolute'}
right={[4, 8]}
top={4}
onClick={() => setPreviewFile(undefined)}
/>
<Box
flex={'1 0 0'}
h={['auto', 0]}
overflow={'overlay'}
px={[4, 8]}
my={4}
contentEditable
dangerouslySetInnerHTML={{ __html: previewFile.text }}
fontSize={'sm'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
onBlur={(e) => {
// @ts-ignore
const val = e.target.innerText;
setShowRePreview(true);
setFiles((state) =>
state.map((file) =>
file.id === previewFile.id
? {
...file,
text: val
}
: file
)
);
}}
/>
</Box>
) : (
<Box h={'100%'} pt={[4, 8]} overflow={'overlay'}>
<Flex px={[4, 8]} alignItems={'center'}>
<Box fontSize={['lg', 'xl']} fontWeight={'bold'}>
({totalChunk})
</Box>
{totalChunk > 100 && (
<Box ml={2} fontSize={'sm'} color={'myhGray.500'}>
</Box>
)}
</Flex>
<Box px={[4, 8]} overflow={'overlay'}>
{files.map((file) =>
file.chunks.slice(0, 30).map((chunk, i) => (
<Box
key={i}
py={4}
bg={'myWhite.500'}
my={2}
borderRadius={'md'}
fontSize={'sm'}
_hover={{ ...hoverDeleteStyles }}
>
<Flex mb={1} px={4} userSelect={'none'}>
<Box px={3} py={'1px'} border={theme.borders.base} borderRadius={'md'}>
# {i + 1}
</Box>
<Box ml={2} fontSize={'sm'} color={'myhGray.500'} {...filenameStyles}>
{file.filename}
</Box>
<Box flex={1} />
<DeleteIcon
onClick={() => {
setFiles((state) =>
state.map((stateFile) =>
stateFile.id === file.id
? {
...file,
chunks: [
...file.chunks.slice(0, i),
...file.chunks.slice(i + 1)
]
}
: stateFile
)
);
}}
/>
</Flex>
<Box
px={4}
fontSize={'sm'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
contentEditable
dangerouslySetInnerHTML={{ __html: chunk.q }}
onBlur={(e) => {
// @ts-ignore
const val = e.target.innerText;
/* delete file */
if (val === '') {
setFiles((state) =>
state.map((stateFile) =>
stateFile.id === file.id
? {
...file,
chunks: [
...file.chunks.slice(0, i),
...file.chunks.slice(i + 1)
]
}
: stateFile
)
);
} else {
// update file
setFiles((stateFiles) =>
stateFiles.map((stateFile) =>
file.id === stateFile.id
? {
...stateFile,
chunks: stateFile.chunks.map((chunk, index) => ({
...chunk,
q: i === index ? val : chunk.q
}))
}
: stateFile
)
);
}
}}
/>
</Box>
))
)}
</Box>
</Box>
)}
</Box>
)}
<ConfirmModal />
</Box>
);
};
export default QAImport;

View File

@@ -0,0 +1,67 @@
import React, { useRef } from 'react';
import { useTranslation } from 'next-i18next';
import MyModal from '@/components/MyModal';
import { Box, Button, ModalBody, ModalFooter, Textarea } from '@chakra-ui/react';
import type { FetchResultItem } from '@/types/plugin';
import { useRequest } from '@/hooks/useRequest';
import { fetchUrls } from '@/api/plugins/common';
const UrlFetchModal = ({
onClose,
onSuccess
}: {
onClose: () => void;
onSuccess: (e: FetchResultItem[]) => void;
}) => {
const { t } = useTranslation();
const Dom = useRef<HTMLTextAreaElement>(null);
const { mutate, isLoading } = useRequest({
mutationFn: async () => {
const val = Dom.current?.value || '';
const urls = val.split('\n').filter((e) => e);
const res = await fetchUrls(urls);
onSuccess(res);
onClose();
},
errorToast: '获取链接失败'
});
return (
<MyModal
title={
<>
<Box>{t('file.Fetch Url')}</Box>
<Box fontWeight={'normal'} fontSize={'sm'} color={'myGray.500'} mt={1}>
</Box>
</>
}
top={'15vh'}
isOpen
onClose={onClose}
w={'600px'}
>
<ModalBody>
<Textarea
ref={Dom}
rows={12}
whiteSpace={'nowrap'}
resize={'both'}
placeholder={'最多10个链接每行一个。'}
/>
</ModalBody>
<ModalFooter>
<Button variant={'base'} mr={4} onClick={onClose}>
</Button>
<Button isLoading={isLoading} onClick={mutate}>
</Button>
</ModalFooter>
</MyModal>
);
};
export default UrlFetchModal;

View File

@@ -0,0 +1,251 @@
import React, {
useCallback,
useState,
useRef,
forwardRef,
useImperativeHandle,
ForwardedRef
} from 'react';
import { useRouter } from 'next/router';
import { Box, Flex, Button, FormControl, IconButton, Input } from '@chakra-ui/react';
import { QuestionOutlineIcon, DeleteIcon } from '@chakra-ui/icons';
import { delDatasetById, putDatasetById } from '@/api/core/dataset';
import { useSelectFile } from '@/hooks/useSelectFile';
import { useToast } from '@/hooks/useToast';
import { useDatasetStore } from '@/store/dataset';
import { useConfirm } from '@/hooks/useConfirm';
import { UseFormReturn } from 'react-hook-form';
import { compressImg } from '@/utils/web/file';
import type { DatasetItemType } from '@/types/core/dataset';
import Avatar from '@/components/Avatar';
import Tag from '@/components/Tag';
import MyTooltip from '@/components/MyTooltip';
export interface ComponentRef {
initInput: (tags: string) => void;
}
const Info = (
{ kbId, form }: { kbId: string; form: UseFormReturn<DatasetItemType, any> },
ref: ForwardedRef<ComponentRef>
) => {
const { getValues, formState, setValue, register, handleSubmit } = form;
const InputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const router = useRouter();
const [btnLoading, setBtnLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const { openConfirm, ConfirmModal } = useConfirm({
content: '确认删除该知识库?数据将无法恢复,请确认!'
});
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const { kbDetail, getKbDetail, loadKbList } = useDatasetStore();
/* 点击删除 */
const onclickDelKb = useCallback(async () => {
setBtnLoading(true);
try {
await delDatasetById(kbId);
toast({
title: '删除成功',
status: 'success'
});
router.replace(`/kb/list`);
await loadKbList();
} catch (err: any) {
toast({
title: err?.message || '删除失败',
status: 'error'
});
}
setBtnLoading(false);
}, [setBtnLoading, kbId, toast, router, loadKbList]);
const saveSubmitSuccess = useCallback(
async (data: DatasetItemType) => {
setBtnLoading(true);
try {
await putDatasetById({
id: kbId,
...data
});
await getKbDetail(kbId, true);
toast({
title: '更新成功',
status: 'success'
});
loadKbList();
} catch (err: any) {
toast({
title: err?.message || '更新失败',
status: 'error'
});
}
setBtnLoading(false);
},
[getKbDetail, kbId, loadKbList, toast]
);
const saveSubmitError = useCallback(() => {
// deep search message
const deepSearch = (obj: any): string => {
if (!obj) return '提交表单错误';
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
toast({
title: deepSearch(formState.errors),
status: 'error',
duration: 4000,
isClosable: true
});
}, [formState.errors, toast]);
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
if (!file) return;
try {
const src = await compressImg({
file,
maxW: 100,
maxH: 100
});
setValue('avatar', src);
setRefresh((state) => !state);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : '头像选择异常',
status: 'warning'
});
}
},
[setRefresh, setValue, toast]
);
useImperativeHandle(ref, () => ({
initInput: (tags: string) => {
if (InputRef.current) {
InputRef.current.value = tags;
}
}
}));
return (
<Box py={5} px={[5, 10]}>
<Flex mt={5} w={'100%'} alignItems={'center'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
ID
</Box>
<Box flex={1}>{kbDetail._id}</Box>
</Flex>
<Flex mt={8} w={'100%'} alignItems={'center'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
</Box>
<Box flex={[1, '0 0 300px']}>{getValues('vectorModel').name}</Box>
</Flex>
<Flex mt={8} w={'100%'} alignItems={'center'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
MaxTokens
</Box>
<Box flex={[1, '0 0 300px']}>{getValues('vectorModel').maxToken}</Box>
</Flex>
<Flex mt={5} w={'100%'} alignItems={'center'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
</Box>
<Box flex={[1, '0 0 300px']}>
<MyTooltip label={'点击切换头像'}>
<Avatar
m={'auto'}
src={getValues('avatar')}
w={['32px', '40px']}
h={['32px', '40px']}
cursor={'pointer'}
onClick={onOpenSelectFile}
/>
</MyTooltip>
</Box>
</Flex>
<FormControl mt={8} w={'100%'} display={'flex'} alignItems={'center'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
</Box>
<Input
flex={[1, '0 0 300px']}
{...register('name', {
required: '知识库名称不能为空'
})}
/>
</FormControl>
<Flex mt={8} alignItems={'center'} w={'100%'} flexWrap={'wrap'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}>
<MyTooltip label={'用空格隔开多个标签,便于搜索'} forceShow>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Input
flex={[1, '0 0 300px']}
ref={InputRef}
defaultValue={getValues('tags')}
placeholder={'标签,使用空格分割。'}
maxLength={30}
onChange={(e) => {
setValue('tags', e.target.value);
setRefresh(!refresh);
}}
/>
<Flex w={'100%'} pl={['90px', '160px']} mt={2}>
{getValues('tags')
.split(' ')
.filter((item) => item)
.map((item, i) => (
<Tag mr={2} mb={2} key={i} whiteSpace={'nowrap'}>
{item}
</Tag>
))}
</Flex>
</Flex>
<Flex mt={5} w={'100%'} alignItems={'flex-end'}>
<Box flex={['0 0 90px', '0 0 160px']} w={0}></Box>
<Button
isLoading={btnLoading}
mr={4}
w={'100px'}
onClick={handleSubmit(saveSubmitSuccess, saveSubmitError)}
>
</Button>
<IconButton
isLoading={btnLoading}
icon={<DeleteIcon />}
aria-label={''}
variant={'outline'}
size={'sm'}
_hover={{
color: 'red.600',
borderColor: 'red.600'
}}
onClick={openConfirm(onclickDelKb)}
/>
</Flex>
<File onSelect={onSelectFile} />
<ConfirmModal />
</Box>
);
};
export default forwardRef(Info);

View File

@@ -0,0 +1,290 @@
import React, { useState, useCallback } from 'react';
import { Box, Flex, Button, Textarea, IconButton, BoxProps } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import {
postData2Dataset,
putDatasetDataById,
delOneDatasetDataById
} from '@/api/core/dataset/data';
import { useToast } from '@/hooks/useToast';
import { getErrText } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import MyModal from '@/components/MyModal';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { useQuery } from '@tanstack/react-query';
import { DatasetDataItemType } from '@/types/core/dataset/data';
import { useTranslation } from 'react-i18next';
import { useDatasetStore } from '@/store/dataset';
import { getFileAndOpen } from '@/utils/web/file';
export type FormData = { dataId?: string } & DatasetDataItemType;
const InputDataModal = ({
onClose,
onSuccess,
onDelete,
kbId,
defaultValues = {
a: '',
q: ''
}
}: {
onClose: () => void;
onSuccess: (data: FormData) => void;
onDelete?: () => void;
kbId: string;
defaultValues?: FormData;
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const { kbDetail, getKbDetail } = useDatasetStore();
const { getValues, register, handleSubmit, reset } = useForm<FormData>({
defaultValues
});
const maxToken = kbDetail.vectorModel?.maxToken || 2000;
/**
* 确认导入新数据
*/
const sureImportData = useCallback(
async (e: FormData) => {
if (e.q.length >= maxToken) {
toast({
title: '总长度超长了',
status: 'warning'
});
return;
}
setLoading(true);
try {
const data = {
dataId: '',
a: e.a,
q: e.q,
source: '手动录入'
};
data.dataId = await postData2Dataset({
kbId,
data
});
toast({
title: '导入数据成功,需要一段时间训练',
status: 'success'
});
reset({
a: '',
q: ''
});
onSuccess(data);
} catch (err: any) {
toast({
title: getErrText(err, '出现了点意外~'),
status: 'error'
});
}
setLoading(false);
},
[kbId, maxToken, onSuccess, reset, toast]
);
const updateData = useCallback(
async (e: FormData) => {
if (!e.dataId) return;
if (e.a !== defaultValues.a || e.q !== defaultValues.q) {
setLoading(true);
try {
const data = {
dataId: e.dataId,
kbId,
a: e.a,
q: e.q === defaultValues.q ? '' : e.q
};
await putDatasetDataById(data);
onSuccess(data);
} catch (err) {
toast({
status: 'error',
title: getErrText(err, '更新数据失败')
});
}
setLoading(false);
}
toast({
title: '修改数据成功',
status: 'success'
});
onClose();
},
[defaultValues.a, defaultValues.q, kbId, onClose, onSuccess, toast]
);
useQuery(['getKbDetail'], () => {
if (kbDetail._id === kbId) return null;
return getKbDetail(kbId);
});
return (
<MyModal
isOpen={true}
onClose={onClose}
isCentered
title={defaultValues.dataId ? '变更数据' : '手动导入数据'}
w={'90vw'}
maxW={'90vw'}
h={'90vh'}
>
<Flex flexDirection={'column'} h={'100%'}>
<Box
display={'flex'}
flexDirection={['column', 'row']}
flex={'1 0 0'}
h={['100%', 0]}
overflow={'overlay'}
px={6}
pb={2}
>
<Box flex={1} mr={[0, 4]} mb={[4, 0]} h={['50%', '100%']}>
<Flex>
<Box h={'30px'}>{'匹配的知识点'}</Box>
<MyTooltip label={'被向量化的部分,通常是问题,也可以是一段陈述描述'}>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Flex>
<Textarea
placeholder={`匹配的知识点。这部分内容会被搜索,请把控内容的质量,最多 ${maxToken} 字。`}
maxLength={maxToken}
resize={'none'}
h={'calc(100% - 30px)'}
{...register(`q`, {
required: true
})}
/>
</Box>
<Box flex={1} h={['50%', '100%']}>
<Flex>
<Box h={'30px'}>{'补充内容'}</Box>
<MyTooltip
label={'匹配的知识点被命中后,这部分内容会随匹配知识点一起注入模型,引导模型回答'}
>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Flex>
<Textarea
placeholder={
'这部分内容不会被搜索,但会作为"匹配的知识点"的内容补充,通常是问题的答案。'
}
resize={'none'}
h={'calc(100% - 30px)'}
{...register('a')}
/>
</Box>
</Box>
<Flex px={6} pt={['34px', 2]} pb={4} alignItems={'center'} position={'relative'}>
<RawFileText
fileId={getValues('file_id')}
filename={getValues('source')}
position={'absolute'}
left={'50%'}
top={['16px', '50%']}
transform={'translate(-50%,-50%)'}
/>
<Box flex={1}>
{defaultValues.dataId && onDelete && (
<IconButton
variant={'outline'}
icon={<MyIcon name={'delete'} w={'16px'} h={'16px'} />}
aria-label={''}
isLoading={loading}
size={'sm'}
_hover={{
color: 'red.600',
borderColor: 'red.600'
}}
onClick={async () => {
if (!onDelete || !defaultValues.dataId) return;
try {
await delOneDatasetDataById(defaultValues.dataId);
onDelete();
onClose();
toast({
status: 'success',
title: '记录已删除'
});
} catch (error) {
toast({
status: 'warning',
title: getErrText(error)
});
console.log(error);
}
}}
/>
)}
</Box>
<Box>
<Button variant={'base'} mr={3} isLoading={loading} onClick={onClose}>
</Button>
<Button
isLoading={loading}
onClick={handleSubmit(defaultValues.dataId ? updateData : sureImportData)}
>
{defaultValues.dataId ? '确认变更' : '确认导入'}
</Button>
</Box>
</Flex>
</Flex>
</MyModal>
);
};
export default InputDataModal;
interface RawFileTextProps extends BoxProps {
filename?: string;
fileId?: string;
}
export function RawFileText({ fileId, filename = '', ...props }: RawFileTextProps) {
const { t } = useTranslation();
const { toast } = useToast();
return (
<MyTooltip label={fileId ? t('file.Click to view file') || '' : ''} shouldWrapChildren={false}>
<Box
color={'myGray.600'}
display={'inline-block'}
whiteSpace={'nowrap'}
{...(!!fileId
? {
cursor: 'pointer',
textDecoration: 'underline',
onClick: async () => {
try {
await getFileAndOpen(fileId);
} catch (error) {
toast({
title: getErrText(error, '获取文件地址失败'),
status: 'error'
});
}
}
}
: {})}
{...props}
>
{filename}
</Box>
</MyTooltip>
);
}

View File

@@ -0,0 +1,291 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Textarea, Button, Flex, useTheme, Grid, Progress } from '@chakra-ui/react';
import { useDatasetStore } from '@/store/dataset';
import type { SearchTestItemType } from '@/types/core/dataset';
import { getDatasetDataItemById } from '@/api/core/dataset/data';
import MyIcon from '@/components/Icon';
import { useRequest } from '@/hooks/useRequest';
import { formatTimeToChatTime } from '@/utils/tools';
import InputDataModal, { type FormData } from './InputDataModal';
import { useGlobalStore } from '@/store/global';
import { getErrText } from '@/utils/tools';
import { useToast } from '@/hooks/useToast';
import { customAlphabet } from 'nanoid';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { postSearchText } from '@/api/core/dataset';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 12);
const Test = ({ kbId }: { kbId: string }) => {
const theme = useTheme();
const { toast } = useToast();
const { setLoading } = useGlobalStore();
const { kbDetail, kbTestList, pushKbTestItem, delKbTestItemById, updateKbItemById } =
useDatasetStore();
const [inputText, setInputText] = useState('');
const [kbTestItem, setKbTestItem] = useState<SearchTestItemType>();
const [editData, setEditData] = useState<FormData>();
const kbTestHistory = useMemo(
() => kbTestList.filter((item) => item.kbId === kbId),
[kbId, kbTestList]
);
const { mutate, isLoading } = useRequest({
mutationFn: () => postSearchText({ kbId, text: inputText.trim() }),
onSuccess(res) {
const testItem = {
id: nanoid(),
kbId,
text: inputText.trim(),
time: new Date(),
results: res
};
pushKbTestItem(testItem);
setKbTestItem(testItem);
},
onError(err) {
toast({
title: getErrText(err),
status: 'error'
});
}
});
useEffect(() => {
setKbTestItem(undefined);
}, [kbId]);
return (
<Box h={'100%'} display={['block', 'flex']}>
<Box
h={['auto', '100%']}
display={['block', 'flex']}
flexDirection={'column'}
flex={1}
maxW={'500px'}
py={4}
borderRight={['none', theme.borders.base]}
>
<Box border={'2px solid'} borderColor={'myBlue.600'} p={3} mx={4} borderRadius={'md'}>
<Box fontSize={'sm'} fontWeight={'bold'}>
<MyIcon mr={2} name={'text'} w={'18px'} h={'18px'} color={'myBlue.700'} />
</Box>
<Textarea
rows={6}
resize={'none'}
variant={'unstyled'}
maxLength={kbDetail.vectorModel.maxToken}
placeholder="输入需要测试的文本"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<Flex alignItems={'center'} justifyContent={'flex-end'}>
<Box mr={3} color={'myGray.500'}>
{inputText.length}
</Box>
<Button isDisabled={inputText === ''} isLoading={isLoading} onClick={mutate}>
</Button>
</Flex>
</Box>
<Box mt={5} flex={'1 0 0'} px={4} overflow={'overlay'} display={['none', 'block']}>
<Flex alignItems={'center'} color={'myGray.600'}>
<MyIcon mr={2} name={'history'} w={'16px'} h={'16px'} />
<Box fontSize={'2xl'}></Box>
</Flex>
<Box mt={2}>
<Flex py={2} fontWeight={'bold'} borderBottom={theme.borders.sm}>
<Box flex={1}></Box>
<Box w={'80px'}></Box>
<Box w={'14px'}></Box>
</Flex>
{kbTestHistory.map((item) => (
<Flex
key={item.id}
p={1}
alignItems={'center'}
borderBottom={theme.borders.base}
_hover={{
bg: '#f4f4f4',
'& .delete': {
display: 'block'
}
}}
cursor={'pointer'}
onClick={() => setKbTestItem(item)}
>
<Box flex={1} mr={2}>
{item.text}
</Box>
<Box w={'80px'}>{formatTimeToChatTime(item.time)}</Box>
<MyTooltip label={'删除该测试记录'}>
<Box w={'14px'} h={'14px'}>
<MyIcon
className="delete"
name={'delete'}
w={'14px'}
display={'none'}
_hover={{ color: 'red.600' }}
onClick={(e) => {
e.stopPropagation();
delKbTestItemById(item.id);
kbTestItem?.id === item.id && setKbTestItem(undefined);
}}
/>
</Box>
</MyTooltip>
</Flex>
))}
</Box>
</Box>
</Box>
<Box p={4} h={['auto', '100%']} overflow={'overlay'} flex={1}>
{!kbTestItem?.results || kbTestItem.results.length === 0 ? (
<Flex
mt={[10, 0]}
h={'100%'}
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
>
<MyIcon name={'empty'} color={'transparent'} w={'54px'} />
<Box mt={3} color={'myGray.600'}>
</Box>
</Flex>
) : (
<>
<Flex alignItems={'flex-end'}>
<Box fontSize={'3xl'} color={'myGray.600'}>
</Box>
<MyTooltip
label={
'根据知识库内容与测试文本的相似度进行排序,你可以根据测试结果调整对应的文本。\n注意测试记录中的数据可能已经被修改过点击某条测试数据后将展示最新的数据。'
}
forceShow
>
<QuestionOutlineIcon
ml={2}
color={'myGray.600'}
cursor={'pointer'}
fontSize={'lg'}
/>
</MyTooltip>
</Flex>
<Grid
mt={1}
gridTemplateColumns={[
'repeat(1,1fr)',
'repeat(1,1fr)',
'repeat(1,1fr)',
'repeat(1,1fr)',
'repeat(2,1fr)'
]}
gridGap={4}
>
{kbTestItem?.results.map((item) => (
<Box
key={item.id}
pb={2}
borderRadius={'sm'}
border={theme.borders.base}
_notLast={{ mb: 2 }}
cursor={'pointer'}
title={'编辑'}
onClick={async () => {
try {
setLoading(true);
const data = await getDatasetDataItemById(item.id);
if (!data) {
throw new Error('该数据已被删除');
}
setEditData({
dataId: data.id,
...data
});
} catch (err) {
toast({
status: 'warning',
title: getErrText(err)
});
}
setLoading(false);
}}
>
<Flex p={3} alignItems={'center'} color={'myGray.500'}>
<MyIcon name={'kbTest'} w={'14px'} />
<Progress
mx={2}
flex={1}
value={item.score * 100}
size="sm"
borderRadius={'20px'}
colorScheme="gray"
/>
<Box>{item.score.toFixed(4)}</Box>
</Flex>
<Box
px={2}
fontSize={'xs'}
color={'myGray.600'}
maxH={'200px'}
overflow={'overlay'}
>
<Box>{item.q}</Box>
<Box>{item.a}</Box>
</Box>
</Box>
))}
</Grid>
</>
)}
</Box>
{editData && (
<InputDataModal
kbId={kbId}
defaultValues={editData}
onClose={() => setEditData(undefined)}
onSuccess={(data) => {
if (kbTestItem && editData.dataId) {
const newTestItem = {
...kbTestItem,
results: kbTestItem.results.map((item) =>
item.id === editData.dataId
? {
...item,
q: data.q,
a: data.a
}
: item
)
};
updateKbItemById(newTestItem);
setKbTestItem(newTestItem);
}
setEditData(undefined);
}}
onDelete={() => {
if (kbTestItem && editData.dataId) {
const newTestItem = {
...kbTestItem,
results: kbTestItem.results.filter((item) => item.id !== editData.dataId)
};
updateKbItemById(newTestItem);
setKbTestItem(newTestItem);
}
setEditData(undefined);
}}
/>
)}
</Box>
);
};
export default Test;

View File

@@ -0,0 +1,212 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import { Box, Flex, IconButton, useTheme } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useForm } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
import { DatasetItemType } from '@/types/core/dataset';
import { getErrText } from '@/utils/tools';
import { useGlobalStore } from '@/store/global';
import { type ComponentRef } from './components/Info';
import Tabs from '@/components/Tabs';
import dynamic from 'next/dynamic';
import MyIcon from '@/components/Icon';
import SideTabs from '@/components/SideTabs';
import PageContainer from '@/components/PageContainer';
import Avatar from '@/components/Avatar';
import Info from './components/Info';
import { serviceSideProps } from '@/utils/web/i18n';
import { useTranslation } from 'react-i18next';
import { getTrainingQueueLen } from '@/api/core/dataset/data';
import { delDatasetEmptyFiles } from '@/api/core/dataset/file';
import MyTooltip from '@/components/MyTooltip';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import { feConfigs } from '@/store/static';
import Script from 'next/script';
import FileCard from './components/FileCard';
import { useDatasetStore } from '@/store/dataset';
const DataCard = dynamic(() => import('./components/DataCard'), {
ssr: false
});
const ImportData = dynamic(() => import('./components/Import'), {
ssr: false
});
const Test = dynamic(() => import('./components/Test'), {
ssr: false
});
enum TabEnum {
dataCard = 'dataCard',
dataset = 'dataset',
import = 'import',
test = 'test',
info = 'info'
}
const Detail = ({ kbId, currentTab }: { kbId: string; currentTab: `${TabEnum}` }) => {
const InfoRef = useRef<ComponentRef>(null);
const theme = useTheme();
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { isPc } = useGlobalStore();
const { kbDetail, getKbDetail } = useDatasetStore();
const tabList = useRef([
{ label: '数据集', id: TabEnum.dataset, icon: 'overviewLight' },
{ label: '导入数据', id: TabEnum.import, icon: 'importLight' },
{ label: '搜索测试', id: TabEnum.test, icon: 'kbTest' },
{ label: '配置', id: TabEnum.info, icon: 'settingLight' }
]);
const setCurrentTab = useCallback(
(tab: `${TabEnum}`) => {
router.replace({
query: {
kbId,
currentTab: tab
}
});
},
[kbId, router]
);
const form = useForm<DatasetItemType>({
defaultValues: kbDetail
});
useQuery([kbId], () => getKbDetail(kbId), {
onSuccess(res) {
form.reset(res);
InfoRef.current?.initInput(res.tags);
},
onError(err: any) {
router.replace(`/kb/list`);
toast({
title: getErrText(err, '获取知识库异常'),
status: 'error'
});
}
});
const { data: trainingQueueLen = 0 } = useQuery(['getTrainingQueueLen'], getTrainingQueueLen, {
refetchInterval: 10000
});
useEffect(() => {
return () => {
try {
delDatasetEmptyFiles(kbId);
} catch (error) {}
};
}, [kbId]);
return (
<>
<Script src="/js/pdf.js" strategy="lazyOnload"></Script>
<PageContainer>
<Box display={['block', 'flex']} h={'100%'} pt={[4, 0]}>
{isPc ? (
<Flex
flexDirection={'column'}
p={4}
h={'100%'}
flex={'0 0 200px'}
borderRight={theme.borders.base}
>
<Flex mb={4} alignItems={'center'}>
<Avatar src={kbDetail.avatar} w={'34px'} borderRadius={'lg'} />
<Box ml={2} fontWeight={'bold'}>
{kbDetail.name}
</Box>
</Flex>
<SideTabs
flex={1}
mx={'auto'}
mt={2}
w={'100%'}
list={tabList.current}
activeId={currentTab}
onChange={(e: any) => {
setCurrentTab(e);
}}
/>
<Box textAlign={'center'}>
<Flex justifyContent={'center'} alignItems={'center'}>
<MyIcon mr={1} name="overviewLight" w={'16px'} color={'green.500'} />
<Box>{t('dataset.System Data Queue')}</Box>
<MyTooltip
label={t('dataset.Queue Desc', { title: feConfigs?.systemTitle })}
placement={'top'}
>
<QuestionOutlineIcon ml={1} w={'16px'} />
</MyTooltip>
</Flex>
<Box mt={1} fontWeight={'bold'}>
{trainingQueueLen}
</Box>
</Box>
<Flex
alignItems={'center'}
cursor={'pointer'}
py={2}
px={3}
borderRadius={'md'}
_hover={{ bg: 'myGray.100' }}
onClick={() => router.replace('/kb/list')}
>
<IconButton
mr={3}
icon={<MyIcon name={'backFill'} w={'18px'} color={'myBlue.600'} />}
bg={'white'}
boxShadow={'1px 1px 9px rgba(0,0,0,0.15)'}
h={'28px'}
size={'sm'}
borderRadius={'50%'}
aria-label={''}
/>
</Flex>
</Flex>
) : (
<Box mb={3}>
<Tabs
m={'auto'}
w={'260px'}
size={isPc ? 'md' : 'sm'}
list={tabList.current.map((item) => ({
id: item.id,
label: item.label
}))}
activeId={currentTab}
onChange={(e: any) => setCurrentTab(e)}
/>
</Box>
)}
{!!kbDetail._id && (
<Box flex={'1 0 0'} h={'100%'} pb={[4, 0]}>
{currentTab === TabEnum.dataset && <FileCard kbId={kbId} />}
{currentTab === TabEnum.dataCard && <DataCard kbId={kbId} />}
{currentTab === TabEnum.import && <ImportData kbId={kbId} />}
{currentTab === TabEnum.test && <Test kbId={kbId} />}
{currentTab === TabEnum.info && <Info ref={InfoRef} kbId={kbId} form={form} />}
</Box>
)}
</Box>
</PageContainer>
</>
);
};
export async function getServerSideProps(context: any) {
const currentTab = context?.query?.currentTab || TabEnum.dataset;
const kbId = context?.query?.kbId;
return {
props: { currentTab, kbId, ...(await serviceSideProps(context)) }
};
}
export default React.memo(Detail);

View File

@@ -0,0 +1,167 @@
import React, { useCallback, useState, useRef } from 'react';
import { Box, Flex, Button, ModalHeader, ModalFooter, ModalBody, Input } from '@chakra-ui/react';
import { useSelectFile } from '@/hooks/useSelectFile';
import { useForm } from 'react-hook-form';
import { compressImg } from '@/utils/web/file';
import { getErrText } from '@/utils/tools';
import { useToast } from '@/hooks/useToast';
import { useRouter } from 'next/router';
import { useGlobalStore } from '@/store/global';
import { useRequest } from '@/hooks/useRequest';
import Avatar from '@/components/Avatar';
import MyTooltip from '@/components/MyTooltip';
import MyModal from '@/components/MyModal';
import { postCreateDataset } from '@/api/core/dataset';
import type { CreateDatasetParams } from '@/api/core/dataset/index.d';
import { vectorModelList } from '@/store/static';
import MySelect from '@/components/Select';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import Tag from '@/components/Tag';
const CreateModal = ({ onClose, parentId }: { onClose: () => void; parentId?: string }) => {
const [refresh, setRefresh] = useState(false);
const { toast } = useToast();
const router = useRouter();
const { isPc } = useGlobalStore();
const { register, setValue, getValues, handleSubmit } = useForm<CreateDatasetParams>({
defaultValues: {
avatar: '/icon/logo.svg',
name: '',
tags: [],
vectorModel: vectorModelList[0].model,
type: 'dataset',
parentId
}
});
const InputRef = useRef<HTMLInputElement>(null);
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
if (!file) return;
try {
const src = await compressImg({
file,
maxW: 100,
maxH: 100
});
setValue('avatar', src);
setRefresh((state) => !state);
} catch (err: any) {
toast({
title: getErrText(err, '头像选择异常'),
status: 'warning'
});
}
},
[setValue, toast]
);
/* create a new kb and router to it */
const { mutate: onclickCreate, isLoading: creating } = useRequest({
mutationFn: async (data: CreateDatasetParams) => {
const id = await postCreateDataset(data);
return id;
},
successToast: '创建成功',
errorToast: '创建知识库出现意外',
onSuccess(id) {
router.push(`/kb/detail?kbId=${id}`);
}
});
return (
<MyModal isOpen onClose={onClose} isCentered={!isPc} w={'400px'}>
<ModalHeader fontSize={'2xl'}></ModalHeader>
<ModalBody>
<Box color={'myGray.800'} fontWeight={'bold'}>
</Box>
<Flex mt={3} alignItems={'center'}>
<MyTooltip label={'点击设置头像'}>
<Avatar
flexShrink={0}
src={getValues('avatar')}
w={['28px', '32px']}
h={['28px', '32px']}
cursor={'pointer'}
borderRadius={'md'}
onClick={onOpenSelectFile}
/>
</MyTooltip>
<Input
ml={3}
flex={1}
autoFocus
bg={'myWhite.600'}
{...register('name', {
required: '知识库名称不能为空~'
})}
/>
</Flex>
<Flex mt={6} alignItems={'center'}>
<Box flex={'0 0 80px'}></Box>
<Box flex={1}>
<MySelect
w={'100%'}
value={getValues('vectorModel')}
list={vectorModelList.map((item) => ({
label: item.name,
value: item.model
}))}
onchange={(e) => {
setValue('vectorModel', e);
setRefresh((state) => !state);
}}
/>
</Box>
</Flex>
<Flex mt={6} alignItems={'center'} w={'100%'}>
<Box flex={'0 0 80px'}>
<MyTooltip label={'用空格隔开多个标签,便于搜索'} forceShow>
<QuestionOutlineIcon ml={1} />
</MyTooltip>
</Box>
<Input
flex={1}
ref={InputRef}
placeholder={'标签,使用空格分割。'}
maxLength={30}
onChange={(e) => {
setValue('tags', e.target.value.split(' '));
setRefresh(!refresh);
}}
/>
</Flex>
<Flex mt={2} flexWrap={'wrap'}>
{getValues('tags')
.filter((item) => item)
.map((item, i) => (
<Tag mr={2} mb={2} key={i} whiteSpace={'nowrap'}>
{item}
</Tag>
))}
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'base'} mr={3} onClick={onClose}>
</Button>
<Button isLoading={creating} onClick={handleSubmit((data) => onclickCreate(data))}>
</Button>
</ModalFooter>
<File onSelect={onSelectFile} />
</MyModal>
);
};
export default CreateModal;

View File

@@ -0,0 +1,85 @@
import React, { useMemo, useRef } from 'react';
import { ModalFooter, ModalBody, Input, Button } from '@chakra-ui/react';
import MyModal from '@/components/MyModal';
import { useTranslation } from 'react-i18next';
import { useRequest } from '@/hooks/useRequest';
import { postCreateDataset, putDatasetById } from '@/api/core/dataset';
import { FolderAvatarSrc, KbTypeEnum } from '@/constants/dataset';
const EditFolderModal = ({
onClose,
onSuccess,
id,
parentId,
name
}: {
onClose: () => void;
onSuccess: () => void;
id?: string;
parentId?: string;
name?: string;
}) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const typeMap = useMemo(
() =>
id
? {
title: t('kb.Edit Folder')
}
: {
title: t('kb.Create Folder')
},
[id, t]
);
const { mutate: onSave, isLoading } = useRequest({
mutationFn: () => {
const val = inputRef.current?.value;
if (!val) return Promise.resolve('');
if (id) {
return putDatasetById({
id,
name: val
});
}
return postCreateDataset({
parentId,
name: val,
type: KbTypeEnum.folder,
avatar: FolderAvatarSrc,
tags: []
});
},
onSuccess: (res) => {
if (!res) return;
onSuccess();
onClose();
}
});
return (
<MyModal isOpen onClose={onClose} title={typeMap.title}>
<ModalBody>
<Input
ref={inputRef}
defaultValue={name}
placeholder={t('kb.Folder Name') || ''}
autoFocus
maxLength={20}
/>
</ModalBody>
<ModalFooter>
<Button mr={3} variant={'base'} onClick={onClose}>
{t('Cancel')}
</Button>
<Button isLoading={isLoading} onClick={onSave}>
{t('Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default EditFolderModal;

View File

@@ -0,0 +1,180 @@
import React, { useMemo, useState } from 'react';
import {
Card,
Flex,
Box,
Button,
ModalBody,
ModalHeader,
ModalFooter,
useTheme,
Grid
} from '@chakra-ui/react';
import Avatar from '@/components/Avatar';
import MyTooltip from '@/components/MyTooltip';
import MyModal from '@/components/MyModal';
import MyIcon from '@/components/Icon';
import { KbTypeEnum } from '@/constants/dataset';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { getDatasets, putDatasetById, getDatasetPaths } from '@/api/core/dataset';
import { useRequest } from '@/hooks/useRequest';
const MoveModal = ({
onClose,
onSuccess,
moveDataId
}: {
onClose: () => void;
onSuccess: () => void;
moveDataId: string;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [parentId, setParentId] = useState<string>('');
const { data } = useQuery(['getDatasets', parentId], () => {
return Promise.all([getDatasets({ parentId, type: 'folder' }), getDatasetPaths(parentId)]);
});
const paths = useMemo(
() => [
{
parentId: '',
parentName: t('kb.My Dataset')
},
...(data?.[1] || [])
],
[data, t]
);
const folderList = useMemo(
() => (data?.[0] || []).filter((item) => item._id !== moveDataId),
[moveDataId, data]
);
const { mutate, isLoading } = useRequest({
mutationFn: () => putDatasetById({ id: moveDataId, parentId }),
onSuccess,
errorToast: t('kb.Move Failed')
});
return (
<MyModal isOpen={true} maxW={['90vw', '800px']} w={'800px'} onClose={onClose}>
<Flex flexDirection={'column'} h={['90vh', 'auto']}>
<ModalHeader>
{!!parentId ? (
<Flex flex={1} userSelect={'none'} fontSize={['sm', 'lg']} fontWeight={'normal'}>
{paths.map((item, i) => (
<Flex key={item.parentId} mr={2} alignItems={'center'}>
<Box
borderRadius={'md'}
{...(i === paths.length - 1
? {
cursor: 'default'
}
: {
cursor: 'pointer',
_hover: {
color: 'myBlue.600'
},
onClick: () => {
setParentId(item.parentId);
}
})}
>
{item.parentName}
</Box>
{i !== paths.length - 1 && (
<MyIcon name={'rightArrowLight'} color={'myGray.500'} w={['18px', '24px']} />
)}
</Flex>
))}
</Flex>
) : (
<Box></Box>
)}
</ModalHeader>
<ModalBody
flex={['1 0 0', '0 0 auto']}
maxH={'80vh'}
overflowY={'auto'}
display={'grid'}
userSelect={'none'}
>
<Grid
gridTemplateColumns={['repeat(1,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']}
gridGap={3}
>
{folderList.map((item) =>
(() => {
return (
<MyTooltip
key={item._id}
label={
item.type === KbTypeEnum.dataset
? t('kb.Select Dataset')
: t('kb.Select Folder')
}
>
<Card
p={3}
border={theme.borders.base}
boxShadow={'sm'}
h={'80px'}
cursor={'pointer'}
_hover={{
boxShadow: 'md'
}}
onClick={() => {
setParentId(item._id);
}}
>
<Flex alignItems={'center'} h={'38px'}>
<Avatar src={item.avatar} w={['24px', '28px']}></Avatar>
<Box
className="textEllipsis"
ml={3}
fontWeight={'bold'}
fontSize={['md', 'lg', 'xl']}
>
{item.name}
</Box>
</Flex>
<Flex justifyContent={'flex-end'} alignItems={'center'} fontSize={'sm'}>
{item.type === KbTypeEnum.folder ? (
<Box color={'myGray.500'}>{t('Folder')}</Box>
) : (
<>
<MyIcon mr={1} name="kbTest" w={'12px'} />
<Box color={'myGray.500'}>{item.vectorModel.name}</Box>
</>
)}
</Flex>
</Card>
</MyTooltip>
);
})()
)}
</Grid>
{folderList.length === 0 && (
<Flex mt={5} flexDirection={'column'} alignItems={'center'}>
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
<Box mt={2} color={'myGray.500'}>
{t('kb.No Folder')}
</Box>
</Flex>
)}
</ModalBody>
<ModalFooter>
<Button isLoading={isLoading} onClick={mutate}>
{t('kb.Confirm move the folder')}
</Button>
</ModalFooter>
</Flex>
</MyModal>
);
};
export default MoveModal;

View File

@@ -0,0 +1,429 @@
import React, { useMemo, useRef, useState } from 'react';
import {
Box,
Flex,
Grid,
useTheme,
useDisclosure,
Card,
MenuButton,
Image
} from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useDatasetStore } from '@/store/dataset';
import PageContainer from '@/components/PageContainer';
import { useConfirm } from '@/hooks/useConfirm';
import { AddIcon } from '@chakra-ui/icons';
import { useQuery } from '@tanstack/react-query';
import { delDatasetById, getDatasetPaths, putDatasetById } from '@/api/core/dataset';
import { exportDatasetData } from '@/api/core/dataset/data';
import { useTranslation } from 'react-i18next';
import Avatar from '@/components/Avatar';
import MyIcon from '@/components/Icon';
import { serviceSideProps } from '@/utils/web/i18n';
import dynamic from 'next/dynamic';
import { FolderAvatarSrc, KbTypeEnum } from '@/constants/dataset';
import Tag from '@/components/Tag';
import MyMenu from '@/components/MyMenu';
import { useRequest } from '@/hooks/useRequest';
import { useGlobalStore } from '@/store/global';
import { useEditTitle } from '@/hooks/useEditTitle';
import { feConfigs } from '@/store/static';
const CreateModal = dynamic(() => import('./component/CreateModal'), { ssr: false });
const EditFolderModal = dynamic(() => import('./component/EditFolderModal'), { ssr: false });
const MoveModal = dynamic(() => import('./component/MoveModal'), { ssr: false });
const Kb = () => {
const { t } = useTranslation();
const theme = useTheme();
const router = useRouter();
const { parentId } = router.query as { parentId: string };
const { setLoading } = useGlobalStore();
const DeleteTipsMap = useRef({
[KbTypeEnum.folder]: t('kb.deleteFolderTips'),
[KbTypeEnum.dataset]: t('kb.deleteDatasetTips')
});
const { openConfirm, ConfirmModal } = useConfirm({
title: t('common.Delete Warning'),
content: ''
});
const { myKbList, loadKbList, setKbList, updateDataset } = useDatasetStore();
const { onOpenModal: onOpenTitleModal, EditModal: EditTitleModal } = useEditTitle({
title: t('Rename')
});
const [moveDataId, setMoveDataId] = useState<string>();
const [dragStartId, setDragStartId] = useState<string>();
const [dragTargetId, setDragTargetId] = useState<string>();
const {
isOpen: isOpenCreateModal,
onOpen: onOpenCreateModal,
onClose: onCloseCreateModal
} = useDisclosure();
const [editFolderData, setEditFolderData] = useState<{
id?: string;
name?: string;
}>();
/* 点击删除 */
const { mutate: onclickDelKb } = useRequest({
mutationFn: async (id: string) => {
setLoading(true);
await delDatasetById(id);
return id;
},
onSuccess(id: string) {
setKbList(myKbList.filter((item) => item._id !== id));
},
onSettled() {
setLoading(false);
},
successToast: t('common.Delete Success'),
errorToast: t('kb.Delete Dataset Error')
});
// export dataset to csv
const { mutate: onclickExport } = useRequest({
mutationFn: (kbId: string) => {
setLoading(true);
return exportDatasetData({ kbId });
},
onSettled() {
setLoading(false);
},
successToast: `导出成功,下次导出需要 ${feConfigs?.limit?.exportLimitMinutes} 分钟后`,
errorToast: '导出异常'
});
const { data, refetch } = useQuery(['loadDataset', parentId], () => {
return Promise.all([loadKbList(parentId), getDatasetPaths(parentId)]);
});
const paths = useMemo(
() => [
{
parentId: '',
parentName: t('kb.My Dataset')
},
...(data?.[1] || [])
],
[data, t]
);
return (
<PageContainer>
<Flex pt={3} px={5} alignItems={'center'}>
{/* url path */}
{!!parentId ? (
<Flex flex={1}>
{paths.map((item, i) => (
<Flex key={item.parentId} mr={2} alignItems={'center'}>
<Box
fontSize={['sm', 'lg']}
px={[0, 2]}
py={1}
borderRadius={'md'}
{...(i === paths.length - 1
? {
cursor: 'default'
}
: {
cursor: 'pointer',
_hover: {
bg: 'myGray.100'
},
onClick: () => {
router.push({
query: {
parentId: item.parentId
}
});
}
})}
>
{item.parentName}
</Box>
{i !== paths.length - 1 && (
<MyIcon name={'rightArrowLight'} color={'myGray.500'} w={['18px', '24px']} />
)}
</Flex>
))}
</Flex>
) : (
<Box flex={1} className="textlg" letterSpacing={1} fontSize={'24px'} fontWeight={'bold'}>
</Box>
)}
<MyMenu
offset={[-30, 10]}
width={120}
Button={
<MenuButton
_hover={{
color: 'myBlue.600'
}}
>
<Flex
alignItems={'center'}
border={theme.borders.base}
px={5}
py={2}
borderRadius={'md'}
cursor={'pointer'}
>
<AddIcon mr={2} />
<Box>{t('Create New')}</Box>
</Flex>
</MenuButton>
}
menuList={[
{
child: (
<Flex>
<Image src={FolderAvatarSrc} alt={''} w={'20px'} mr={1} />
{t('Folder')}
</Flex>
),
onClick: () => setEditFolderData({})
},
{
child: (
<Flex>
<Image src={'/imgs/module/db.png'} alt={''} w={'20px'} mr={1} />
{t('Dataset')}
</Flex>
),
onClick: onOpenCreateModal
}
]}
/>
</Flex>
<Grid
p={5}
gridTemplateColumns={['1fr', 'repeat(3,1fr)', 'repeat(4,1fr)', 'repeat(5,1fr)']}
gridGap={5}
userSelect={'none'}
>
{myKbList.map((kb) => (
<Card
display={'flex'}
flexDirection={'column'}
key={kb._id}
py={4}
px={5}
cursor={'pointer'}
h={'130px'}
border={theme.borders.md}
boxShadow={'none'}
position={'relative'}
data-drag-id={kb.type === KbTypeEnum.folder ? kb._id : undefined}
borderColor={dragTargetId === kb._id ? 'myBlue.600' : ''}
draggable
onDragStart={(e) => {
setDragStartId(kb._id);
}}
onDragOver={(e) => {
e.preventDefault();
const targetId = e.currentTarget.getAttribute('data-drag-id');
if (!targetId) return;
KbTypeEnum.folder && setDragTargetId(targetId);
}}
onDragLeave={(e) => {
e.preventDefault();
setDragTargetId(undefined);
}}
onDrop={async (e) => {
e.preventDefault();
if (!dragTargetId || !dragStartId || dragTargetId === dragStartId) return;
// update parentId
try {
await putDatasetById({
id: dragStartId,
parentId: dragTargetId
});
refetch();
} catch (error) {}
setDragTargetId(undefined);
}}
_hover={{
boxShadow: '1px 1px 10px rgba(0,0,0,0.2)',
borderColor: 'transparent',
'& .delete': {
display: 'block'
}
}}
onClick={() => {
if (kb.type === KbTypeEnum.folder) {
router.push({
pathname: '/kb/list',
query: {
parentId: kb._id
}
});
} else if (kb.type === KbTypeEnum.dataset) {
router.push({
pathname: '/kb/detail',
query: {
kbId: kb._id
}
});
}
}}
>
<MyMenu
offset={[-30, 10]}
width={120}
Button={
<MenuButton
position={'absolute'}
top={3}
right={3}
w={'22px'}
h={'22px'}
borderRadius={'md'}
_hover={{
color: 'myBlue.600',
'& .icon': {
bg: 'myGray.100'
}
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<MyIcon
className="icon"
name={'more'}
h={'16px'}
w={'16px'}
px={1}
py={1}
borderRadius={'md'}
cursor={'pointer'}
/>
</MenuButton>
}
menuList={[
{
child: (
<Flex alignItems={'center'}>
<MyIcon name={'edit'} w={'14px'} mr={2} />
{t('Rename')}
</Flex>
),
onClick: () =>
onOpenTitleModal({
defaultVal: kb.name,
onSuccess: (val) => {
if (val === kb.name || !val) return;
updateDataset({ id: kb._id, name: val });
}
})
},
{
child: (
<Flex alignItems={'center'}>
<MyIcon name={'moveLight'} w={'14px'} mr={2} />
{t('Move')}
</Flex>
),
onClick: () => setMoveDataId(kb._id)
},
{
child: (
<Flex alignItems={'center'}>
<MyIcon name={'export'} w={'14px'} mr={2} />
{t('Export')}
</Flex>
),
onClick: () => onclickExport(kb._id)
},
{
child: (
<Flex alignItems={'center'}>
<MyIcon name={'delete'} w={'14px'} mr={2} />
{t('common.Delete')}
</Flex>
),
onClick: () => {
openConfirm(
() => onclickDelKb(kb._id),
undefined,
DeleteTipsMap.current[kb.type]
)();
}
}
]}
/>
<Flex alignItems={'center'} h={'38px'}>
<Avatar src={kb.avatar} borderRadius={'lg'} w={'28px'} />
<Box ml={3}>{kb.name}</Box>
</Flex>
<Box flex={'1 0 0'} overflow={'hidden'} pt={2}>
<Flex>
{kb.tags.map((tag, i) => (
<Tag key={i} mr={2} mb={2}>
{tag}
</Tag>
))}
</Flex>
</Box>
<Flex justifyContent={'flex-end'} alignItems={'center'} fontSize={'sm'}>
{kb.type === KbTypeEnum.folder ? (
<Box color={'myGray.500'}>{t('Folder')}</Box>
) : (
<>
<MyIcon mr={1} name="kbTest" w={'12px'} />
<Box color={'myGray.500'}>{kb.vectorModel.name}</Box>
</>
)}
</Flex>
</Card>
))}
</Grid>
{myKbList.length === 0 && (
<Flex mt={'35vh'} flexDirection={'column'} alignItems={'center'}>
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
<Box mt={2} color={'myGray.500'}>
</Box>
</Flex>
)}
<ConfirmModal />
<EditTitleModal />
{isOpenCreateModal && <CreateModal onClose={onCloseCreateModal} parentId={parentId} />}
{!!editFolderData && (
<EditFolderModal
onClose={() => setEditFolderData(undefined)}
onSuccess={refetch}
parentId={parentId}
{...editFolderData}
/>
)}
{!!moveDataId && (
<MoveModal
moveDataId={moveDataId}
onClose={() => setMoveDataId('')}
onSuccess={() => {
refetch();
setMoveDataId('');
}}
/>
)}
</PageContainer>
);
};
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content))
}
};
}
export default Kb;