feat: kb UI
This commit is contained in:
@@ -1,13 +1,7 @@
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Card,
|
||||
IconButton,
|
||||
Flex,
|
||||
Button,
|
||||
@@ -17,10 +11,8 @@ import {
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Input,
|
||||
Tooltip
|
||||
Grid
|
||||
} from '@chakra-ui/react';
|
||||
import { QuestionOutlineIcon } from '@chakra-ui/icons';
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import type { KbDataItemType } from '@/types/plugin';
|
||||
import { usePagination } from '@/hooks/usePagination';
|
||||
import {
|
||||
@@ -29,30 +21,21 @@ import {
|
||||
delOneKbDataByDataId,
|
||||
getTrainingData
|
||||
} from '@/api/plugins/kb';
|
||||
import { DeleteIcon, RepeatIcon, EditIcon } from '@chakra-ui/icons';
|
||||
import { useLoading } from '@/hooks/useLoading';
|
||||
import { DeleteIcon } from '@chakra-ui/icons';
|
||||
import { fileDownload } from '@/utils/file';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import Papa from 'papaparse';
|
||||
import dynamic from 'next/dynamic';
|
||||
import InputModal, { FormData as InputDataType } from './InputDataModal';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const SelectFileModal = dynamic(() => import('./SelectFileModal'));
|
||||
const SelectCsvModal = dynamic(() => import('./SelectCsvModal'));
|
||||
|
||||
const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
const lastSearch = useRef('');
|
||||
const tdStyles = useRef<BoxProps>({
|
||||
fontSize: 'xs',
|
||||
minW: '150px',
|
||||
maxW: '500px',
|
||||
maxH: '250px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowY: 'auto'
|
||||
});
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
@@ -64,7 +47,7 @@ const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
pageNum
|
||||
} = usePagination<KbDataItemType>({
|
||||
api: getKbDataList,
|
||||
pageSize: 10,
|
||||
pageSize: 24,
|
||||
params: {
|
||||
kbId,
|
||||
searchText
|
||||
@@ -109,7 +92,6 @@ const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
mutationFn: () => getExportDataList(kbId),
|
||||
onSuccess(res) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const text = Papa.unparse({
|
||||
fields: ['question', 'answer'],
|
||||
data: res
|
||||
@@ -126,7 +108,6 @@ const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
} catch (error) {
|
||||
error;
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError(err: any) {
|
||||
toast({
|
||||
@@ -137,6 +118,14 @@ const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const getFirstData = useCallback(
|
||||
debounce(() => {
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
// interval get data
|
||||
useQuery(['refetchData'], () => refetchData(1), {
|
||||
refetchInterval: 5000,
|
||||
@@ -150,148 +139,137 @@ const DataCard = ({ kbId }: { kbId: string }) => {
|
||||
|
||||
return (
|
||||
<Box position={'relative'}>
|
||||
<Flex>
|
||||
<Box fontWeight={'bold'} fontSize={'lg'} flex={1} mr={2}>
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<Box fontWeight={'bold'} fontSize={'lg'} mr={2}>
|
||||
知识库数据: {total}组
|
||||
</Box>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
aria-label={'refresh'}
|
||||
variant={'base'}
|
||||
mr={[2, 4]}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
refetchData(pageNum);
|
||||
getTrainingData({ kbId, init: true });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={'base'}
|
||||
mr={2}
|
||||
size={'sm'}
|
||||
isLoading={isLoadingExport}
|
||||
title={'半小时仅能导出1次'}
|
||||
onClick={() => onclickExport()}
|
||||
>
|
||||
导出csv
|
||||
</Button>
|
||||
<Menu autoSelect={false}>
|
||||
<MenuButton as={Button} size={'sm'}>
|
||||
导入
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
a: '',
|
||||
q: ''
|
||||
})
|
||||
}
|
||||
>
|
||||
手动输入
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectFileModal}>文本/文件拆分</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectCsvModal}>csv 问答对导入</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<Box>
|
||||
<Button
|
||||
variant={'base'}
|
||||
mr={2}
|
||||
size={'sm'}
|
||||
isLoading={isLoadingExport || isLoading}
|
||||
title={'半小时仅能导出1次'}
|
||||
onClick={() => onclickExport()}
|
||||
>
|
||||
导出csv
|
||||
</Button>
|
||||
<Menu autoSelect={false}>
|
||||
<MenuButton as={Button} size={'sm'} isLoading={isLoading}>
|
||||
导入
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
a: '',
|
||||
q: ''
|
||||
})
|
||||
}
|
||||
>
|
||||
手动输入
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectFileModal}>文本/文件拆分</MenuItem>
|
||||
<MenuItem onClick={onOpenSelectCsvModal}>csv 问答对导入</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex mt={4}>
|
||||
{(qaListLen > 0 || vectorListLen > 0) && (
|
||||
<Flex my={4}>
|
||||
{qaListLen > 0 || vectorListLen > 0 ? (
|
||||
<Box fontSize={'xs'}>
|
||||
{qaListLen > 0 ? `${qaListLen}条数据正在拆分,` : ''}
|
||||
{vectorListLen > 0 ? `${vectorListLen}条数据正在生成索引,` : ''}
|
||||
请耐心等待...
|
||||
</Box>
|
||||
) : (
|
||||
<Box fontSize={'xs'}>所有数据已就绪~</Box>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
<Box flex={1} mr={1} />
|
||||
<Input
|
||||
maxW={['90%', '300px']}
|
||||
maxW={['60%', '300px']}
|
||||
size={'sm'}
|
||||
value={searchText}
|
||||
placeholder="搜索匹配知识,补充知识和来源,回车确认"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="根据匹配知识,补充知识和来源搜索"
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
getFirstData();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
getFirstData();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (searchText === lastSearch.current) return;
|
||||
if (e.key === 'Enter') {
|
||||
getData(1);
|
||||
lastSearch.current = searchText;
|
||||
getFirstData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<TableContainer mt={4} minH={'200px'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>
|
||||
匹配的知识点
|
||||
<Tooltip
|
||||
label={
|
||||
'对话时,会将用户的问题和知识库的 "匹配知识点" 进行比较,找到最相似的前 n 条记录,将这些记录的 "匹配知识点"+"补充知识点" 作为 chatgpt 的系统提示词。'
|
||||
}
|
||||
>
|
||||
<QuestionOutlineIcon ml={1} />
|
||||
</Tooltip>
|
||||
</Th>
|
||||
<Th>补充知识</Th>
|
||||
<Th>来源</Th>
|
||||
<Th>操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{kbDataList.map((item) => (
|
||||
<Tr key={item.id} fontSize={'sm'}>
|
||||
<Td>
|
||||
<Box {...tdStyles.current}>{item.q}</Box>
|
||||
</Td>
|
||||
<Td>
|
||||
<Box {...tdStyles.current}>{item.a || '-'}</Box>
|
||||
</Td>
|
||||
<Td maxW={'15%'} whiteSpace={'pre-wrap'} userSelect={'all'}>
|
||||
{item.source?.trim() || '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
mr={5}
|
||||
icon={<EditIcon />}
|
||||
variant={'base'}
|
||||
aria-label={'delete'}
|
||||
size={'sm'}
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
dataId: item.id,
|
||||
q: item.q,
|
||||
a: item.a
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
variant={'base'}
|
||||
colorScheme={'gray'}
|
||||
aria-label={'delete'}
|
||||
size={'sm'}
|
||||
onClick={async () => {
|
||||
await delOneKbDataByDataId(item.id);
|
||||
refetchData(pageNum);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex mt={2} justifyContent={'flex-end'}>
|
||||
<Grid
|
||||
minH={'200px'}
|
||||
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']}
|
||||
gridGap={4}
|
||||
>
|
||||
{kbDataList.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
cursor={'pointer'}
|
||||
pb={3}
|
||||
userSelect={'none'}
|
||||
boxShadow={'none'}
|
||||
_hover={{ boxShadow: 'lg', '& .delete': { display: 'block' } }}
|
||||
border={'1px solid '}
|
||||
borderColor={'myGray.200'}
|
||||
onClick={() =>
|
||||
setEditInputData({
|
||||
dataId: item.id,
|
||||
q: item.q,
|
||||
a: item.a
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flex py={3} px={4} h={'36px'}>
|
||||
<Box className={'textEllipsis'} flex={1} fontSize={'sm'}>
|
||||
{item.source?.trim()}
|
||||
</Box>
|
||||
<IconButton
|
||||
className="delete"
|
||||
display={['block', 'none']}
|
||||
icon={<DeleteIcon />}
|
||||
variant={'base'}
|
||||
colorScheme={'gray'}
|
||||
aria-label={'delete'}
|
||||
size={'xs'}
|
||||
borderRadius={'md'}
|
||||
lineHeight={1}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await delOneKbDataByDataId(item.id);
|
||||
refetchData(pageNum);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Box
|
||||
h={'100px'}
|
||||
overflow={'hidden'}
|
||||
wordBreak={'break-all'}
|
||||
px={3}
|
||||
color={'myGray.600'}
|
||||
>
|
||||
<Box color={'myGray.1000'}>{item.q}</Box>
|
||||
<Box color={'myGray.600'}>{item.a}</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Flex mt={2} justifyContent={'center'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
{editInputData !== undefined && (
|
||||
<InputModal
|
||||
kbId={kbId}
|
||||
|
||||
Reference in New Issue
Block a user