feat: Add base project structure with API and web components
This commit is contained in:
464
web/src/views/KnowledgeBase/index.tsx
Normal file
464
web/src/views/KnowledgeBase/index.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useEffect, useState, useRef, useMemo, type FC } from 'react';
|
||||
import { Row, Col, Button, Dropdown, Modal, message, Tooltip } from 'antd'
|
||||
import type { MenuProps } from 'antd';
|
||||
import { EllipsisOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
|
||||
import generalIcon from '@/assets/images/knowledgeBase/datasets.png';
|
||||
import webIcon from '@/assets/images/knowledgeBase/general.png';
|
||||
import tpIcon from '@/assets/images/knowledgeBase/text.png';
|
||||
import type { KnowledgeBaseListItem, CreateModalRef, KnowledgeBaseListResponse, ListQuery } from './types'
|
||||
import CreateModal from './components/CreateModal'
|
||||
import RbCard from '@/components/RbCard'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import Empty from '@/components/Empty'
|
||||
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from './service'
|
||||
const { confirm } = Modal;
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
type ModelMenuInfo = {
|
||||
menu: NonNullable<MenuProps['items']>;
|
||||
summary: string[];
|
||||
};
|
||||
|
||||
const KnowledgeBaseManagement: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<KnowledgeBaseListItem[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [query, setQuery] = useState<ListQuery>({
|
||||
orderby:'created_at',
|
||||
desc:true,
|
||||
})
|
||||
const [modelTypes, setModelTypes] = useState<string[]>([]);
|
||||
const [modelMenus, setModelMenus] = useState<Record<string, ModelMenuInfo>>({});
|
||||
const [knowledgeBaseTypes, setKnowledgeBaseTypes] = useState<string[]>([]);
|
||||
const modelListCache = useRef<Record<string, string>>({});
|
||||
const modalRef = useRef<CreateModalRef>(null)
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
|
||||
// 生成下拉菜单项(根据当前 item)
|
||||
const getOptMenuItems = (item: KnowledgeBaseListItem): MenuProps['items'] => {
|
||||
const items: NonNullable<MenuProps['items']> = [];
|
||||
|
||||
// 当权限为 share 时,不显示编辑按钮
|
||||
if (item.permission_id !== 'share') {
|
||||
items.push({
|
||||
key: '1',
|
||||
label: t('knowledgeBase.edit'),
|
||||
onClick: () => {
|
||||
handleEdit(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: '2',
|
||||
label: t('knowledgeBase.delete'),
|
||||
onClick: () => {
|
||||
handleDelete(item);
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
// 根据类型获取图标
|
||||
const getTypeIcon = (type: string) => {
|
||||
const normalized = (type || '').toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'general':
|
||||
return generalIcon;
|
||||
case 'folder':
|
||||
return folderIcon;
|
||||
case 'web':
|
||||
return webIcon;
|
||||
case 'third-party':
|
||||
case 'tp':
|
||||
return tpIcon;
|
||||
default:
|
||||
return generalIcon;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据类型获取翻译 key
|
||||
const getTypeLabelKey = (type: string) => {
|
||||
const normalized = (type || '').toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'general':
|
||||
return 'knowledgeBase.general';
|
||||
case 'folder':
|
||||
return 'knowledgeBase.folder';
|
||||
case 'web':
|
||||
return 'knowledgeBase.web';
|
||||
case 'third-party':
|
||||
case 'tp':
|
||||
return 'knowledgeBase.tp';
|
||||
default:
|
||||
return `knowledgeBase.${normalized}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理创建
|
||||
const handleCreate = (type?: string) => {
|
||||
modalRef?.current?.handleOpen(null, type)
|
||||
}
|
||||
|
||||
// 动态生成 createItems
|
||||
const createItems: MenuProps['items'] = useMemo(() => {
|
||||
return knowledgeBaseTypes.map((type, index) => ({
|
||||
key: String(index + 1),
|
||||
icon: <img src={getTypeIcon(type)} alt={type} style={{ width: 16, height: 16 }} />,
|
||||
label: t(getTypeLabelKey(type.toLocaleLowerCase())),
|
||||
onClick: () => {
|
||||
handleCreate(type);
|
||||
},
|
||||
}));
|
||||
}, [knowledgeBaseTypes, t]);
|
||||
const typeToFieldKey = (type: string) => {
|
||||
const normalized = (type || '').toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'embedding':
|
||||
return 'embedding_id';
|
||||
case 'llm':
|
||||
return 'llm_id';
|
||||
case 'image2text':
|
||||
return 'image2text_id';
|
||||
case 'rerank':
|
||||
case 'reranker':
|
||||
return 'reranker_id';
|
||||
case 'chat':
|
||||
return 'chat_id';
|
||||
default:
|
||||
return `${normalized}_id`;
|
||||
}
|
||||
};
|
||||
const formatData = (data: KnowledgeBaseListItem) => {
|
||||
const keys: (keyof KnowledgeBaseListItem)[] = ['type', 'permission_id']
|
||||
return keys.map(key => ({
|
||||
key,
|
||||
label: t(`knowledgeBase.${key}`),
|
||||
children: key === 'permission_id'
|
||||
? (data[key] === 'Private' || data[key] === 'private' ? t('knowledgeBase.private') : t('knowledgeBase.share'))
|
||||
: String(data[key] || '-'),
|
||||
}))
|
||||
}
|
||||
const fetchModelTypes = async () => {
|
||||
try {
|
||||
const response = await getModelTypeList();
|
||||
setModelTypes(Array.isArray(response) ? [...response.filter(type => type !== 'chat'),'image2text'] : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch model types:', error);
|
||||
setModelTypes([]);
|
||||
}
|
||||
};
|
||||
const fetchModelList = async () => {
|
||||
try {
|
||||
const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 });
|
||||
// 缓存模型列表,建立 id -> name 的映射
|
||||
if (response?.items && Array.isArray(response.items)) {
|
||||
const cache: Record<string, string> = {};
|
||||
response.items.forEach((model: any) => {
|
||||
if (model.id && model.name) {
|
||||
cache[model.id] = model.name;
|
||||
}
|
||||
});
|
||||
modelListCache.current = cache;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch model list:', error);
|
||||
}
|
||||
};
|
||||
const fetchKnowledgeBaseTypes = async () => {
|
||||
try {
|
||||
let types = await getKnowledgeBaseTypeList();
|
||||
types = types.filter(type => (type === 'General' )); //|| type === 'Folder'
|
||||
//暂时未实现 ,过滤掉未实现
|
||||
setKnowledgeBaseTypes(types);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge base types:', error);
|
||||
setKnowledgeBaseTypes([]);
|
||||
}
|
||||
};
|
||||
const getModelNameById = (id?: string | null) => {
|
||||
if (!id) return '';
|
||||
// 从模型列表缓存中获取模型名称
|
||||
return modelListCache.current[id] || '';
|
||||
};
|
||||
const buildModelMenuForItem = (item: KnowledgeBaseListItem): ModelMenuInfo | null => {
|
||||
const entries: { menuItem: NonNullable<MenuProps['items']>[number]; summary: string }[] = [];
|
||||
const record = item as unknown as Record<string, unknown>;
|
||||
for (const type of modelTypes) {
|
||||
const curType = type === 'rerank' ? 'reranker' : type;
|
||||
const fieldKey = typeToFieldKey(curType);
|
||||
const modelId = record[fieldKey] as string | undefined;
|
||||
if (!modelId) continue;
|
||||
const modelName = getModelNameById(modelId);
|
||||
if (!modelName) continue;
|
||||
const typeLabel = t(`knowledgeBase.createForm.${fieldKey}`) || t(`knowledgeBase.${fieldKey}`) || type;
|
||||
entries.push({
|
||||
menuItem: {
|
||||
key: `${fieldKey}_${modelId}`,
|
||||
label: (
|
||||
<span className="rb:text-gray-500 rb:text-[12px]">
|
||||
{typeLabel}: {modelName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
summary: `${typeLabel}: ${modelName}`,
|
||||
});
|
||||
}
|
||||
if (!entries.length) {
|
||||
return null;
|
||||
}
|
||||
const header: NonNullable<MenuProps['items']>[number] = {
|
||||
key: 'header',
|
||||
label: (<span className='rb:font-medium'>{t('knowledgeBase.allModels')}</span>),
|
||||
disabled: true,
|
||||
};
|
||||
const menuArray = [header, ...entries.map(({ menuItem }) => menuItem)] as NonNullable<MenuProps['items']>;
|
||||
return {
|
||||
menu: menuArray,
|
||||
summary: entries.map(({ summary }) => summary),
|
||||
};
|
||||
};
|
||||
const buildModelMenus = (items: KnowledgeBaseListItem[], isLoadMore: boolean = false) => {
|
||||
const nextMenus: Record<string, ModelMenuInfo> = {};
|
||||
items.forEach((item) => {
|
||||
const result = buildModelMenuForItem(item);
|
||||
if (result) {
|
||||
nextMenus[item.id] = result;
|
||||
}
|
||||
});
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,合并之前的菜单
|
||||
setModelMenus(prev => ({ ...prev, ...nextMenus }));
|
||||
} else {
|
||||
// 首次加载或刷新时,替换所有菜单
|
||||
setModelMenus(nextMenus);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async (pageNum: number = 1, isLoadMore: boolean = false) => {
|
||||
if (!modelTypes.length) return;
|
||||
if (loading) return;
|
||||
console.log('fetchData called, pageNum:', pageNum, 'isLoadMore:', isLoadMore);
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
...query,
|
||||
page: pageNum,
|
||||
pagesize: 9,
|
||||
orderby:'created_at',
|
||||
desc:true,
|
||||
}
|
||||
const res = await getKnowledgeBaseList(undefined, params);
|
||||
const response = res as KnowledgeBaseListResponse & { items?: KnowledgeBaseListItem[] };
|
||||
console.log('API response:', response);
|
||||
const list = response.items || [];
|
||||
const curDatas = list.map((item: KnowledgeBaseListItem) => ({
|
||||
...item,
|
||||
descriptionItems: formatData(item),
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
setData(prev => [...prev, ...curDatas]);
|
||||
} else {
|
||||
setData(curDatas);
|
||||
// 重置分页状态,确保从第一页开始
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
// 更新是否有更多数据
|
||||
const hasNext = response.page?.has_next ?? false;
|
||||
console.log('hasNext:', hasNext, 'response.page:', response.page);
|
||||
setHasMore(hasNext);
|
||||
|
||||
buildModelMenus(list, isLoadMore);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge base list:', error);
|
||||
if (!isLoadMore) {
|
||||
setData([]);
|
||||
setModelMenus({});
|
||||
setPage(1);
|
||||
}
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
console.log('loadMore called, loading:', loading, 'hasMore:', hasMore, 'page:', page);
|
||||
if (loading || !hasMore) return;
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchData(nextPage, true);
|
||||
}
|
||||
|
||||
// 创建一个稳定的刷新函数供子组件调用
|
||||
const handleRefresh = () => {
|
||||
fetchData(1, false);
|
||||
}
|
||||
|
||||
|
||||
const handleSearch = (value?: string) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
keywords: value,
|
||||
}))
|
||||
}
|
||||
// 处理编辑
|
||||
const handleEdit = (item: KnowledgeBaseListItem) => {
|
||||
modalRef?.current?.handleOpen(item, item.type);
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (item: KnowledgeBaseListItem) => {
|
||||
confirm({
|
||||
title: t('common.deleteWarning'),
|
||||
content: t('common.deleteWarningContent', { content: item.name }),
|
||||
onOk: () => {
|
||||
deleteKnowledgeBase(item.id).then((res) => {
|
||||
if (res) {
|
||||
messageApi.success(t('common.deleteSuccess'));
|
||||
fetchData(1, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('取消删除');
|
||||
},
|
||||
});
|
||||
};
|
||||
// 处理跳转详情
|
||||
const handleToDetail = (knowledgeBase: KnowledgeBaseListItem) => {
|
||||
// 根据权限类型跳转到不同的详情页
|
||||
if (knowledgeBase.permission_id === 'Private' || knowledgeBase.permission_id === 'private') {
|
||||
navigate(`/knowledge-base/${knowledgeBase.id}/private`)
|
||||
} else {
|
||||
navigate(`/knowledge-base/${knowledgeBase.id}/share`)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchModelTypes();
|
||||
fetchKnowledgeBaseTypes();
|
||||
fetchModelList();
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (modelTypes.length) {
|
||||
fetchData(1, false);
|
||||
}
|
||||
}, [modelTypes, query])
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className="rb:flex rb:justify-between rb:px-2 rb:mb-4">
|
||||
<SearchInput
|
||||
placeholder={t('knowledgeBase.searchPlaceholder')}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: '32.666%' }}
|
||||
/>
|
||||
|
||||
<Dropdown menu={{ items: createItems }} trigger={['click']}>
|
||||
<Button type="primary">+ {t('knowledgeBase.createKnowledgeBase')}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div id="scrollableDiv" style={{ height: 'calc(100vh - 120px)', overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<InfiniteScroll
|
||||
dataLength={data.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore && !loading}
|
||||
loader={<div className="rb:text-center rb:py-4">{t('common.loading')}</div>}
|
||||
endMessage={
|
||||
data.length > 0 ? (
|
||||
<div className="rb:text-center rb:py-4 rb:text-gray-400">
|
||||
{t('common.noMoreData')}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
scrollThreshold={0.9}
|
||||
scrollableTarget="scrollableDiv"
|
||||
style={{ overflow: 'visible', width: '100%' }}
|
||||
>
|
||||
{data.length === 0 && !loading ? (
|
||||
<Empty size={200} />
|
||||
) : (
|
||||
<Row gutter={[16, 16]} className="rb:mb-2" style={{ margin: 0 }}>
|
||||
{data.map((item) => {
|
||||
const modelInfo = modelMenus[item.id];
|
||||
const hasModelInfo = modelInfo && modelInfo.menu.length > 1;
|
||||
return (
|
||||
<Col xs={12} sm={12} md={12} lg={8} xl={8} key={item.id} >
|
||||
<RbCard
|
||||
title={item.name}
|
||||
className='rb:min-h-[198px]'
|
||||
extra={
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown menu={{ items: getOptMenuItems(item) }} >
|
||||
<EllipsisOutlined className="rb:cursor-pointer" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='rb:min-h-[158px]' onClick={() => handleToDetail(item)}>
|
||||
<div className='rb:min-h-[124px]'>
|
||||
{item.descriptionItems?.map((description: Record<string, unknown>) => (
|
||||
<div
|
||||
key={description.key as string}
|
||||
className="rb:flex rb:gap-4 rb:justify-start rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]"
|
||||
>
|
||||
<div className="rb:whitespace-nowrap rb:w-20">{(description.label as string)}</div>
|
||||
<div className={clsx('rb:flex-inline rb:text-left rb:py-[1px] rb:rounded rb:font-medium',{
|
||||
"rb:text-[#155eef] rb:bg-[rgba(21,94,239,0.06)] rb:px-2 rb:border rb:border-[rgba(21,94,239,0.25)] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.private'),
|
||||
"rb:text-[#369F21] rb:bg-[rgba(54,159,33,0.06)] rb:px-2 rb:border rb:border-[rgba(54,159,33,0.25);] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.share'),
|
||||
})}>{(description.children as string)}</div>
|
||||
</div>
|
||||
))}
|
||||
{item.description && (
|
||||
<div className="rb:flex rb:text-[#5B6167] rb:h-10 rb:line-clamp-2 rb:text-sm rb:leading-5 rb:mb-3 rb:gap-4">
|
||||
<div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div>
|
||||
<Tooltip title={item.description}>
|
||||
<div className='rb:flex-1 rb:text-left rb:leading-5 rb:text-gray-800 rb:break-words rb:line-clamp-2'>{item.description || t('knowledgeBase.noDescription')}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasModelInfo && (
|
||||
<Dropdown menu={{ items: modelInfo.menu }}>
|
||||
<div
|
||||
className="rb:flex rb:text-gray-500 rb:px-3 rb:py-2 rb:text-[12px] rb:leading-4 rb:mb-2 rb:bg-[#F0F3F8] rb:rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>{t('knowledgeBase.models')}:</span>
|
||||
<span className="rb:ml-1 rb:truncate rb:max-w-[200px]">
|
||||
{modelInfo.summary.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</RbCard>
|
||||
</Col>
|
||||
)})}
|
||||
</Row>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
|
||||
<CreateModal
|
||||
ref={modalRef}
|
||||
refreshTable={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default KnowledgeBaseManagement
|
||||
|
||||
Reference in New Issue
Block a user