Feat: Filter the knowledge base list using owner #3221 (#7191)

### What problem does this PR solve?

Feat: Filter the knowledge base list using owner #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu 2025-04-22 13:44:41 +08:00 committed by GitHub
parent c8194f5fd0
commit 1cc17eb611
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 574 additions and 35 deletions

View File

@ -1,24 +1,57 @@
import { Filter } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { Button } from './ui/button';
import { ChevronDown } from 'lucide-react';
import React, {
ChangeEventHandler,
FunctionComponent,
PropsWithChildren,
} from 'react';
import { Button, ButtonProps } from './ui/button';
import { SearchInput } from './ui/input';
interface IProps {
title: string;
showDialog?: () => void;
FilterPopover?: FunctionComponent<any>;
searchString?: string;
onSearchChange?: ChangeEventHandler<HTMLInputElement>;
count?: number;
}
const FilterButton = React.forwardRef<
HTMLButtonElement,
ButtonProps & { count?: number }
>(({ count = 0, ...props }, ref) => {
return (
<Button variant="outline" size={'sm'} {...props} ref={ref}>
Filter <span>{count}</span> <ChevronDown />
</Button>
);
});
export default function ListFilterBar({
title,
children,
showDialog,
FilterPopover,
searchString,
onSearchChange,
count,
}: PropsWithChildren<IProps>) {
return (
<div className="flex justify-between mb-6">
<span className="text-3xl font-bold ">{title}</span>
<div className="flex gap-4 items-center">
<Filter className="size-5" />
<SearchInput></SearchInput>
{FilterPopover ? (
<FilterPopover>
<FilterButton count={count}></FilterButton>
</FilterPopover>
) : (
<FilterButton></FilterButton>
)}
<SearchInput
value={searchString}
onChange={onSearchChange}
></SearchInput>
<Button variant={'tertiary'} size={'sm'} onClick={showDialog}>
{children}
</Button>

View File

@ -0,0 +1,117 @@
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { ButtonProps, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -2,6 +2,7 @@ import { ResponsePostType } from '@/interfaces/database/base';
import {
IKnowledge,
IKnowledgeGraph,
IKnowledgeResult,
IRenameTag,
ITestingResult,
} from '@/interfaces/database/knowledge';
@ -9,6 +10,7 @@ import i18n from '@/locales/config';
import kbService, {
deleteKnowledgeGraph,
getKnowledgeGraph,
listDataset,
listTag,
removeTag,
renameTag,
@ -23,9 +25,12 @@ import {
} from '@tanstack/react-query';
import { useDebounce } from 'ahooks';
import { message } from 'antd';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { useSearchParams } from 'umi';
import { useHandleSearchChange } from './logic-hooks';
import {
useGetPaginationWithRouter,
useHandleSearchChange,
} from './logic-hooks';
import { useSetPaginationParams } from './route-hook';
export const useKnowledgeBaseId = (): string => {
@ -64,7 +69,7 @@ export const useFetchKnowledgeList = (
initialData: [],
gcTime: 0, // https://tanstack.com/query/latest/docs/framework/react/guides/caching?from=reactQueryV3
queryFn: async () => {
const { data } = await kbService.getList();
const { data } = await listDataset();
const list = data?.data?.kbs ?? [];
return shouldFilterListWithoutDocument
? list.filter((x: IKnowledge) => x.chunk_num > 0)
@ -91,6 +96,7 @@ export const useInfiniteFetchKnowledgeList = () => {
const debouncedSearchString = useDebounce(searchString, { wait: 500 });
const PageSize = 30;
const {
data,
error,
@ -102,7 +108,7 @@ export const useInfiniteFetchKnowledgeList = () => {
} = useInfiniteQuery({
queryKey: ['infiniteFetchKnowledgeList', debouncedSearchString],
queryFn: async ({ pageParam }) => {
const { data } = await kbService.getList({
const { data } = await listDataset({
page: pageParam,
page_size: PageSize,
keywords: debouncedSearchString,
@ -132,6 +138,67 @@ export const useInfiniteFetchKnowledgeList = () => {
};
};
export const useFetchNextKnowledgeListByPage = () => {
const { searchString, handleInputChange } = useHandleSearchChange();
const { pagination, setPagination } = useGetPaginationWithRouter();
const [ownerIds, setOwnerIds] = useState<string[]>([]);
const debouncedSearchString = useDebounce(searchString, { wait: 500 });
const { data, isFetching: loading } = useQuery<IKnowledgeResult>({
queryKey: [
'fetchKnowledgeListByPage',
{
debouncedSearchString,
...pagination,
ownerIds,
},
],
initialData: {
kbs: [],
total: 0,
},
gcTime: 0,
queryFn: async () => {
const { data } = await listDataset(
{
keywords: debouncedSearchString,
page_size: pagination.pageSize,
page: pagination.current,
},
{
owner_ids: ownerIds,
},
);
return data?.data;
},
});
const onInputChange: React.ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
// setPagination({ page: 1 }); // TODO: 这里导致重复请求
handleInputChange(e);
},
[handleInputChange],
);
const handleOwnerIdsChange = useCallback((ids: string[]) => {
// setPagination({ page: 1 }); // TODO: 这里导致重复请求
setOwnerIds(ids);
}, []);
return {
...data,
searchString,
handleInputChange: onInputChange,
pagination: { ...pagination, total: data?.total },
setPagination,
loading,
setOwnerIds: handleOwnerIdsChange,
ownerIds,
};
};
export const useCreateKnowledge = () => {
const queryClient = useQueryClient();
const {
@ -198,7 +265,7 @@ export const useUpdateKnowledge = (shouldFetchList = false) => {
message.success(i18n.t(`message.updated`));
if (shouldFetchList) {
queryClient.invalidateQueries({
queryKey: ['infiniteFetchKnowledgeList'],
queryKey: ['fetchKnowledgeListByPage'],
});
} else {
queryClient.invalidateQueries({ queryKey: ['fetchKnowledgeDetail'] });

View File

@ -23,10 +23,15 @@ export interface IKnowledge {
update_time: number;
vector_similarity_weight: number;
embd_id: string;
nickname?: string;
nickname: string;
operator_permission: number;
}
export interface IKnowledgeResult {
kbs: IKnowledge[];
total: number;
}
export interface Raptor {
use_raptor: boolean;
}

View File

@ -8,3 +8,13 @@ export interface ITestRetrievalRequestBody {
highlight?: boolean;
kb_id?: string[];
}
export interface IFetchKnowledgeListRequestBody {
owner_ids?: string[];
}
export interface IFetchKnowledgeListRequestParams {
keywords?: string;
page?: number;
page_size?: number;
}

View File

@ -132,7 +132,7 @@ export default function BasicSettingForm() {
defaultValue={selectedFrameworks}
placeholder="Select frameworks"
variant="inverted"
maxCount={100}
maxCount={0}
{...field}
/>
</FormControl>

View File

@ -0,0 +1,149 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { zodResolver } from '@hookform/resolvers/zod';
import { PropsWithChildren, useCallback, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useFetchNextKnowledgeListByPage } from '@/hooks/knowledge-hooks';
import { useSelectOwners } from './use-select-owners';
const FormSchema = z.object({
items: z.array(z.string()).refine((value) => value.some((item) => item), {
message: 'You have to select at least one item.',
}),
});
type CheckboxReactHookFormMultipleProps = Pick<
ReturnType<typeof useFetchNextKnowledgeListByPage>,
'setOwnerIds' | 'ownerIds'
>;
function CheckboxReactHookFormMultiple({
setOwnerIds,
ownerIds,
}: CheckboxReactHookFormMultipleProps) {
const owners = useSelectOwners();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
items: [],
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
setOwnerIds(data.items);
}
const onReset = useCallback(() => {
setOwnerIds([]);
}, [setOwnerIds]);
useEffect(() => {
form.setValue('items', ownerIds);
}, [form, ownerIds]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
onReset={() => form.reset()}
>
<FormField
control={form.control}
name="items"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">Owner</FormLabel>
</div>
{owners.map((item) => (
<FormField
key={item.id}
control={form.control}
name="items"
render={({ field }) => {
return (
<div className="flex items-center justify-between">
<FormItem
key={item.id}
className="flex flex-row space-x-3 space-y-0 items-center"
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel className="text-lg">
{item.label}
</FormLabel>
</FormItem>
<span className=" text-sm">{item.count}</span>
</div>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-between">
<Button
type="button"
variant={'outline'}
size={'sm'}
onClick={onReset}
>
Clear
</Button>
<Button type="submit" size={'sm'}>
Submit
</Button>
</div>
</form>
</Form>
);
}
export function DatasetsFilterPopover({
children,
setOwnerIds,
ownerIds,
}: PropsWithChildren & CheckboxReactHookFormMultipleProps) {
return (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent>
<CheckboxReactHookFormMultiple
setOwnerIds={setOwnerIds}
ownerIds={ownerIds}
></CheckboxReactHookFormMultiple>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,96 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
export type DatasetsPaginationType = {
showQuickJumper?: boolean;
onChange?(page: number, pageSize?: number): void;
total?: number;
current?: number;
pageSize?: number;
};
export function DatasetsPagination({
current = 1,
pageSize = 10,
total = 0,
onChange,
}: DatasetsPaginationType) {
const [currentPage, setCurrentPage] = useState(1);
const pages = useMemo(() => {
const num = Math.ceil(total / pageSize);
console.log('🚀 ~ pages ~ num:', num);
return new Array(num).fill(0).map((_, idx) => idx + 1);
}, [pageSize, total]);
const handlePreviousPageChange = useCallback(() => {
setCurrentPage((page) => {
const previousPage = page - 1;
if (previousPage > 0) {
return previousPage;
}
return page;
});
}, []);
const handlePageChange = useCallback(
(page: number) => () => {
setCurrentPage(page);
},
[],
);
const handleNextPageChange = useCallback(() => {
setCurrentPage((page) => {
const nextPage = page + 1;
if (nextPage <= pages.length) {
return nextPage;
}
return page;
});
}, [pages.length]);
useEffect(() => {
setCurrentPage(current);
}, [current]);
useEffect(() => {
onChange?.(currentPage);
}, [currentPage, onChange]);
return (
<section className="flex items-center justify-end">
<span className="mr-4">Total {total}</span>
<Pagination className="w-auto mx-0">
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={handlePreviousPageChange} />
</PaginationItem>
{pages.map((x) => (
<PaginationItem
key={x}
className={cn({ ['bg-red-500']: currentPage === x })}
>
<PaginationLink onClick={handlePageChange(x)}>{x}</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext onClick={handleNextPageChange} />
</PaginationItem>
</PaginationContent>
</Pagination>
</section>
);
}

View File

@ -3,14 +3,16 @@ import { RenameDialog } from '@/components/rename-dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useInfiniteFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { useFetchNextKnowledgeListByPage } from '@/hooks/knowledge-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { IKnowledge } from '@/interfaces/database/knowledge';
import { formatDate } from '@/utils/date';
import { pick } from 'lodash';
import { ChevronRight, Ellipsis, Plus } from 'lucide-react';
import { useMemo } from 'react';
import { PropsWithChildren, useCallback } from 'react';
import { DatasetCreatingDialog } from './dataset-creating-dialog';
import { DatasetDropdown } from './dataset-dropdown';
import { DatasetsFilterPopover } from './datasets-filter-popover';
import { DatasetsPagination } from './datasets-pagination';
import { useSaveKnowledge } from './hooks';
import { useRenameDataset } from './use-rename-dataset';
@ -25,23 +27,15 @@ export default function Datasets() {
const { navigateToDataset } = useNavigatePage();
const {
fetchNextPage,
data,
hasNextPage,
searchString,
kbs,
total,
pagination,
setPagination,
handleInputChange,
loading,
} = useInfiniteFetchKnowledgeList();
const nextList: IKnowledge[] = useMemo(() => {
const list =
data?.pages?.flatMap((x) => (Array.isArray(x.kbs) ? x.kbs : [])) ?? [];
return list;
}, [data?.pages]);
const total = useMemo(() => {
return data?.pages.at(-1).total ?? 0;
}, [data?.pages]);
searchString,
setOwnerIds,
ownerIds,
} = useFetchNextKnowledgeListByPage();
const {
datasetRenameLoading,
@ -52,14 +46,32 @@ export default function Datasets() {
showDatasetRenameModal,
} = useRenameDataset();
const handlePageChange = useCallback(
(page: number, pageSize?: number) => {
setPagination({ page, pageSize });
},
[setPagination],
);
return (
<section className="p-8 text-foreground">
<ListFilterBar title="Datasets" showDialog={showModal}>
<ListFilterBar
title="Datasets"
showDialog={showModal}
count={ownerIds.length}
FilterPopover={({ children }: PropsWithChildren) => (
<DatasetsFilterPopover setOwnerIds={setOwnerIds} ownerIds={ownerIds}>
{children}
</DatasetsFilterPopover>
)}
searchString={searchString}
onSearchChange={handleInputChange}
>
<Plus className="mr-2 h-4 w-4" />
Create dataset
</ListFilterBar>
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8">
{nextList.map((dataset) => (
{kbs.map((dataset) => (
<Card
key={dataset.id}
className="bg-colors-background-inverse-weak flex-1"
@ -99,6 +111,13 @@ export default function Datasets() {
</Card>
))}
</div>
<div className="mt-8">
<DatasetsPagination
{...pick(pagination, 'current', 'pageSize')}
total={total}
onChange={handlePageChange}
></DatasetsPagination>
</div>
{visible && (
<DatasetCreatingDialog
hideModal={hideModal}

View File

@ -16,7 +16,13 @@ export const useRenameDataset = () => {
const onDatasetRenameOk = useCallback(
async (name: string) => {
const ret = await saveKnowledgeConfiguration({
...omit(dataset, ['id', 'update_time', 'nickname', 'tenant_avatar']),
...omit(dataset, [
'id',
'update_time',
'nickname',
'tenant_avatar',
'tenant_id',
]),
kb_id: dataset.id,
name,
});

View File

@ -0,0 +1,28 @@
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { useMemo } from 'react';
export type OwnerFilterType = {
id: string;
label: string;
count: number;
};
export function useSelectOwners() {
const { list } = useFetchKnowledgeList();
const owners = useMemo(() => {
const ownerList: OwnerFilterType[] = [];
list.forEach((x) => {
const item = ownerList.find((y) => y.id === x.tenant_id);
if (!item) {
ownerList.push({ id: x.tenant_id, label: x.nickname, count: 1 });
} else {
item.count += 1;
}
});
return ownerList;
}, [list]);
return owners;
}

View File

@ -1,4 +1,8 @@
import { IRenameTag } from '@/interfaces/database/knowledge';
import {
IFetchKnowledgeListRequestBody,
IFetchKnowledgeListRequestParams,
} from '@/interfaces/request/knowledge';
import api from '@/utils/api';
import registerServer from '@/utils/register-server';
import request, { post } from '@/utils/request';
@ -54,7 +58,7 @@ const methods = {
},
getList: {
url: kb_list,
method: 'get',
method: 'post',
},
// document manager
get_document_list: {
@ -173,4 +177,9 @@ export function deleteKnowledgeGraph(knowledgeId: string) {
return request.delete(api.getKnowledgeGraph(knowledgeId));
}
export const listDataset = (
params?: IFetchKnowledgeListRequestParams,
body?: IFetchKnowledgeListRequestBody,
) => request.post(api.kb_list, { data: body || {}, params });
export default kbService;