Merge #77 into develop_web from feature/20251219_yjp

feat(knowledgeBase): enhance file upload and dataset creation with abort support and improved UX

* feature/20251219_yjp: (1 commits)
  feat(knowledgeBase): enhance file upload and dataset creation with abort support and improved UX

Signed-off-by: vrhs@163.com <accounts_660b6454a0eb398d3f8d2c76@mail.teambition.com>
Merged-by: vrhs@163.com <accounts_660b6454a0eb398d3f8d2c76@mail.teambition.com>

CR-link: https://codeup.aliyun.com/redbearai/python/redbear-mem-open/change/77
This commit is contained in:
vrhs@163.com
2025-12-29 19:14:36 +08:00
9 changed files with 311 additions and 107 deletions

View File

@@ -64,8 +64,8 @@ export const getModelTypeList = async () => {
return response as any[]; return response as any[];
}; };
// 获取模型列表 // 获取模型列表
export const getModelList = async (type: string | string[], pageInfo: PageRequest) => { export const getModelList = async (pageInfo: PageRequest) => {
const response = await request.get(`${apiPrefix}/models`, { type, ...pageInfo }); const response = await request.get(`${apiPrefix}/models`, pageInfo);
return response as any; return response as any;
}; };
//获取模型提供者 //获取模型提供者
@@ -135,16 +135,18 @@ interface UploadFileOptions {
kb_id?: string; kb_id?: string;
parent_id?: string; parent_id?: string;
onUploadProgress?: (event: AxiosProgressEvent) => void; onUploadProgress?: (event: AxiosProgressEvent) => void;
signal?: AbortSignal;
} }
// 上传文件 // 上传文件
export const uploadFile = async (data: FormData, options?: UploadFileOptions) => { export const uploadFile = async (data: FormData, options?: UploadFileOptions) => {
const { kb_id, parent_id, onUploadProgress } = options || {}; const { kb_id, parent_id, onUploadProgress, signal } = options || {};
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (kb_id) params.kb_id = kb_id; if (kb_id) params.kb_id = kb_id;
if (parent_id) params.parent_id = parent_id; if (parent_id) params.parent_id = parent_id;
const response = await request.uploadFile(`${apiPrefix}/files/file`, data, { const response = await request.uploadFile(`${apiPrefix}/files/file`, data, {
params, params,
onUploadProgress, onUploadProgress,
signal,
}); });
return response as UploadFileResponse; return response as UploadFileResponse;
}; };

View File

