feat: kb UI

This commit is contained in:
archer
2023-06-12 15:11:29 +08:00
parent daf1148bb1
commit 6ac7119edf
10 changed files with 237 additions and 235 deletions

View File

@@ -37,8 +37,8 @@ Fast GPT 允许你使用自己的 openai API KEY 来快速的调用 openai 接
## 👀 其他
- [FastGpt 常见问题](https://kjqvjse66l.feishu.cn/docx/HtrgdT0pkonP4kxGx8qcu6XDnGh)
- [docker 部署教程](https://www.bilibili.com/video/BV1jo4y147fT/)
- [公众号接入](https://www.bilibili.com/video/BV1xh4y1t7fy/)
- [FastGpt + Laf 最佳实践,将知识库装入公众号,点击去 Laf 公众号体验效果](https://b4jky7-fastgpt.oss.laf.run/lafercode.png)
- [FastGpt V3.4 更新集合](https://www.bilibili.com/video/BV1Lo4y147Qh/?vd_source=92041a1a395f852f9d89158eaa3f61b4)
- [FastGpt 知识库演示](https://www.bilibili.com/video/BV1Wo4y1p7i1/)

View File

@@ -29,11 +29,11 @@ const Tag = ({ children, colorSchema = 'blue', ...props }: Props) => {
}, [colorSchema]);
return (
<Box
as={'span'}
display={'inline-block'}
border={'1px solid'}
px={2}
lineHeight={0}
py={'1px'}
lineHeight={1}
py={'2px'}
borderRadius={'md'}
fontSize={'xs'}
{...theme}

View File

@@ -249,8 +249,8 @@ export const theme = extendTheme({
'2xl': '2100px'
},
lgColor: {
activeBlueGradient: 'linear-gradient(120deg, #d6e8ff 0%, #f0f7ff 100%)',
hoverBlueGradient: 'linear-gradient(60deg, #f0f7ff 0%, #d6e8ff 100%)',
activeBlueGradient: 'linear-gradient(to bottom right, #d6e8ff 0%, #f0f7ff 100%)',
hoverBlueGradient: 'linear-gradient(to top left, #d6e8ff 0%, #f0f7ff 100%)',
primary: 'linear-gradient(to bottom right, #2152d9 0%,#3370ff 40%, #4e83fd 100%)',
primary2: 'linear-gradient(to bottom right, #2152d9 0%,#3370ff 30%,#4e83fd 80%, #85b1ff 100%)'
},

View File

@@ -54,6 +54,7 @@ export const usePagination = <T = any,>({
size={'sm'}
w={'28px'}
h={'28px'}
isLoading={isLoading}
onClick={() => mutate(pageNum - 1)}
/>
<Flex mx={2} alignItems={'center'}>
@@ -84,13 +85,14 @@ export const usePagination = <T = any,>({
icon={<ArrowForwardIcon />}
aria-label={'left'}
size={'sm'}
isLoading={isLoading}
w={'28px'}
h={'28px'}
onClick={() => mutate(pageNum + 1)}
/>
</Flex>
);
}, [maxPage, mutate, pageNum]);
}, [isLoading, maxPage, mutate, pageNum]);
useEffect(() => {
defaultRequest && mutate(1);

View File

@@ -26,6 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const { userId } = await authUser({ req, authToken: true });
await connectToDatabase();
searchText = searchText.replace(/'/g, '');
const where: any = [
['user_id', userId],

View File

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

View File

@@ -1,21 +1,30 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { Card, Box } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useForm } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
import { KbItemType } from '@/types/plugin';
import { useScreen } from '@/hooks/useScreen';
import DataCard from './DataCard';
import { getErrText } from '@/utils/tools';
import Info, { type ComponentRef } from './Info';
import Tabs from '@/components/Tabs';
enum TabEnum {
data = 'data',
test = 'test',
info = 'info'
}
const Detail = ({ kbId }: { kbId: string }) => {
const { toast } = useToast();
const router = useRouter();
const { isPc } = useScreen();
const BasicInfo = useRef<ComponentRef>(null);
const { setLastKbId, kbDetail, getKbDetail, loadKbList, myKbList } = useUserStore();
const [currentTab, setCurrentTab] = useState(TabEnum.data);
const form = useForm<KbItemType>({
defaultValues: kbDetail
@@ -42,13 +51,23 @@ const Detail = ({ kbId }: { kbId: string }) => {
});
return (
<Box h={'100%'} p={5} overflow={'overlay'} position={'relative'}>
<Card p={6}>
<Info ref={BasicInfo} kbId={kbId} form={form} />
</Card>
<Card p={6} mt={5}>
<DataCard kbId={kbId} />
</Card>
<Box bg={'#fcfcfc'} h={'100%'} p={5} overflow={'overlay'} position={'relative'}>
<Box mb={5}>
<Tabs
m={'auto'}
w={'260px'}
size={isPc ? 'md' : 'sm'}
list={[
{ id: TabEnum.data, label: '数据管理' },
{ id: TabEnum.test, label: '搜索测试' },
{ id: TabEnum.info, label: '基本信息' }
]}
activeId={currentTab}
onChange={(e: any) => setCurrentTab(e)}
/>
</Box>
{currentTab === TabEnum.data && <DataCard kbId={kbId} />}
{currentTab === TabEnum.info && <Info ref={BasicInfo} kbId={kbId} form={form} />}
</Box>
);
};

View File

@@ -140,88 +140,89 @@ const Info = (
}));
return (
<Box>
<Flex>
<Box fontWeight={'bold'} fontSize={'2xl'} flex={1}>
<Flex flexDirection={'column'} alignItems={'center'}>
<Flex mt={5} w={'100%'} maxW={'350px'} alignItems={'center'}>
<Box flex={'0 0 90px'} w={0}>
</Box>
<Box flex={1}>
<Avatar
m={'auto'}
src={getValues('avatar')}
w={['32px', '40px']}
h={['32px', '40px']}
cursor={'pointer'}
title={'点击切换头像'}
onClick={onOpenSelectFile}
/>
</Box>
{kbDetail._id && (
<>
<Button
isLoading={btnLoading}
mr={3}
onClick={handleSubmit(saveSubmitSuccess, saveSubmitError)}
>
</Button>
<IconButton
isLoading={btnLoading}
icon={<DeleteIcon />}
aria-label={''}
variant={'solid'}
colorScheme={'red'}
onClick={openConfirm(onclickDelKb)}
/>
</>
)}
</Flex>
<Flex mt={5} alignItems={'center'}>
<Box flex={'0 0 60px'} w={0}>
<FormControl mt={8} w={'100%'} maxW={'350px'} display={'flex'} alignItems={'center'}>
<Box flex={'0 0 90px'} w={0}>
</Box>
<Avatar
src={getValues('avatar')}
w={['28px', '36px']}
h={['28px', '36px']}
cursor={'pointer'}
title={'点击切换头像'}
onClick={onOpenSelectFile}
<Input
flex={1}
{...register('name', {
required: '知识库名称不能为空'
})}
/>
</Flex>
<FormControl mt={5}>
<Flex alignItems={'center'} maxW={'350px'}>
<Box flex={'0 0 60px'} w={0}>
</Box>
<Input
{...register('name', {
required: '知识库名称不能为空'
})}
/>
</Flex>
</FormControl>
<Box>
<Flex mt={5} alignItems={'center'} maxW={'350px'} flexWrap={'wrap'}>
<Box flex={'0 0 60px'} w={0}>
<Tooltip label={'仅用于记忆,用空格隔开多个标签'}>
<QuestionOutlineIcon ml={1} />
</Tooltip>
</Box>
<Input
flex={1}
ref={InputRef}
placeholder={'标签,使用空格分割。'}
onChange={(e) => {
setValue('tags', e.target.value);
setRefresh(!refresh);
}}
<Flex mt={8} alignItems={'center'} w={'100%'} maxW={'350px'} flexWrap={'wrap'}>
<Box flex={'0 0 90px'} w={0}>
<Tooltip label={'用空格隔开多个标签,便于搜索'}>
<QuestionOutlineIcon ml={1} />
</Tooltip>
</Box>
<Input
flex={1}
maxW={'300px'}
ref={InputRef}
placeholder={'标签,使用空格分割。'}
maxLength={30}
onChange={(e) => {
setValue('tags', e.target.value);
setRefresh(!refresh);
}}
/>
<Box mt={2} w="100%">
{getValues('tags')
.split(' ')
.filter((item) => item)
.map((item, i) => (
<Tag mr={2} mb={2} key={i} whiteSpace={'nowrap'}>
{item}
</Tag>
))}
</Box>
</Flex>
{kbDetail._id && (
<Flex mt={5} w={'100%'} maxW={'350px'} alignItems={'flex-end'}>
<Box flex={'0 0 90px'} 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'}
colorScheme={'red'}
color={'red.500'}
onClick={openConfirm(onclickDelKb)}
/>
<Box pl={'60px'} mt={2} w="100%">
{getValues('tags')
.split(' ')
.filter((item) => item)
.map((item, i) => (
<Tag mr={2} mb={2} key={i}>
{item}
</Tag>
))}
</Box>
</Flex>
</Box>
)}
<File onSelect={onSelectFile} />
<ConfirmChild />
</Box>
</Flex>
);
};

View File

@@ -119,12 +119,12 @@ const KbList = ({ kbId }: { kbId: string }) => {
{item.name}
</Box>
{/* tags */}
<Box className="textEllipsis" color={'myGray.400'} py={1}>
<Box color={'myGray.400'} py={1} fontSize={'sm'}>
{!item.tags ? (
<>{item.tags || '你还没设置标签~'}</>
) : (
item.tags.split(' ').map((item, i) => (
<Tag key={i} mr={2} mb={2}>
<Tag key={i} mr={1} mb={1}>
{item}
</Tag>
))

View File

@@ -245,13 +245,14 @@ const NumberSetting = ({ tableType }: { tableType: `${TableEnum}` }) => {
<Card mt={4} px={[3, 6]} py={4}>
<Tabs
m={'auto'}
w={'200px'}
list={tableList.current}
activeId={currentTab}
size={'sm'}
onChange={(id: any) => setCurrentTab(id)}
/>
<Box>
<Box minH={'300px'}>
{(() => {
const item = tableList.current.find((item) => item.id === currentTab);