feat(web): knowledge base

This commit is contained in:
zhaoying
2026-05-06 14:13:13 +08:00
parent b0a4f9fa18
commit a947d6d095
9 changed files with 181 additions and 68 deletions

View File

@@ -154,6 +154,19 @@ export const uploadFile = async (data: FormData, options?: UploadFileOptions) =>
});
return response as UploadFileResponse;
};
// 上传 QA 文件
export const uploadQaFile = async (data: FormData, options?: UploadFileOptions) => {
const { kb_id, parent_id, onUploadProgress, signal } = options || {};
const params: Record<string, string> = {};
if (kb_id) params.kb_id = kb_id;
if (parent_id) params.parent_id = parent_id;
const response = await request.uploadFile(`/chunks/${kb_id}/import_qa`, data, {
params,
onUploadProgress,
signal,
});
return response as UploadFileResponse;
};
// 下载文件
export const downloadFile = async (fileId: string, fileName?: string) => {
@@ -293,7 +306,10 @@ export const updateDocumentChunk = async (kb_id:string, document_id:string, doc_
const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data);
return response as any;
};
export const deleteDocumentChunk = async (kb_id: string, document_id: string, doc_id: string) => {
const response = await request.delete(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`);
return response as any;
};
// 文档块儿创建
export const createDocumentChunk = async (kb_id:string, document_id:string, data: any) => {
const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data);

View File

@@ -709,6 +709,8 @@ export const en = {
localFile: 'Local File',
uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files',
webLink: 'Web Link',
csvFile: 'Tabular Dataset',
csvUploadFileTypes: 'Upload files in CSV format',
webLinkPlaceholder:'Please enter',
webLinkDesc: 'Only static links are supported. If the uploaded data shows as empty, the link may not be readable. One per line, with a maximum of {{count}} links at a time',
selectorTutorial: 'Selector Usage Tutorial',

View File

@@ -194,6 +194,8 @@ export const zh = {
localFile: '本地文件',
uploadFileTypes: '上传 PDF、 TXT、 DOCX、 IMAGE、 MEDIA 等格式的文件',
webLink: '网页链接',
csvFile: '表格数据集',
csvUploadFileTypes: '上传 CSV 格式的文件',
webLinkPlaceholder: '请输入',
webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接',
selectorTutorial: '选择器使用教程',
@@ -283,6 +285,7 @@ export const zh = {
qaExtract: '问答对提取',
default: '默认',
customize: '自定义',
qaPrompt: 'QA 拆分引导词',
defaultSettings: '使用系统默认的参数和规则',
customSettings: '自定义设置数据处理规则',
fileName: '文件名称',

View File

@@ -10,7 +10,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd';
import UploadFiles from '@/components/Upload/UploadFiles';
import type { UploadRequestOption } from 'rc-upload/lib/interface';
import { uploadFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import { uploadFile, uploadQaFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import SliderInput from '@/components/SliderInput';
@@ -38,7 +38,7 @@ const { TextArea } = Input;
});
type SourceType = 'local' | 'link' | 'text';
type SourceType = 'local' | 'link' | 'text' | 'csv';
type ProcessingMethod = 'directBlock' | 'qaExtract';
type ParameterSettings = 'defaultSettings' | 'customSettings';
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
@@ -63,6 +63,8 @@ interface ContentFormData {
title: string;
content: string;
}
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt', 'png', 'jpg', 'mp3', 'mp4', 'mov', 'wav']
const csvFileType = ['csv']
const CreateDataset = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -91,11 +93,12 @@ const CreateDataset = () => {
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
const [blockSize, setBlockSize] = useState<number>(130);
const [qaPrompt, setQaPrompt] = useState<string | undefined>()
console.log('qaPrompt', qaPrompt)
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true);
const [pdfEnhancementMethod, setPdfEnhancementMethod] = useState<string>('mineru');
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg','mp3','mp4','mov','wav']
const steps = useMemo(
() => [
{ title: t('knowledgeBase.selectFile') },
@@ -112,8 +115,11 @@ const CreateDataset = () => {
const handleNext = async () => {
// Temporarily hide step 3: adjust step index (0->1->2 corresponds to select file->parameter settings->confirm upload)
let nextStep = current + 1;
if (current === 0 && source === 'csv') {
return
}
if(nextStep === 1 && source === 'local') {
if((nextStep === 1 && source === 'local') || (nextStep === 2 && source === 'csv')) {
// Check if files have been uploaded
if (rechunkFileIds.length === 0) {
// If no files, prompt user to upload first
@@ -159,6 +165,7 @@ const CreateDataset = () => {
delimiter: delimiter,
chunk_token_num: blockSize,
auto_questions: processingMethod === 'directBlock' ? 0 : 1,
qa_prompt: qaPrompt
}
}
updateDocument(id, params)
@@ -378,6 +385,32 @@ const CreateDataset = () => {
formData.append('parent_id', parentId);
}
if (source === 'csv') {
uploadQaFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
signal: abortController.signal,
})
.then((res: UploadFileResponse) => {
// Upload successful, remove AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
messageApi.success(t('knowledgeBase.uploadSuccess'))
handleBack()
})
.catch((error) => {
// Remove AbortController
abortControllersRef.current.delete(fileUid);
// If user actively cancelled, don't show error message
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('Upload cancelled:', (file as File).name);
return;
}
onError?.(error as Error);
});
} else {
uploadFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
@@ -412,6 +445,7 @@ const CreateDataset = () => {
}
onError?.(error as Error);
});
}
};
@@ -557,21 +591,21 @@ const CreateDataset = () => {
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
<div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'>
{source !== 'csv' && <div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'>
<Steps current={current} items={steps} className="custom-steps" />
</div>
</div> }
<div className='rb:bg-white rb:rounded-xl rb:flex-1 rb:mt-3'>
{current === 0 && (
<div className='rb:flex rb:w-full rb:p-6'>
{source && source === 'local' && (
{source && (source === 'local' || source === 'csv') && (
<UploadFiles
ref={uploadRef}
isCanDrag={true}
fileSize={100}
multiple={true}
maxCount={99}
fileType={fileType}
multiple={source !== 'csv'}
maxCount={source === 'csv' ? 1 : 99}
fileType={source === 'csv' ? csvFileType : fileType}
customRequest={handleUpload}
onChange={(fileList) => {
console.log('File list changed:', fileList);
@@ -765,7 +799,7 @@ const CreateDataset = () => {
</Flex>
</Radio>
</Radio.Group>
{parameterSettings === 'customSettings' && (
{parameterSettings === 'customSettings' && (<>
<div className='rb:grid rb:grid-cols-2 rb:mt-5 rb-border rb:rounded-xl rb:px-6 rb:py-4 rb:gap-10'>
<div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
@@ -775,8 +809,13 @@ const CreateDataset = () => {
</div>
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
</div>
)}
<div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2 rb:mt-4'>
{t('knowledgeBase.qaPrompt')}
</div>
<Input.TextArea value={qaPrompt} rows={6} onChange={(e) => setQaPrompt(e.target.value)} />
</div>
</>)}
</div>
)}
@@ -853,7 +892,7 @@ const CreateDataset = () => {
{t('common.previous') || 'Prev'}
</Button>
)}
<Button
{source !== 'csv' && <Button
type='primary'
onClick={current === 2 ? handleStartUpload : handleNext}
disabled={
@@ -863,7 +902,7 @@ const CreateDataset = () => {
}
>
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
</Button>
</Button>}
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import { useNavigate, useParams, useLocation, useSearchParams } from 'react-rout
import { useTranslation } from 'react-i18next';
import { useBreadcrumbManager, type BreadcrumbPath } from '@/hooks/useBreadcrumbManager';
import { Button, Spin, message, Switch, App } from 'antd';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk, getFileUrl } from '@/api/knowledgeBase';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '@/api/knowledgeBase';
import type { KnowledgeBaseDocumentData, RecallTestData } from '@/views/KnowledgeBase/types';
import { formatDateTime } from '@/utils/format';
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
@@ -20,7 +20,6 @@ import SearchInput from '@/components/SearchInput';
import DocumentPreview from '@/components/DocumentPreview';
import InsertModal, { type InsertModalRef } from '../components/InsertModal';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
const imagePath = 'https://devapi.mem.redbearai.com'
import copy from 'copy-to-clipboard'
const DocumentDetails: FC = () => {
const { t } = useTranslation();
@@ -228,6 +227,11 @@ const DocumentDetails: FC = () => {
}
};
const refreshChunks = () => {
let nextPage = 1;
setPage(nextPage);
ChunkList(nextPage);
}
const loadMoreChunks = () => {
const nextPage = page + 1;
setPage(nextPage);
@@ -363,8 +367,8 @@ const DocumentDetails: FC = () => {
fileName={document?.file_name}
fileExt={document?.file_ext}
height="calc(100% - 40px)"
mode="google"
showModeSwitch={true}
// mode="google"
// showModeSwitch={true}
/>
</div>
)}
@@ -425,7 +429,7 @@ const DocumentDetails: FC = () => {
{t('knowledgeBase.chunkList') || '分块列表'}
</h2>
<RecallTestResult
refresh={refreshChunks}
data={chunkList}
showEmpty={false}
hasMore={hasMore}

View File

@@ -55,6 +55,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
title: t('knowledgeBase.customText'),
description: t('knowledgeBase.manuallyInputText')
},
{
title: t('knowledgeBase.csvFile'),
description: t('knowledgeBase.csvUploadFileTypes')
},
]
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
@@ -86,7 +90,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
// description: selected.description,
// });
// 跳转到创建数据集页面并携带来源参数
const source = value === 0 ? 'local' : value === 1 ? 'link' : 'text';
const source = value === 3 ? 'csv' : value === 0 ? 'local' : value === 1 ? 'link' : 'text';
if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
state: {
@@ -140,6 +144,12 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex>
</Radio>
<Radio value={3} style={getActiveRadioStyle(value === 3)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[2].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span>
</Flex>
</Radio>
</Radio.Group>
</div>
</div>

View File

@@ -7,20 +7,22 @@
* @LastEditTime: 2025-12-22 13:47:53
*/
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
import { Skeleton, Flex, Space } from 'antd';
import { Skeleton, Flex, Space, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { RecallTestData } from '@/views/KnowledgeBase/types';
import { NoData } from './noData';
import { formatDateTime } from '@/utils/format';
import InfiniteScroll from 'react-infinite-scroll-component';
import RbMarkdown from '@/components/Markdown';
import { useMemo } from 'react';
import { useMemo, type MouseEvent } from 'react';
import { deleteDocumentChunk } from '@/api/knowledgeBase'
interface RecallTestResultProps {
data: RecallTestData[];
showEmpty?: boolean;
hasMore?: boolean;
loadMore?: () => void;
refresh?: () => void;
loading?: boolean;
scrollableTarget?: string;
editable?: boolean; // Whether editable
@@ -34,6 +36,7 @@ const RecallTestResult = ({
showEmpty = true,
hasMore = false,
loadMore,
refresh,
loading = false,
scrollableTarget,
editable = false,
@@ -42,6 +45,7 @@ const RecallTestResult = ({
handleCopy,
}: RecallTestResultProps) => {
const { t } = useTranslation();
const { modal, message } = App.useApp()
console.log('chunk data', data)
// Parse QA format content
@@ -133,6 +137,24 @@ const RecallTestResult = ({
return 'rb:text-[#FF5D34]';
}
};
const handleDelete = (e: MouseEvent, item: RecallTestData) => {
e.preventDefault();
e.stopPropagation();
modal.confirm({
title: t('common.confirmDeleteDesc', { name: `chunk_${item.metadata?.sort_id}` }),
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: () => {
deleteDocumentChunk(item.metadata.knowledge_id, item.metadata.document_id, item.metadata.doc_id)
.then(() => {
message.success(t('common.deleteSuccess'));
refresh?.()
})
}
})
console.log('RecallTestData', item)
}
// Show skeleton when initial loading
if (loading && data.length === 0) {
@@ -186,17 +208,21 @@ const RecallTestResult = ({
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
</span>
)}
<div className={`rb:flex rb:mt-2 rb:flex rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
<div className={`rb:flex rb:mt-2 rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
<span className='rb:text-gray-800'>
<FileOutlined /> {item.metadata?.file_name || '-'}
</span>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-[2px] rb:rounded'>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-0.5 rb:rounded'>
chunk_{item.metadata?.sort_id || index}
</span>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/delete.svg')] rb:hover:bg-[url('@/assets/images/common/delete_hover.svg')]"
onClick={(e) => handleDelete(e, item)}
></div>
</div>
</div>
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-white rb:rounded-lg rb:mt-2'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:break-words rb:w-full'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:wrap-break-word rb:w-full'>
{(() => {
const qaContent = parseQAContent(item.page_content);
if (qaContent) {
@@ -239,7 +265,7 @@ const RecallTestResult = ({
<div className='rb:flex rb:h-full rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
<span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>
@@ -262,7 +288,7 @@ const RecallTestResult = ({
<div className='rb:flex rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
<span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>

View File

@@ -16,6 +16,7 @@ import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty'
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
import copy from 'copy-to-clipboard'
import InfiniteScroll from 'react-infinite-scroll-component';
@@ -527,6 +528,10 @@ const KnowledgeBaseManagement: FC = () => {
fetchData(1, false);
}
}, [modelTypes, query.parent_id, query.keywords, query.orderby, query.desc])
const handleCopy = (value: string) => {
copy(value)
messageApi.success(t('common.copySuccess'))
}
return (
<>
@@ -595,6 +600,13 @@ const KnowledgeBaseManagement: FC = () => {
</Tooltip>
</div>
<Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'>
<div className="rb:cursor-pointer rb:mb-3 rb:w-full" onClick={() => handleCopy(item.id)}>
<div className="rb:text-gray-800 rb:font-medium">ID:</div>
<Flex align="center" className="rb:text-[#5B6167]">
{item.id}
<span className="rb:ml-1 rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></span>
</Flex>
</div>
{item.descriptionItems?.map((description: Record<string, unknown>) => (
<div
key={description.key as string}

View File

@@ -95,7 +95,7 @@ export interface ParserConfig {
auto_keywords?: number; // 自动关键词
auto_questions?: number; // 自动问题
html4excel?: boolean; // 是否为Excel文件
graphrag: GraphragConfig; // 知识图谱生成
graphrag?: GraphragConfig; // 知识图谱生成
// Web 类型特有字段
entry_url?: string; // 入口网址
@@ -135,6 +135,7 @@ export interface KnowledgeBaseDocumentData { // 知识库文档数据
status?: number; // 状态 1 可检索 0 不可检索
created_at?: string; // 创建时间
updated_at?: string; // 更新时间
qa_prompt?: string; // 提示词
}
export interface DocumentModalRef {
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;