@@ -38,6 +38,8 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
maxCount?: number; maxCount?: number;
/** 是否支持拖拽上传默认为false */ /** 是否支持拖拽上传默认为false */
isCanDrag?: boolean; isCanDrag?: boolean;
/** 自定义移除文件回调 */
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
} }
const ALL_FILE_TYPE: { const ALL_FILE_TYPE: {
[key: string]: string; [key: string]: string;
@@ -77,6 +79,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
isAutoUpload = true, isAutoUpload = true,
maxCount = 1, maxCount = 1,
isCanDrag = false, isCanDrag = false,
onRemove: customOnRemove,
...props ...props
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -86,11 +89,20 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
// 处理文件移除 // 处理文件移除
const handleRemove = (file: UploadFile) => { const handleRemove = (file: UploadFile) => {
// 如果有自定义的 onRemove 回调,先执行它
if (customOnRemove) {
const result = customOnRemove(file);
// 如果返回 false阻止移除
if (result === false) {
return false;
}
}
confirm({ confirm({
title: '确定要删除此文件吗?', title: `${t('common.confirmRemoveFile')}`,
okText: '确定', okText: `${t('common.confirm')}`,
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: `${t('common.cancel')}`,
onOk: () => { onOk: () => {
const newFileList = fileList.filter((item) => item.uid !== file.uid); const newFileList = fileList.filter((item) => item.uid !== file.uid);
setFileList(newFileList); setFileList(newFileList);
@@ -236,7 +248,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
<div key={file.uid} className="rb:relative rb:w-full rb:pt-[8px] rb:pl-[10px] rb:pr-[10px] rb-pb-[10px] rb:border-1 rb:border-[#EBEBEB] rb:rounded rb:p-2 rb:mt-2 rb:bg-white"> <div key={file.uid} className="rb:relative rb:w-full rb:pt-[8px] rb:pl-[10px] rb:pr-[10px] rb-pb-[10px] rb:border-1 rb:border-[#EBEBEB] rb:rounded rb:p-2 rb:mt-2 rb:bg-white">
<div className="rb:text-[12px] rb:flex rb:items-center rb:justify-between rb:mb-[2px]"> <div className="rb:text-[12px] rb:flex rb:items-center rb:justify-between rb:mb-[2px]">
{file.name} {file.name}
<span className="rb:text-[#5B6167]" onClick={() => actions?.remove()}>Cancel</span> <span className="rb:text-[#5B6167] rb:cursor-pointer" onClick={() => actions?.remove()}>Cancel</span>
</div> </div>
<Progress percent={file.percent || 0} strokeColor={file.status === 'error' ? '#FF5D34' : '#155EEF'} size="small" showInfo={false} /> <Progress percent={file.percent || 0} strokeColor={file.status === 'error' ? '#FF5D34' : '#155EEF'} size="small" showInfo={false} />
</div> </div>

View File

@@ -296,7 +296,9 @@ export const en = {
add: 'Add', add: 'Add',
addOption: 'Add Option', addOption: 'Add Option',
viewDetail: 'View Detail', viewDetail: 'View Detail',
confirmRemoveFile: 'Are you sure you want to remove this file?',
deleteSuccess: 'Delete successfully', deleteSuccess: 'Delete successfully',
deleteFailed: 'Delete Failure',
foldUp: 'Collapse', foldUp: 'Collapse',
expanded: 'Expand', expanded: 'Expand',
clickUploadIcon: 'click on the upload icon', clickUploadIcon: 'click on the upload icon',
@@ -473,7 +475,7 @@ export const en = {
knowledgeBase: 'Knowledge Base', knowledgeBase: 'Knowledge Base',
selectDataSource: 'Select Source', selectDataSource: 'Select Source',
localFile: 'Local File', localFile: 'Local File',
uploadFileTypes: 'Upload PDF, TXT, DOCX and other format files', uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files',
webLink: 'Web Link', webLink: 'Web Link',
webLinkPlaceholder:'Please enter', 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', 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',
@@ -481,6 +483,7 @@ export const en = {
readStaticWebPage: 'Read static web page content as dataset', readStaticWebPage: 'Read static web page content as dataset',
customText: 'Custom Text', customText: 'Custom Text',
customContent: 'Custom Content', customContent: 'Custom Content',
createContentError: 'Failed to create custom content',
manuallyInputText: 'Manually input a text as dataset', manuallyInputText: 'Manually input a text as dataset',
importTemplate: 'Template Import', importTemplate: 'Template Import',
importBackup: 'Backup Import', importBackup: 'Backup Import',

View File

@@ -94,7 +94,7 @@ export const zh = {
operation: '操作', operation: '操作',
selectDataSource: '选择来源', selectDataSource: '选择来源',
localFile: '本地文件', localFile: '本地文件',
uploadFileTypes: '上传 PDF、TXT、DOCX 等格式的文件', uploadFileTypes: '上传 PDF、TXT、DOCX, IMAGE, MEDIA 等格式的文件',
webLink: '网页链接', webLink: '网页链接',
webLinkPlaceholder: '请输入', webLinkPlaceholder: '请输入',
webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接', webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接',
@@ -102,6 +102,7 @@ export const zh = {
readStaticWebPage: '读取静态网页内容作为数据集', readStaticWebPage: '读取静态网页内容作为数据集',
customText: '自定义文本', customText: '自定义文本',
customContent: '自定义内容', customContent: '自定义内容',
createContentError: '创建自定义文件失败',
manuallyInputText: '手动输入一段文本作为数据集', manuallyInputText: '手动输入一段文本作为数据集',
openKnowledgeBase: '打开知识库', openKnowledgeBase: '打开知识库',
searchPlaceholder: '搜索', searchPlaceholder: '搜索',
@@ -796,7 +797,9 @@ export const zh = {
add: '添加', add: '添加',
addOption: '添加选项', addOption: '添加选项',
viewDetail: '查看详情', viewDetail: '查看详情',
confirmRemoveFile: '确定要移除此文件吗?',
deleteSuccess: '删除成功', deleteSuccess: '删除成功',
deleteFailed: '删除失败',
foldUp: '收起', foldUp: '收起',
expanded: '展开', expanded: '展开',
clickUploadIcon: '点击上传图标', clickUploadIcon: '点击上传图标',

View File

@@ -1,14 +1,15 @@
import { useMemo,useRef, useState, useEffect } from 'react'; import { useMemo,useRef, useState, useEffect } from 'react';
import { Button, Flex, Radio, Steps, Modal, Input, Spin, message, Checkbox, Select} from 'antd'; import { Button, Flex, Radio, Steps, Modal, Input, Spin, message, Checkbox, Select, Form} from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import Table, { type TableRef } from '@/components/Table' import Table, { type TableRef } from '@/components/Table'
import type { AnyObject } from 'antd/es/_util/type'; import type { AnyObject } from 'antd/es/_util/type';
import type { UploadFileResponse,KnowledgeBaseDocumentData } from '@/views/KnowledgeBase/types'; import type { UploadFileResponse,KnowledgeBaseDocumentData } from '@/views/KnowledgeBase/types';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd';
import UploadFiles from '@/components/Upload/UploadFiles'; import UploadFiles from '@/components/Upload/UploadFiles';
import type { UploadRequestOption } from 'rc-upload/lib/interface'; import type { UploadRequestOption } from 'rc-upload/lib/interface';
import { uploadFile, getDocumentList, parseDocument, updateDocument, deleteDocument } from '@/api/knowledgeBase'; import { uploadFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import SliderInput from '@/components/SliderInput'; import SliderInput from '@/components/SliderInput';
@@ -56,7 +57,10 @@ interface CreateDatasetLocationState {
fileId?: string | string[]; fileId?: string | string[];
fileIds?: string | string[]; fileIds?: string | string[];
} }
interface ContentFormData {
title: string;
content: string;
}
const CreateDataset = () => { const CreateDataset = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -75,7 +79,7 @@ const CreateDataset = () => {
const [current, setCurrent] = useState<number>(stepIndexMap[initialStepKey]); const [current, setCurrent] = useState<number>(stepIndexMap[initialStepKey]);
const tableRef = useRef<TableRef>(null); const tableRef = useRef<TableRef>(null);
const [form] = Form.useForm<ContentFormData>();
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]); const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds); const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
@@ -98,12 +102,15 @@ const CreateDataset = () => {
], ],
[t], [t],
); );
// 存储每个文件的 AbortController用于取消上传
const handleNext = () => { const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null);
console.log('上传文件',uploadRef.current?.fileList.length)
const handleNext = async () => {
// 暂时隐藏第三步调整步骤索引0->1->2 对应 选择文件->参数设置->确认上传) // 暂时隐藏第三步调整步骤索引0->1->2 对应 选择文件->参数设置->确认上传)
let nextStep = current + 1; let nextStep = current + 1;
if(nextStep === 1) { if(nextStep === 1 && source === 'local') {
// 检查是否有文件已上传 // 检查是否有文件已上传
if (rechunkFileIds.length === 0) { if (rechunkFileIds.length === 0) {
// 如果没有文件,提示用户先上传文件 // 如果没有文件,提示用户先上传文件
@@ -113,6 +120,27 @@ const CreateDataset = () => {
}); });
return; // 不进入下一步 return; // 不进入下一步
} }
}else if(nextStep === 1 && source === 'text'){
try {
const values = await form.validateFields();
// setLoading(true);
// TODO: 这里需要调用相应的API来保存内容
const params = {
// ...values,
kb_id: knowledgeBaseId,
parent_id: parentId,
};
const response = await createDocumentAndUpload(values, params)
if(response) {
setRechunkFileIds([response.id])
}
} catch (err) {
messageApi.error(t('knowledgeBase.createContentError'));
} finally {
// setLoading(false);
}
} }
// 从参数设置进入确认上传时的处理 // 从参数设置进入确认上传时的处理
@@ -262,7 +290,7 @@ const CreateDataset = () => {
media.onerror = () => { media.onerror = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
reject(new Error('无法读取媒体文件')); reject(new Error(`${t('knowledgeBase.unableReadFile')}`));
}; };
media.src = url; media.src = url;
@@ -273,18 +301,24 @@ const CreateDataset = () => {
const handleUpload = async (options: UploadRequestOption) => { const handleUpload = async (options: UploadRequestOption) => {
const { file, onSuccess, onError, onProgress, filename = 'file' } = options; const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
// 创建 AbortController 用于取消上传
const abortController = new AbortController();
const fileUid = (file as any).uid;
abortControllersRef.current.set(fileUid, abortController);
// 获取文件扩展名 // 获取文件扩展名
const fileExtension = (file as File).name.split('.').pop()?.toLowerCase(); const fileExtension = (file as File).name.split('.').pop()?.toLowerCase();
const mediaExtensions = ['mp3', 'mp4', 'mov', 'wav']; const mediaExtensions = ['mp3', 'mp4', 'mov', 'wav'];
// 如果是媒体文件,进行大小和时长检查 // 如果是媒体文件,进行大小和时长检查
if (fileExtension && mediaExtensions.includes(fileExtension)) { if (fileExtension && mediaExtensions.includes(fileExtension)) {
const fileSizeInMB = (file as File).size / (1024 * 1024); const fileSizeInMB = (file as File).size / (100 * 1024);
// 检查文件大小(256MB限制 // 检查文件大小(50MB限制
if (fileSizeInMB > 256) { if (fileSizeInMB > 100) {
messageApi.error(`${t('knowledgeBase.sizeLimitError')}${fileSizeInMB.toFixed(2)}MB`); messageApi.error(`${t('knowledgeBase.sizeLimitError')}${fileSizeInMB.toFixed(2)}MB`);
onError?.(new Error(`${t('knowledgeBase.fileSizeExceeds')}`)); onError?.(new Error(`${t('knowledgeBase.fileSizeExceeds')}`));
abortControllersRef.current.delete(fileUid);
return; return;
} }
@@ -294,11 +328,13 @@ const CreateDataset = () => {
if (duration > 150) { if (duration > 150) {
messageApi.error(`${t('knowledgeBase.fileDurationLimitError')}${Math.round(duration)}`); messageApi.error(`${t('knowledgeBase.fileDurationLimitError')}${Math.round(duration)}`);
onError?.(new Error(`${t('knowledgeBase.fileDurationExceeds')}`)); onError?.(new Error(`${t('knowledgeBase.fileDurationExceeds')}`));
abortControllersRef.current.delete(fileUid);
return; return;
} }
} catch (error) { } catch (error) {
messageApi.error(`${t('knowledgeBase.unableReadFile')}`); messageApi.error(`${t('knowledgeBase.unableReadFile')}`);
onError?.(error as Error); onError?.(error as Error);
abortControllersRef.current.delete(fileUid);
return; return;
} }
} }
@@ -315,6 +351,7 @@ const CreateDataset = () => {
uploadFile(formData, { uploadFile(formData, {
kb_id: knowledgeBaseId, kb_id: knowledgeBaseId,
parent_id: parentId, parent_id: parentId,
signal: abortController.signal,
onUploadProgress: (event) => { onUploadProgress: (event) => {
if (!event.total) return; if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100); const percent = Math.round((event.loaded / event.total) * 100);
@@ -332,6 +369,14 @@ const CreateDataset = () => {
} }
}) })
.catch((error) => { .catch((error) => {
// 移除 AbortController
abortControllersRef.current.delete(fileUid);
// 如果是用户主动取消,不显示错误信息
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('上传已取消:', (file as File).name);
return;
}
onError?.(error as Error); onError?.(error as Error);
}); });
}; };
@@ -419,23 +464,33 @@ const CreateDataset = () => {
setBlockSize(value); setBlockSize(value);
} }
} }
// 删除已上传的文件
// 当从其他页面跳转过来且带有 fileIds 时,加载对应的文档数据 const handleDeleteFile = async (fileId: string) => {
useEffect(() => { try {
if (initialFileIds.length > 0 && initialStepKey !== 'selectFile' && knowledgeBaseId && parentId) { await deleteDocument(fileId);
// 加载文档列表数据 // 删除成功,从 rechunkFileIds 中移除该 id
getDocumentList(knowledgeBaseId,{ setRechunkFileIds((prev) => prev.filter((id) => id !== fileId));
document_ids: initialFileIds.join(','), console.log(`${t('common.deleteSuccess')}`);
}) } catch (error) {
.then((res: any) => { messageApi.error(`${t('common.deleteFailed')}`);
const documents = res.items || [];
setData(documents);
})
.catch((error) => {
console.error('加载文档列表失败:', error);
});
} }
}, []); };
// 当从其他页面跳转过来且带有 fileIds 时,加载对应的文档数据
// useEffect(() => {
// if (initialFileIds.length > 0 && initialStepKey !== 'selectFile' && knowledgeBaseId && parentId) {
// // 加载文档列表数据
// getDocumentList(knowledgeBaseId,{
// document_ids: initialFileIds.join(','),
// })
// .then((res: any) => {
// const documents = res.items || [];
// setData(documents);
// })
// .catch((error) => {
// console.error('加载文档列表失败:', error);
// });
// }
// }, []);
// 清理函数:组件卸载时清除定时器和 loading 状态 // 清理函数:组件卸载时清除定时器和 loading 状态
useEffect(() => { useEffect(() => {
@@ -480,7 +535,40 @@ const CreateDataset = () => {
{current === 0 && ( {current === 0 && (
<div className='rb:flex rb:w-full rb:mt-10'> <div className='rb:flex rb:w-full rb:mt-10'>
{source && source === 'local' && ( {source && source === 'local' && (
<UploadFiles isCanDrag={true} fileSize={50} multiple={true} maxCount={99} fileType={fileType} customRequest={handleUpload} /> <UploadFiles
ref={uploadRef}
isCanDrag={true}
fileSize={100}
multiple={true}
maxCount={99}
fileType={fileType}
customRequest={handleUpload}
onChange={(fileList) => {
console.log('文件列表变化:', fileList);
}}
onRemove={async (file) => {
// 如果文件正在上传,取消上传
const fileUid = file.uid;
const abortController = abortControllersRef.current.get(fileUid);
if (abortController) {
abortController.abort();
abortControllersRef.current.delete(fileUid);
}
console.log('文件移除前:', uploadRef.current?.fileList);
// 如果文件已经上传成功删除服务器上的文件并从rechunkFileIds中移除对应的ID
if (file.response?.id) {
try {
await deleteDocument(file.response.id);
setRechunkFileIds(prev => prev.filter(id => id !== file.response.id));
} catch (error) {
console.error('删除文件失败:', error);
messageApi.error('删除文件失败');
}
}
return true; // 允许移除文件
}} />
)} )}
{source && source === 'link' && ( {source && source === 'link' && (
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'> <div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
@@ -500,15 +588,36 @@ const CreateDataset = () => {
)} )}
{source && source === 'text' && ( {source && source === 'text' && (
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'> <div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label={t('knowledgeBase.title')}
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
>
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
</Form.Item>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'> <Form.Item
name="content"
label={t('knowledgeBase.customContent')}
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
>
<Input.TextArea
placeholder={t('knowledgeBase.pleaseEnterContent')}
rows={8}
showCount
maxLength={5000}
/>
</Form.Item>
</Form>
{/* <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
{t('knowledgeBase.customText')} {t('knowledgeBase.customText')}
</div> </div>
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/> <Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'> <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
{t('knowledgeBase.customContent')} {t('knowledgeBase.customContent')}
</div> </div>
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> <TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> */}
</div> </div>
)} )}
</div> </div>
@@ -700,7 +809,7 @@ const CreateDataset = () => {
<Button <Button
type='primary' type='primary'
onClick={current === 2 ? handleStartUpload : handleNext} onClick={current === 2 ? handleStartUpload : handleNext}
disabled={pollingLoading} disabled={pollingLoading || (current === 0 && rechunkFileIds.length === 0)}
loading={pollingLoading} loading={pollingLoading}
> >
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'} {current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}

View File

@@ -321,28 +321,27 @@ const Private: FC = () => {
{ {
key: '2', key: '2',
icon: <img src={textIcon} alt="text" style={{ width: 16, height: 16 }} />, icon: <img src={textIcon} alt="text" style={{ width: 16, height: 16 }} />,
label: (<span>{t('knowledgeBase.text')} {t('knowledgeBase.dataset')}</span>), label: (<span>{t('knowledgeBase.createA')} {t('knowledgeBase.dataset')}</span>),
onClick: () => { onClick: () => {
datasetModalRef?.current?.handleOpen(knowledgeBase?.id,folder?.parent_id ?? knowledgeBase?.id ?? ''); datasetModalRef?.current?.handleOpen(knowledgeBase?.id,folder?.parent_id ?? knowledgeBase?.id ?? '');
}, },
}, },
{ // {
key: '8', // key: '8',
icon: <img src={blankIcon} alt="Custome Text" style={{ width: 16, height: 16 }} />, // icon: <img src={blankIcon} alt="Custome Text" style={{ width: 16, height: 16 }} />,
label: t('knowledgeBase.customTextDataset'), // label: t('knowledgeBase.mediaDataSet'),
onClick: () => { // onClick: () => {
createContentModalRef?.current?.handleOpen(knowledgeBase?.id ?? '', folder?.parent_id ?? knowledgeBase?.id ?? ''); // createContentModalRef?.current?.handleOpen(knowledgeBase?.id ?? '', folder?.parent_id ?? knowledgeBase?.id ?? '');
// handleCreate('folder'); // 传入 type: 'folder' // },
}, // },
}, // {
{ // key: '3',
key: '3', // icon: <img src={imageIcon} alt="image" style={{ width: 16, height: 16 }} />,
icon: <img src={imageIcon} alt="image" style={{ width: 16, height: 16 }} />, // label: t('knowledgeBase.imageDataSet'),
label: t('knowledgeBase.mediaDataSet'), // onClick: () => {
onClick: () => { // createImageDataset?.current?.handleOpen(knowledgeBaseId || '', parentId || '')
createImageDataset?.current?.handleOpen(knowledgeBaseId || '', parentId || '') // },
}, // },
},
// 暂时未实现 // 暂时未实现
// { // {
// key: '4', // key: '4',

View File

@@ -4,7 +4,7 @@
* @Author: yujiangping * @Author: yujiangping
* @Date: 2025-11-10 18:52:55 * @Date: 2025-11-10 18:52:55
* @LastEditors: yujiangping * @LastEditors: yujiangping
* @LastEditTime: 2025-11-24 11:23:33 * @LastEditTime: 2025-12-29 16:09:13
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import type { RadioChangeEvent } from 'antd'; import type { RadioChangeEvent } from 'antd';
@@ -51,10 +51,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
// title: t('knowledgeBase.webLink'), // title: t('knowledgeBase.webLink'),
// description: t('knowledgeBase.readStaticWebPage') // description: t('knowledgeBase.readStaticWebPage')
// }, // },
// { {
// title: t('knowledgeBase.customText'), title: t('knowledgeBase.customText'),
// description: t('knowledgeBase.manuallyInputText') description: t('knowledgeBase.manuallyInputText')
// }, },
] ]
// 封装取消方法,添加关闭弹窗逻辑 // 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => { const handleClose = () => {
@@ -111,7 +111,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
return ( return (
<RbModal <RbModal
title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.text') + ' ' + t('knowledgeBase.dataset')} title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.dataset')}
open={visible} open={visible}
onCancel={handleClose} onCancel={handleClose}
okText={t('common.create')} okText={t('common.create')}
@@ -133,13 +133,13 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span> <span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span> <span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex> </Flex>
</Radio> </Radio> */}
<Radio value={2} style={getActiveRadioStyle(value === 2)} className='rb:w-full'> <Radio value={2} style={getActiveRadioStyle(value === 2)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical> <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-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span> <span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex> </Flex>
</Radio> */} </Radio>
</Radio.Group> </Radio.Group>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,14 @@
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'; import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Form, Input, message } from 'antd'; import { Form, message } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { UploadFile } from 'antd'; import type { UploadFile } from 'antd';
import type { CreateSetModalRef, CreateSetMoealRefProps, UploadFileResponse } from '@/views/KnowledgeBase/types'; import type { CreateSetModalRef, CreateSetMoealRefProps } from '@/views/KnowledgeBase/types';
import type { UploadRequestOption } from 'rc-upload/lib/interface'; import type { UploadRequestOption } from 'rc-upload/lib/interface';
import RbModal from '@/components/RbModal'; import RbModal from '@/components/RbModal';
import UploadFiles from '@/components/Upload/UploadFiles'; import UploadFiles from '@/components/Upload/UploadFiles';
import { uploadFile } from '@/api/knowledgeBase'; import { uploadFile, deleteDocument } from '@/api/knowledgeBase';
interface ImageDatasetFormData { interface ImageDatasetFormData {
name: string; name: string;
@@ -26,16 +26,26 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [kbId, setKbId] = useState<string>(''); const [kbId, setKbId] = useState<string>('');
const [parentId, setParentId] = useState<string>(''); const [parentId, setParentId] = useState<string>('');
const [hasFiles, setHasFiles] = useState(false);
const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null); const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null);
// 存储每个文件的 AbortController用于取消上传
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
// const fileIds = []; // const fileIds = [];
const handleClose = () => { const handleClose = () => {
// 取消所有正在进行的上传
abortControllersRef.current.forEach((controller) => {
controller.abort();
});
abortControllersRef.current.clear();
form.resetFields(); form.resetFields();
uploadRef.current?.clearFiles(); uploadRef.current?.clearFiles();
setLoading(false); setLoading(false);
setVisible(false); setVisible(false);
setKbId(''); setKbId('');
setParentId(''); setParentId('');
setHasFiles(false);
}; };
const handleOpen = (kb_id: string, parent_id: string) => { const handleOpen = (kb_id: string, parent_id: string) => {
@@ -43,6 +53,7 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
setParentId(parent_id); setParentId(parent_id);
form.resetFields(); form.resetFields();
uploadRef.current?.clearFiles(); uploadRef.current?.clearFiles();
setHasFiles(false);
setVisible(true); setVisible(true);
}; };
@@ -120,21 +131,38 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
media.src = url; media.src = url;
}); });
}; };
// 删除已上传的文件
const handleDeleteFile = async (fileId: string) => {
try {
await deleteDocument(fileId);
console.log(`${t('common.deleteSuccess')}`);
} catch (error) {
messageApi.error(`${t('common.deleteFailed')}`);
}
};
// 上传文件 // 上传文件
const handleUpload = async (options: UploadRequestOption) => { const handleUpload = async (options: UploadRequestOption) => {
const { file, onSuccess, onError, onProgress, filename = 'file' } = options; const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
// 创建 AbortController 用于取消上传
const abortController = new AbortController();
const fileUid = (file as any).uid;
abortControllersRef.current.set(fileUid, abortController);
// 获取文件扩展名 // 获取文件扩展名
const fileExtension = (file as File).name.split('.').pop()?.toLowerCase(); const fileExtension = (file as File).name.split('.').pop()?.toLowerCase();
const mediaExtensions = ['mp3', 'mp4', 'mov', 'wav']; const mediaExtensions = ['mp3', 'mp4', 'mov', 'wav'];
// 如果是媒体文件,进行大小和时长检查 // 如果是媒体文件,进行大小和时长检查
if (fileExtension && mediaExtensions.includes(fileExtension)) { if (fileExtension && mediaExtensions.includes(fileExtension)) {
const fileSizeInMB = (file as File).size / (1024 * 1024); const fileSizeInMB = (file as File).size / (50 * 1024);
// 检查文件大小(256MB限制 // 检查文件大小(50MB限制
if (fileSizeInMB > 256) { if (fileSizeInMB > 50) {
messageApi.error(`${t('knowledgeBase.sizeLimitError')}${fileSizeInMB.toFixed(2)}MB`); messageApi.error(`${t('knowledgeBase.sizeLimitError')}${fileSizeInMB.toFixed(2)}MB`);
onError?.(new Error(`${t('knowledgeBase.fileSizeExceeds')}`)); onError?.(new Error(`${t('knowledgeBase.fileSizeExceeds')}`));
abortControllersRef.current.delete(fileUid);
return; return;
} }
@@ -144,11 +172,13 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
if (duration > 150) { if (duration > 150) {
messageApi.error(`${t('knowledgeBase.fileDurationLimitError')}${Math.round(duration)}`); messageApi.error(`${t('knowledgeBase.fileDurationLimitError')}${Math.round(duration)}`);
onError?.(new Error(`${t('knowledgeBase.fileDurationExceeds')}`)); onError?.(new Error(`${t('knowledgeBase.fileDurationExceeds')}`));
abortControllersRef.current.delete(fileUid);
return; return;
} }
} catch (error) { } catch (error) {
messageApi.error(`${t('knowledgeBase.unableReadFile')}`); messageApi.error(`${t('knowledgeBase.unableReadFile')}`);
onError?.(error as Error); onError?.(error as Error);
abortControllersRef.current.delete(fileUid);
return; return;
} }
} }
@@ -162,36 +192,53 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
formData.append('parent_id', parentId); formData.append('parent_id', parentId);
} }
uploadFile(formData, { try {
kb_id: kbId, const res = await uploadFile(formData, {
parent_id: parentId, kb_id: kbId,
onUploadProgress: (event) => { parent_id: parentId,
if (!event.total) return; signal: abortController.signal,
const percent = Math.round((event.loaded / event.total) * 100); onUploadProgress: (event) => {
onProgress?.({ percent }, file); if (!event.total) return;
}, const percent = Math.round((event.loaded / event.total) * 100);
}) onProgress?.({ percent }, file);
.then((res: UploadFileResponse) => { },
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
// 上传成功
// fileIds.push(res.id)
}
})
.catch((error) => {
onError?.(error as Error);
}); });
// 上传成功,移除 AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
// 上传成功
// fileIds.push(res.id)
}
} catch (error: any) {
// 移除 AbortController
abortControllersRef.current.delete(fileUid);
// 如果是用户主动取消,不显示错误信息
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('上传已取消:', (file as File).name);
return;
}
onError?.(error as Error);
}
}; };
return ( return (
<> <>
{contextHolder} {contextHolder}
<RbModal <RbModal
title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.imageDataSet')}`} title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.mediaDataSet')}`}
open={visible} open={visible}
onCancel={handleClose} onCancel={handleClose}
okText={t('common.create')} okText={t('common.create')}
onOk={handleSave} onOk={handleSave}
confirmLoading={loading} confirmLoading={loading}
maskClosable={false}
okButtonProps={{
disabled: loading || !hasFiles
}}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
{/* <Form.Item {/* <Form.Item
@@ -206,11 +253,31 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
<UploadFiles <UploadFiles
ref={uploadRef} ref={uploadRef}
isCanDrag={true} isCanDrag={true}
fileSize={100} fileSize={50}
multiple={true} multiple={true}
maxCount={99} maxCount={99}
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'mp4', 'mov', 'wav']} fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'mp4', 'mov', 'wav']}
customRequest={handleUpload} customRequest={handleUpload}
onChange={(fileList) => {
// 实时更新文件状态
setHasFiles(fileList.length > 0);
}}
onRemove={async (file) => {
// 如果文件正在上传,取消上传
const fileUid = file.uid;
const abortController = abortControllersRef.current.get(fileUid);
if (abortController) {
abortController.abort();
abortControllersRef.current.delete(fileUid);
}
// 如果文件已经上传成功,删除服务器上的文件
if (file.response?.id) {
await handleDeleteFile(file.response.id);
}
return true; // 允许移除文件
}}
/> />
</Form.Item> </Form.Item>
</Form> </Form>

View File

@@ -7,6 +7,9 @@ import RbModal from '@/components/RbModal'
const { TextArea } = Input; const { TextArea } = Input;
const { confirm } = Modal const { confirm } = Modal
// 全局模型数据常量
let models: any = null;
const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
refreshTable refreshTable
}, ref) => { }, ref) => {
@@ -46,20 +49,26 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
}; };
const fetchModelLists = async (types: string[]) => { const fetchModelLists = async (types: string[]) => {
// 如果 types 中包含 'llm',也需要获取 'chat' 的数据 // 如果还没有获取过全部模型数据,则获取一次
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types; if (!models) {
const entries = await Promise.all(typesToFetch.map(async (tp) => {
try { try {
const res = await getModelList(tp === 'image2text' ? 'chat' : tp, { page: 1, pagesize: 100 }); models = await getModelList({ page: 1, pagesize: 100 });
const options = (res?.items || []).map((m: any) => ({ label: m.name, value: m.id })); } catch (error) {
return [tp, options] as [string, { label: string; value: string }[]]; console.error('Failed to fetch models:', error);
} catch { models = { items: [] };
return [tp, []] as [string, { label: string; value: string }[]];
} }
})); }
// 从全部模型数据中过滤出需要的类型
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types;
const next: Record<string, { label: string; value: string }[]> = {}; const next: Record<string, { label: string; value: string }[]> = {};
entries.forEach(([k, v]) => { next[k] = v; });
typesToFetch.forEach((tp) => {
const targetType = tp === 'image2text' ? 'chat' : tp;
const filteredModels = (models?.items || []).filter((m: any) => m.type === targetType);
next[tp] = filteredModels.map((m: any) => ({ label: m.name, value: m.id }));
});
setModelOptionsByType(next); setModelOptionsByType(next);
}; };
@@ -157,7 +166,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
console.log('Validation failed:', err) console.log('Validation failed:', err)
}); });
} }
const handleChange = (value: string, tp: string) => { const handleChange = (_value: string, tp: string) => {
// 只在编辑模式且类型为 embedding 时触发提示 // 只在编辑模式且类型为 embedding 时触发提示
if (datasets?.id && tp.toLowerCase() === 'embedding') { if (datasets?.id && tp.toLowerCase() === 'embedding') {
const fieldKey = typeToFieldKey(tp); const fieldKey = typeToFieldKey(tp);