### What problem does this PR solve? Feat: Scrolling knowledge base list and set the number of entries per page to 30 #3695 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.14.1
| @manager.route('/list', methods=['GET']) | @manager.route('/list', methods=['GET']) | ||||
| @login_required | @login_required | ||||
| def list_kbs(): | def list_kbs(): | ||||
| keywords = request.args.get("keywords", "") | |||||
| page_number = int(request.args.get("page", 1)) | page_number = int(request.args.get("page", 1)) | ||||
| items_per_page = int(request.args.get("page_size", 150)) | items_per_page = int(request.args.get("page_size", 150)) | ||||
| orderby = request.args.get("orderby", "create_time") | orderby = request.args.get("orderby", "create_time") | ||||
| desc = request.args.get("desc", True) | desc = request.args.get("desc", True) | ||||
| try: | try: | ||||
| tenants = TenantService.get_joined_tenants_by_user_id(current_user.id) | tenants = TenantService.get_joined_tenants_by_user_id(current_user.id) | ||||
| kbs = KnowledgebaseService.get_by_tenant_ids( | |||||
| [m["tenant_id"] for m in tenants], current_user.id, page_number, items_per_page, orderby, desc) | |||||
| return get_json_result(data=kbs) | |||||
| kbs, total = KnowledgebaseService.get_by_tenant_ids( | |||||
| [m["tenant_id"] for m in tenants], current_user.id, page_number, items_per_page, orderby, desc, keywords) | |||||
| return get_json_result(data={"kbs": kbs, "total": total}) | |||||
| except Exception as e: | except Exception as e: | ||||
| return server_error_response(e) | return server_error_response(e) | ||||
| from api.db import StatusEnum, TenantPermission | from api.db import StatusEnum, TenantPermission | ||||
| from api.db.db_models import Knowledgebase, DB, Tenant, User, UserTenant,Document | from api.db.db_models import Knowledgebase, DB, Tenant, User, UserTenant,Document | ||||
| from api.db.services.common_service import CommonService | from api.db.services.common_service import CommonService | ||||
| from peewee import fn | |||||
| class KnowledgebaseService(CommonService): | class KnowledgebaseService(CommonService): | ||||
| @classmethod | @classmethod | ||||
| @DB.connection_context() | @DB.connection_context() | ||||
| def get_by_tenant_ids(cls, joined_tenant_ids, user_id, | def get_by_tenant_ids(cls, joined_tenant_ids, user_id, | ||||
| page_number, items_per_page, orderby, desc): | |||||
| page_number, items_per_page, orderby, desc, keywords): | |||||
| fields = [ | fields = [ | ||||
| cls.model.id, | cls.model.id, | ||||
| cls.model.avatar, | cls.model.avatar, | ||||
| User.avatar.alias('tenant_avatar'), | User.avatar.alias('tenant_avatar'), | ||||
| cls.model.update_time | cls.model.update_time | ||||
| ] | ] | ||||
| kbs = cls.model.select(*fields).join(User, on=(cls.model.tenant_id == User.id)).where( | |||||
| ((cls.model.tenant_id.in_(joined_tenant_ids) & (cls.model.permission == | |||||
| TenantPermission.TEAM.value)) | ( | |||||
| cls.model.tenant_id == user_id)) | |||||
| & (cls.model.status == StatusEnum.VALID.value) | |||||
| ) | |||||
| if keywords: | |||||
| kbs = cls.model.select(*fields).join(User, on=(cls.model.tenant_id == User.id)).where( | |||||
| ((cls.model.tenant_id.in_(joined_tenant_ids) & (cls.model.permission == | |||||
| TenantPermission.TEAM.value)) | ( | |||||
| cls.model.tenant_id == user_id)) | |||||
| & (cls.model.status == StatusEnum.VALID.value), | |||||
| (fn.LOWER(cls.model.name).contains(keywords.lower())) | |||||
| ) | |||||
| else: | |||||
| kbs = cls.model.select(*fields).join(User, on=(cls.model.tenant_id == User.id)).where( | |||||
| ((cls.model.tenant_id.in_(joined_tenant_ids) & (cls.model.permission == | |||||
| TenantPermission.TEAM.value)) | ( | |||||
| cls.model.tenant_id == user_id)) | |||||
| & (cls.model.status == StatusEnum.VALID.value) | |||||
| ) | |||||
| if desc: | if desc: | ||||
| kbs = kbs.order_by(cls.model.getter_by(orderby).desc()) | kbs = kbs.order_by(cls.model.getter_by(orderby).desc()) | ||||
| else: | else: | ||||
| kbs = kbs.order_by(cls.model.getter_by(orderby).asc()) | kbs = kbs.order_by(cls.model.getter_by(orderby).asc()) | ||||
| count = kbs.count() | |||||
| kbs = kbs.paginate(page_number, items_per_page) | kbs = kbs.paginate(page_number, items_per_page) | ||||
| return list(kbs.dicts()) | |||||
| return list(kbs.dicts()), count | |||||
| @classmethod | @classmethod | ||||
| @DB.connection_context() | @DB.connection_context() | 
| while True: | while True: | ||||
| res = list_dataset(get_auth, page_number) | res = list_dataset(get_auth, page_number) | ||||
| data = res.get("data") | data = res.get("data") | ||||
| for item in data: | |||||
| for item in data.get("kbs"): | |||||
| dataset_id = item.get("id") | dataset_id = item.get("id") | ||||
| dataset_list.append(dataset_id) | dataset_list.append(dataset_id) | ||||
| if len(dataset_list) < page_number * 150: | if len(dataset_list) < page_number * 150: | ||||
| while True: | while True: | ||||
| res = list_dataset(get_auth, page_number) | res = list_dataset(get_auth, page_number) | ||||
| data = res.get("data") | data = res.get("data") | ||||
| for item in data: | |||||
| for item in data.get("kbs"): | |||||
| dataset_id = item.get("id") | dataset_id = item.get("id") | ||||
| dataset_list.append(dataset_id) | dataset_list.append(dataset_id) | ||||
| if len(dataset_list) < page_number * 150: | if len(dataset_list) < page_number * 150: | 
| proxy: [ | proxy: [ | ||||
| { | { | ||||
| context: ['/api', '/v1'], | context: ['/api', '/v1'], | ||||
| target: 'http://127.0.0.1:9456/', | |||||
| target: 'http://127.0.0.1:9380/', | |||||
| changeOrigin: true, | changeOrigin: true, | ||||
| ws: true, | ws: true, | ||||
| logger: console, | logger: console, | 
| "react-force-graph": "^1.44.4", | "react-force-graph": "^1.44.4", | ||||
| "react-hook-form": "^7.53.1", | "react-hook-form": "^7.53.1", | ||||
| "react-i18next": "^14.0.0", | "react-i18next": "^14.0.0", | ||||
| "react-infinite-scroll-component": "^6.1.0", | |||||
| "react-markdown": "^9.0.1", | "react-markdown": "^9.0.1", | ||||
| "react-pdf-highlighter": "^6.1.0", | "react-pdf-highlighter": "^6.1.0", | ||||
| "react-string-replace": "^1.1.1", | "react-string-replace": "^1.1.1", | ||||
| } | } | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/react-infinite-scroll-component": { | |||||
| "version": "6.1.0", | |||||
| "resolved": "https://registry.npmmirror.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", | |||||
| "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", | |||||
| "dependencies": { | |||||
| "throttle-debounce": "^2.1.0" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "react": ">=16.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/react-infinite-scroll-component/node_modules/throttle-debounce": { | |||||
| "version": "2.3.0", | |||||
| "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz", | |||||
| "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", | |||||
| "engines": { | |||||
| "node": ">=8" | |||||
| } | |||||
| }, | |||||
| "node_modules/react-is": { | "node_modules/react-is": { | ||||
| "version": "18.2.0", | "version": "18.2.0", | ||||
| "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.2.0.tgz", | "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.2.0.tgz", | 
| "react-force-graph": "^1.44.4", | "react-force-graph": "^1.44.4", | ||||
| "react-hook-form": "^7.53.1", | "react-hook-form": "^7.53.1", | ||||
| "react-i18next": "^14.0.0", | "react-i18next": "^14.0.0", | ||||
| "react-infinite-scroll-component": "^6.1.0", | |||||
| "react-markdown": "^9.0.1", | "react-markdown": "^9.0.1", | ||||
| "react-pdf-highlighter": "^6.1.0", | "react-pdf-highlighter": "^6.1.0", | ||||
| "react-string-replace": "^1.1.1", | "react-string-replace": "^1.1.1", | 
| import i18n from '@/locales/config'; | import i18n from '@/locales/config'; | ||||
| import kbService from '@/services/knowledge-service'; | import kbService from '@/services/knowledge-service'; | ||||
| import { | import { | ||||
| useInfiniteQuery, | |||||
| useIsMutating, | useIsMutating, | ||||
| useMutation, | useMutation, | ||||
| useMutationState, | useMutationState, | ||||
| useQuery, | useQuery, | ||||
| useQueryClient, | useQueryClient, | ||||
| } from '@tanstack/react-query'; | } from '@tanstack/react-query'; | ||||
| import { useDebounce } from 'ahooks'; | |||||
| import { message } from 'antd'; | import { message } from 'antd'; | ||||
| import { useSearchParams } from 'umi'; | import { useSearchParams } from 'umi'; | ||||
| import { useHandleSearchChange } from './logic-hooks'; | |||||
| import { useSetPaginationParams } from './route-hook'; | import { useSetPaginationParams } from './route-hook'; | ||||
| export const useKnowledgeBaseId = (): string => { | export const useKnowledgeBaseId = (): string => { | ||||
| gcTime: 0, // https://tanstack.com/query/latest/docs/framework/react/guides/caching?from=reactQueryV3 | gcTime: 0, // https://tanstack.com/query/latest/docs/framework/react/guides/caching?from=reactQueryV3 | ||||
| queryFn: async () => { | queryFn: async () => { | ||||
| const { data } = await kbService.getList(); | const { data } = await kbService.getList(); | ||||
| const list = data?.data ?? []; | |||||
| const list = data?.data?.kbs ?? []; | |||||
| return shouldFilterListWithoutDocument | return shouldFilterListWithoutDocument | ||||
| ? list.filter((x: IKnowledge) => x.chunk_num > 0) | ? list.filter((x: IKnowledge) => x.chunk_num > 0) | ||||
| : list; | : list; | ||||
| return { list: data, loading }; | return { list: data, loading }; | ||||
| }; | }; | ||||
| export const useInfiniteFetchKnowledgeList = () => { | |||||
| const { searchString, handleInputChange } = useHandleSearchChange(); | |||||
| const debouncedSearchString = useDebounce(searchString, { wait: 500 }); | |||||
| const PageSize = 30; | |||||
| const { | |||||
| data, | |||||
| error, | |||||
| fetchNextPage, | |||||
| hasNextPage, | |||||
| isFetching, | |||||
| isFetchingNextPage, | |||||
| status, | |||||
| } = useInfiniteQuery({ | |||||
| queryKey: ['infiniteFetchKnowledgeList', debouncedSearchString], | |||||
| queryFn: async ({ pageParam }) => { | |||||
| const { data } = await kbService.getList({ | |||||
| page: pageParam, | |||||
| page_size: PageSize, | |||||
| keywords: debouncedSearchString, | |||||
| }); | |||||
| const list = data?.data ?? []; | |||||
| return list; | |||||
| }, | |||||
| initialPageParam: 1, | |||||
| getNextPageParam: (lastPage, pages, lastPageParam) => { | |||||
| if (lastPageParam * PageSize <= lastPage.total) { | |||||
| return lastPageParam + 1; | |||||
| } | |||||
| return undefined; | |||||
| }, | |||||
| }); | |||||
| return { | |||||
| data, | |||||
| loading: isFetching, | |||||
| error, | |||||
| fetchNextPage, | |||||
| hasNextPage, | |||||
| isFetching, | |||||
| isFetchingNextPage, | |||||
| status, | |||||
| handleInputChange, | |||||
| searchString, | |||||
| }; | |||||
| }; | |||||
| export const useCreateKnowledge = () => { | export const useCreateKnowledge = () => { | ||||
| const queryClient = useQueryClient(); | const queryClient = useQueryClient(); | ||||
| const { | const { | ||||
| const { data } = await kbService.rmKb({ kb_id: id }); | const { data } = await kbService.rmKb({ kb_id: id }); | ||||
| if (data.code === 0) { | if (data.code === 0) { | ||||
| message.success(i18n.t(`message.deleted`)); | message.success(i18n.t(`message.deleted`)); | ||||
| queryClient.invalidateQueries({ queryKey: ['fetchKnowledgeList'] }); | |||||
| queryClient.invalidateQueries({ | |||||
| queryKey: ['infiniteFetchKnowledgeList'], | |||||
| }); | |||||
| } | } | ||||
| return data?.data ?? []; | return data?.data ?? []; | ||||
| }, | }, | 
| namePlaceholder: 'Please input name!', | namePlaceholder: 'Please input name!', | ||||
| doc: 'Docs', | doc: 'Docs', | ||||
| searchKnowledgePlaceholder: 'Search', | searchKnowledgePlaceholder: 'Search', | ||||
| noMoreData: 'It is all, nothing more', | |||||
| }, | }, | ||||
| knowledgeDetails: { | knowledgeDetails: { | ||||
| dataset: 'Dataset', | dataset: 'Dataset', | 
| namePlaceholder: '請輸入名稱', | namePlaceholder: '請輸入名稱', | ||||
| doc: '文件', | doc: '文件', | ||||
| searchKnowledgePlaceholder: '搜索', | searchKnowledgePlaceholder: '搜索', | ||||
| noMoreData: 'It is all, nothing more', | |||||
| }, | }, | ||||
| knowledgeDetails: { | knowledgeDetails: { | ||||
| dataset: '數據集', | dataset: '數據集', | 
| namePlaceholder: '请输入名称', | namePlaceholder: '请输入名称', | ||||
| doc: '文档', | doc: '文档', | ||||
| searchKnowledgePlaceholder: '搜索', | searchKnowledgePlaceholder: '搜索', | ||||
| noMoreData: '沒有更多的數據了', | |||||
| }, | }, | ||||
| knowledgeDetails: { | knowledgeDetails: { | ||||
| dataset: '数据集', | dataset: '数据集', | 
| .knowledge { | .knowledge { | ||||
| padding: 48px 0; | padding: 48px 0; | ||||
| overflow: auto; | |||||
| } | } | ||||
| .topWrapper { | .topWrapper { | 
| import { useNextFetchKnowledgeList } from '@/hooks/knowledge-hooks'; | |||||
| import { useInfiniteFetchKnowledgeList } from '@/hooks/knowledge-hooks'; | |||||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | ||||
| import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; | import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; | ||||
| import { Button, Empty, Flex, Input, Space, Spin } from 'antd'; | |||||
| import { | |||||
| Button, | |||||
| Divider, | |||||
| Empty, | |||||
| Flex, | |||||
| Input, | |||||
| Skeleton, | |||||
| Space, | |||||
| Spin, | |||||
| } from 'antd'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import InfiniteScroll from 'react-infinite-scroll-component'; | |||||
| import { useSaveKnowledge } from './hooks'; | |||||
| import KnowledgeCard from './knowledge-card'; | import KnowledgeCard from './knowledge-card'; | ||||
| import KnowledgeCreatingModal from './knowledge-creating-modal'; | import KnowledgeCreatingModal from './knowledge-creating-modal'; | ||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { useSaveKnowledge, useSearchKnowledge } from './hooks'; | |||||
| import { useMemo } from 'react'; | |||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| const KnowledgeList = () => { | const KnowledgeList = () => { | ||||
| const { searchString, handleInputChange } = useSearchKnowledge(); | |||||
| const { loading, list: data } = useNextFetchKnowledgeList(); | |||||
| const list = data.filter((x) => x.name.includes(searchString)); | |||||
| const { data: userInfo } = useFetchUserInfo(); | const { data: userInfo } = useFetchUserInfo(); | ||||
| const { t } = useTranslation('translation', { keyPrefix: 'knowledgeList' }); | const { t } = useTranslation('translation', { keyPrefix: 'knowledgeList' }); | ||||
| const { | const { | ||||
| onCreateOk, | onCreateOk, | ||||
| loading: creatingLoading, | loading: creatingLoading, | ||||
| } = useSaveKnowledge(); | } = useSaveKnowledge(); | ||||
| const { | |||||
| fetchNextPage, | |||||
| data, | |||||
| hasNextPage, | |||||
| searchString, | |||||
| handleInputChange, | |||||
| loading, | |||||
| } = useInfiniteFetchKnowledgeList(); | |||||
| console.log('🚀 ~ KnowledgeList ~ data:', data); | |||||
| const nextList = data?.pages?.flatMap((x) => x.kbs) ?? []; | |||||
| const total = useMemo(() => { | |||||
| return data?.pages.at(-1).total ?? 0; | |||||
| }, [data?.pages]); | |||||
| return ( | return ( | ||||
| <Flex className={styles.knowledge} vertical flex={1}> | |||||
| <Flex className={styles.knowledge} vertical flex={1} id="scrollableDiv"> | |||||
| <div className={styles.topWrapper}> | <div className={styles.topWrapper}> | ||||
| <div> | <div> | ||||
| <span className={styles.title}> | <span className={styles.title}> | ||||
| </Space> | </Space> | ||||
| </div> | </div> | ||||
| <Spin spinning={loading}> | <Spin spinning={loading}> | ||||
| <Flex | |||||
| gap={'large'} | |||||
| wrap="wrap" | |||||
| className={styles.knowledgeCardContainer} | |||||
| <InfiniteScroll | |||||
| dataLength={nextList?.length ?? 0} | |||||
| next={fetchNextPage} | |||||
| hasMore={hasNextPage} | |||||
| loader={<Skeleton avatar paragraph={{ rows: 1 }} active />} | |||||
| endMessage={total && <Divider plain>{t('noMoreData')} 🤐</Divider>} | |||||
| scrollableTarget="scrollableDiv" | |||||
| > | > | ||||
| {list.length > 0 ? ( | |||||
| list.map((item: any) => { | |||||
| return ( | |||||
| <KnowledgeCard item={item} key={item.name}></KnowledgeCard> | |||||
| ); | |||||
| }) | |||||
| ) : ( | |||||
| <Empty className={styles.knowledgeEmpty}></Empty> | |||||
| )} | |||||
| </Flex> | |||||
| <Flex | |||||
| gap={'large'} | |||||
| wrap="wrap" | |||||
| className={styles.knowledgeCardContainer} | |||||
| > | |||||
| {nextList?.length > 0 ? ( | |||||
| nextList.map((item: any) => { | |||||
| return ( | |||||
| <KnowledgeCard item={item} key={item.name}></KnowledgeCard> | |||||
| ); | |||||
| }) | |||||
| ) : ( | |||||
| <Empty className={styles.knowledgeEmpty}></Empty> | |||||
| )} | |||||
| </Flex> | |||||
| </InfiniteScroll> | |||||
| </Spin> | </Spin> | ||||
| <KnowledgeCreatingModal | <KnowledgeCreatingModal | ||||
| loading={creatingLoading} | loading={creatingLoading} |