feat: Add base project structure with API and web components
This commit is contained in:
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal file
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import { useMemo,useRef, useState, useEffect } from 'react';
|
||||
import { Button, Flex, Radio, Steps, Modal, Input, Spin,message} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import Table, { type TableRef } from '@/components/Table'
|
||||
import type { AnyObject } from 'antd/es/_util/type';
|
||||
import type { UploadFileResponse,KnowledgeBaseDocumentData } from '../types';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import UploadFiles from '@/components/Upload/UploadFiles';
|
||||
import type { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
import { uploadFile, getDocumentList, previewDocumentChunk, parseDocument, updateDocument, deleteDocument } from '../service';
|
||||
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
|
||||
import { NoData } from '../components/noData';
|
||||
import noDataIcon from '@/assets/images/knowledgeBase/noData.png';
|
||||
import SliderInput from '@/components/SliderInput';
|
||||
import DelimiterSelector from '../components/DelimiterSelector';
|
||||
const { confirm } = Modal
|
||||
const { TextArea } = Input;
|
||||
import styles from '../index.module.css';
|
||||
const style: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
};
|
||||
const radioWrapperBaseStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
columnGap: 14, // 点与文字更宽的间距
|
||||
width: '100%',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
};
|
||||
const getActiveRadioStyle = (active: boolean): React.CSSProperties => ({
|
||||
...radioWrapperBaseStyle,
|
||||
border: active ? '1px solid #1677ff' : radioWrapperBaseStyle.border,
|
||||
});
|
||||
|
||||
|
||||
type SourceType = 'local' | 'link' | 'text';
|
||||
type ProcessingMethod = 'directBlock' | 'qaExtract';
|
||||
type ParameterSettings = 'defaultSettings' | 'customSettings';
|
||||
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
|
||||
type StepKey = typeof stepKeys[number];
|
||||
|
||||
const stepIndexMap: Record<StepKey, number> = {
|
||||
selectFile: 0,
|
||||
parameterSettings: 1,
|
||||
dataPreview: 2,
|
||||
confirmUpload: 3,
|
||||
};
|
||||
|
||||
interface CreateDatasetLocationState {
|
||||
source?: SourceType;
|
||||
knowledgeBaseId?: string;
|
||||
parentId?: string;
|
||||
startStep?: StepKey;
|
||||
fileId?: string;
|
||||
fileIds?: string[];
|
||||
}
|
||||
|
||||
const CreateDataset = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeBaseId: routeKnowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const location = useLocation();
|
||||
const locationState = (location.state ?? {}) as CreateDatasetLocationState;
|
||||
const source = (locationState.source ?? 'local') as SourceType;
|
||||
const knowledgeBaseId = locationState.knowledgeBaseId || routeKnowledgeBaseId;
|
||||
const parentId = locationState.parentId;
|
||||
const initialStepKey = locationState.startStep ?? 'selectFile';
|
||||
const initialFileIds = locationState.fileIds ?? (locationState.fileId ? [locationState.fileId] : []);
|
||||
const [current, setCurrent] = useState<number>(stepIndexMap[initialStepKey]);
|
||||
const tableRef = useRef<TableRef>(null);
|
||||
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
|
||||
const [chunkData, setChunkData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
|
||||
const [curSelectedFileId, setCurSelectedFileId] = useState<number>(-1);
|
||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||
const [pollingLoading, setPollingLoading] = useState<boolean>(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
|
||||
const [blockSize, setBlockSize] = useState<number>(130);
|
||||
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
|
||||
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg']
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{ title: t('knowledgeBase.selectFile') },
|
||||
{ title: t('knowledgeBase.parameterSettings') },
|
||||
// { title: t('knowledgeBase.dataPreview') }, // 暂时隐藏第三步
|
||||
{ title: t('knowledgeBase.confirmUpload') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleNext = () => {
|
||||
// 暂时隐藏第三步:调整步骤索引(0->1->2 对应 选择文件->参数设置->确认上传)
|
||||
let nextStep = current + 1;
|
||||
|
||||
if(nextStep === 1) {
|
||||
// 检查是否有文件已上传
|
||||
if (rechunkFileIds.length === 0) {
|
||||
// 如果没有文件,提示用户先上传文件
|
||||
Modal.warning({
|
||||
title: t('common.warning') || '提示',
|
||||
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
|
||||
});
|
||||
return; // 不进入下一步
|
||||
}
|
||||
}
|
||||
|
||||
// 从参数设置进入确认上传时的处理
|
||||
if(current === 1 && nextStep === 2) {
|
||||
// handlePreview(data[0],0)
|
||||
if(parameterSettings === 'customSettings'){
|
||||
rechunkFileIds.map((id) => {
|
||||
const params = {
|
||||
parser_config: {
|
||||
layout_recognize:'DeepDOC',
|
||||
delimiter: delimiter,
|
||||
chunk_token_num: blockSize,
|
||||
}
|
||||
}
|
||||
updateDocument(id, params)
|
||||
})
|
||||
}
|
||||
|
||||
// 立即执行一次,加载文档列表用于预览(不自动返回)
|
||||
pollDocumentStatus(false);
|
||||
}
|
||||
|
||||
// 限制最大步骤为 2(确认上传)
|
||||
setCurrent(Math.min(nextStep, 2));
|
||||
};
|
||||
const handlePrev = () => setCurrent((c) => Math.max(c - 1, 0));
|
||||
|
||||
// 开始上传:触发文档解析并启动轮询
|
||||
const handleStartUpload = () => {
|
||||
if (rechunkFileIds.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('common.warning') || '提示',
|
||||
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示确认弹框
|
||||
confirm({
|
||||
title: t('knowledgeBase.startUploadConfirmTitle') || '开始处理文档',
|
||||
content: t('knowledgeBase.startUploadConfirmContent') || '文档处理将在后台进行,您可以选择立即返回列表页或停留在此页面查看处理进度。',
|
||||
okText: t('knowledgeBase.returnToList') || '返回列表页',
|
||||
cancelText: t('knowledgeBase.stayOnPage') || '停留在此页',
|
||||
onOk: () => {
|
||||
// 用户选择返回列表页
|
||||
startProcessing(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
// 用户选择停留在当前页
|
||||
startProcessing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 实际开始处理的函数
|
||||
const startProcessing = (autoReturnToList: boolean) => {
|
||||
// 触发文档解析
|
||||
rechunkFileIds.map((id) => {
|
||||
parseDocument(id);
|
||||
});
|
||||
|
||||
// 开启 loading
|
||||
setPollingLoading(true);
|
||||
|
||||
if (autoReturnToList) {
|
||||
// 用户选择立即返回,直接跳转
|
||||
console.log('用户选择立即返回列表页');
|
||||
handleBack();
|
||||
} else {
|
||||
// 用户选择停留,启动轮询查看进度
|
||||
console.log('用户选择停留查看进度');
|
||||
|
||||
// 立即执行一次轮询(启用自动返回)
|
||||
pollDocumentStatus(true);
|
||||
|
||||
// 然后每3秒执行一次(启用自动返回)
|
||||
pollingTimerRef.current = setInterval(() => {
|
||||
pollDocumentStatus(true);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
const handleDelete = (record: AnyObject) => {
|
||||
confirm({
|
||||
title: t('common.deleteWarning'),
|
||||
content: t('common.deleteWarningContent', { content: record.name }),
|
||||
onOk: async () => {
|
||||
// TODO: 实现删除逻辑
|
||||
const response = await deleteDocument(record.id);
|
||||
|
||||
// 删除成功,刷新列表
|
||||
// messageApi.success(t('common.deleteSuccess'));
|
||||
messageApi.success(t('common.deleteSuccess'));
|
||||
tableRef.current?.loadData();
|
||||
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('取消删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
// 表格列配置
|
||||
const columns: ColumnsType = [
|
||||
{
|
||||
title: t('knowledgeBase.name'),
|
||||
dataIndex: 'file_name',
|
||||
key: 'file_name'
|
||||
},
|
||||
|
||||
{
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (value: number) => {
|
||||
return (
|
||||
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
|
||||
<span className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full" style={{ backgroundColor: value === 1 ? '#369F21' : '#FF8A4C' }}></span>
|
||||
<span>{value === 1 ? 'Completed' : 'Processing'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Button type='text' danger onClick={() => handleDelete(record)}>{t('common.delete')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
// 上传文件
|
||||
const handleUpload = (options: UploadRequestOption) => {
|
||||
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(filename, file as File);
|
||||
if (knowledgeBaseId) {
|
||||
formData.append('kb_id', knowledgeBaseId);
|
||||
}
|
||||
if (parentId) {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
uploadFile(formData, {
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
onUploadProgress: (event) => {
|
||||
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) {
|
||||
setRechunkFileIds((prev) => {
|
||||
if (prev.includes(res.id)) return prev;
|
||||
const next = [...prev, res.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error as Error);
|
||||
});
|
||||
};
|
||||
// 点击文件 预览分块
|
||||
const handlePreview = async(item: KnowledgeBaseDocumentData, index: number) => {
|
||||
setCurSelectedFileId(index);
|
||||
setPreviewLoading(true);
|
||||
try{
|
||||
const res = await previewDocumentChunk(knowledgeBaseId ?? '', item.id ?? '');
|
||||
setChunkData(res.items || []);
|
||||
setTotal(res.page.total || 0);
|
||||
console.log('res', res);
|
||||
}catch(error) {
|
||||
console.log('error', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询检查文档处理状态
|
||||
// autoReturn: 是否在所有文档完成时自动返回列表页
|
||||
const pollDocumentStatus = (autoReturn: boolean = false) => {
|
||||
if (!knowledgeBaseId || !parentId || rechunkFileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新 Table 组件的数据(仅在 confirmUpload 步骤)
|
||||
if (current === 2) {
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
|
||||
// 同时获取文档列表检查是否全部完成
|
||||
getDocumentList({
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
document_ids: rechunkFileIds.join(','),
|
||||
})
|
||||
.then((res: any) => {
|
||||
const documents = res.items || [];
|
||||
setData(documents);
|
||||
|
||||
// 检查是否所有文档的 progress 都为 1
|
||||
const allCompleted = documents.every((doc: KnowledgeBaseDocumentData) => doc.progress === 1);
|
||||
|
||||
console.log('轮询状态:', documents.map((d: KnowledgeBaseDocumentData) => ({ name: d.file_name, progress: d.progress })));
|
||||
|
||||
// 只有在 autoReturn 为 true 且所有文档完成时才自动返回
|
||||
if (allCompleted && autoReturn) {
|
||||
// 所有文档处理完成,清除定时器和 loading
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
setPollingLoading(false);
|
||||
|
||||
// 延迟 2 秒后跳转,让用户看到完成状态
|
||||
console.log('所有文档处理完成,2秒后返回列表页');
|
||||
setTimeout(() => {
|
||||
handleBack();
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('轮询文档状态失败:', error);
|
||||
setPollingLoading(false);
|
||||
});
|
||||
};
|
||||
const handleBack = () => {
|
||||
if (knowledgeBaseId) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/private`, {
|
||||
state: {
|
||||
refresh: true,
|
||||
timestamp: Date.now(), // 添加时间戳确保每次都是新的 state
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn('缺少路由参数,无法返回');
|
||||
}
|
||||
};
|
||||
const handleChange = (value: number | null) =>{
|
||||
if (value !== null) {
|
||||
setBlockSize(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 当从其他页面跳转过来且带有 fileIds 时,加载对应的文档数据
|
||||
useEffect(() => {
|
||||
if (initialFileIds.length > 0 && initialStepKey !== 'selectFile' && knowledgeBaseId && parentId) {
|
||||
// 加载文档列表数据
|
||||
getDocumentList({
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
document_ids: initialFileIds.join(','),
|
||||
})
|
||||
.then((res: any) => {
|
||||
const documents = res.items || [];
|
||||
setData(documents);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('加载文档列表失败:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理函数:组件卸载时清除定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
setPollingLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<div className='rb:p-6 rb:pt-2 rb:h-full'>
|
||||
{/* <Typography.Title level={4} className='rb:!m-0 rb:!mb-4'>
|
||||
{t('knowledgeBase.createA') + ' ' + t('knowledgeBase.dataset')}
|
||||
</Typography.Title> */}
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<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-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED]'>
|
||||
<Steps current={current} items={steps} />
|
||||
</div>
|
||||
|
||||
|
||||
{current === 0 && (
|
||||
<div className='rb:flex rb:w-full rb:mt-10'>
|
||||
{source && source === 'local' && (
|
||||
<UploadFiles isCanDrag={true} fileSize={50} multiple={true} maxCount={99} fileType={fileType} customRequest={handleUpload} />
|
||||
)}
|
||||
{source && source === 'link' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3 rb:max-w-[558px]'>
|
||||
{t('knowledgeBase.webLinkDesc')}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
</div>
|
||||
)}
|
||||
{source && source === 'text' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<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'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current === 1 && (
|
||||
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
|
||||
{rechunkFileIds.length > 0 && (
|
||||
<div className='rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded rb:px-3 rb:py-2 rb:mb-4 rb:text-xs rb:text-gray-600 rb:flex rb:flex-wrap rb:gap-2'>
|
||||
<span className='rb:text-gray-700 rb:font-medium'>{t('knowledgeBase.rechunking')}:</span>
|
||||
{rechunkFileIds.map((id) => (
|
||||
<span key={id} className='rb:px-2 rb:py-0.5 rb:bg-white rb:border rb:border-[#DFE4ED] rb:rounded'>{id}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.dataProcessingSettings')}
|
||||
</div>
|
||||
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
|
||||
{t('knowledgeBase.processingMethod')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={processingMethod}
|
||||
onChange={(e) => setProcessingMethod(e.target.value)}
|
||||
style={style}
|
||||
>
|
||||
<Radio value='directBlock' style={getActiveRadioStyle(processingMethod === 'directBlock')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.directBlock')}
|
||||
</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
<Radio value='qaExtract' style={getActiveRadioStyle(processingMethod === 'qaExtract')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.qaExtract')}
|
||||
</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
|
||||
{t('knowledgeBase.parameterSettings')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={parameterSettings}
|
||||
onChange={(e) => setParameterSettings(e.target.value)}
|
||||
style={style}
|
||||
>
|
||||
<Radio value='defaultSettings' style={getActiveRadioStyle(parameterSettings === 'defaultSettings')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.default')}
|
||||
</span>
|
||||
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.defaultSettings')}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
<Radio value='customSettings' style={getActiveRadioStyle(parameterSettings === 'customSettings')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.customize')}
|
||||
</span>
|
||||
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.customSettings')}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
{parameterSettings === 'customSettings' && (
|
||||
<div className='rb:flex rb:flex-col rb:mt-5'>
|
||||
<div className='rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.delimiter')}
|
||||
</div>
|
||||
<DelimiterSelector value={delimiter} onChange={setDelimiter} className='rb:mb-5'/>
|
||||
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 暂时隐藏第三步:数据预览 */}
|
||||
{/* {current === stepIndexMap.dataPreview && (
|
||||
<div className='rb:grid rb:grid-cols-2 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:h-[calc(100%-160px)] rb:bg-[#FBFDFF] rb:mt-4'>
|
||||
<div className='rb:border-r rb:h-full rb:overflow-hidden rb:border-[#DFE4ED]'>
|
||||
<div className='rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
|
||||
{t('knowledgeBase.fileList')}
|
||||
</div>
|
||||
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className={`rb:h-11 rb:w-full rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3 rb:hover:text-[#155EEF] rb:cursor-pointer ${curSelectedFileId === index ? styles.textBg + ' ' + styles.active : ''}`}
|
||||
onClick={() => handlePreview(item, index)}>
|
||||
{item.file_name}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:h-full rb:overflow-hidden'>
|
||||
<div className='rb:flex rb:items-center rb:justify-between rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
|
||||
{t('knowledgeBase.dataPreview')}
|
||||
<span className='rb:text-sm rb:text-gray-500'>{t('knowledgeBase.maxPreviewChunks', {count: total, max: chunkData.length})}</span>
|
||||
</div>
|
||||
<Spin spinning={previewLoading}>
|
||||
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
|
||||
{chunkData.length > 0 ? (
|
||||
chunkData.map((item, index) => (
|
||||
<div key={index} className='rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3'
|
||||
dangerouslySetInnerHTML={{ __html: item.page_content }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoData title={t('knowledgeBase.noChunksToPreview')}
|
||||
subTitle={t('knowledgeBase.clickToPreview')}
|
||||
image={noDataIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{current === 2 && (
|
||||
<Spin spinning={pollingLoading} tip={t('knowledgeBase.processingDocuments') || '正在处理文档...'}>
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-4 rb:h-[calc(100%-160px)] rb:overflow-y-auto'>
|
||||
<Table
|
||||
ref={tableRef}
|
||||
apiUrl={`/documents/${knowledgeBaseId}/${parentId}/documents`}
|
||||
apiParams={{
|
||||
document_ids: rechunkFileIds.join(','),
|
||||
}}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
|
||||
<div className={`rb:flex rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-40 rb:mt-10' : ''}`}>
|
||||
{current !== 0 && (
|
||||
<Button onClick={handlePrev} disabled={current === 0 || pollingLoading}>
|
||||
{t('common.previous') || 'Prev'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={current === 2 ? handleStartUpload : handleNext}
|
||||
disabled={pollingLoading}
|
||||
loading={pollingLoading}
|
||||
>
|
||||
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
};
|
||||
|
||||
export default CreateDataset;
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* @Description: 文档详情
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-15 16:13:47
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-29 19:46:46
|
||||
*/
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Spin, message, Switch } from 'antd';
|
||||
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '../service';
|
||||
import type { KnowledgeBaseDocumentData, RecallTestData } from '../types';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
|
||||
import RecallTestResult from '../components/RecallTestResult';
|
||||
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'
|
||||
const DocumentDetails: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const location = useLocation();
|
||||
const { documentId, parentId: locationParentId } = location.state as { documentId: string; parentId?: string };
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [document, setDocument] = useState<KnowledgeBaseDocumentData | null>(null);
|
||||
const [chunkList, setChunkList] = useState<RecallTestData[]>([]);
|
||||
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [chunkLoading, setChunkLoading] = useState(false);
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const insertModalRef = useRef<InsertModalRef>(null);
|
||||
const isManualRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
fetchDocumentDetail();
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
// 当文档加载完成且 progress === 1 时,加载分块列表
|
||||
useEffect(() => {
|
||||
if (document && document.progress === 1 && !isManualRefreshRef.current) {
|
||||
ChunkList();
|
||||
}
|
||||
// 重置标志
|
||||
isManualRefreshRef.current = false;
|
||||
}, [document]);
|
||||
|
||||
// 监听 keywords 变化,重新搜索
|
||||
useEffect(() => {
|
||||
if (documentId && keywords && document?.progress === 1) {
|
||||
setPage(1); // 重置页码
|
||||
setChunkList([]); // 清空列表
|
||||
ChunkList(1, false); // 重新加载第一页
|
||||
}
|
||||
}, [keywords]);
|
||||
|
||||
|
||||
|
||||
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
|
||||
return [
|
||||
{
|
||||
key: 'file_name',
|
||||
label: t('knowledgeBase.fileName') || '文件名',
|
||||
value: doc.file_name ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('knowledgeBase.status') || '进度',
|
||||
value: doc.progress === 1 ? t('knowledgeBase.progressComplete') : t('knowledgeBase.progressing') ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'chunk_num',
|
||||
label: t('knowledgeBase.chunk_num') || '分块数量',
|
||||
value: doc.chunk_num ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'parser_id',
|
||||
label: t('knowledgeBase.processingMode') || '处理模式',
|
||||
value: doc.parser_id ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('knowledgeBase.created_at') || '创建时间',
|
||||
value: formatDateTime(doc.created_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
label: t('knowledgeBase.updated_at') || '更新时间',
|
||||
value: formatDateTime(doc.updated_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
};
|
||||
|
||||
const fetchDocumentDetail = async () => {
|
||||
if (!documentId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDocumentDetail(documentId);
|
||||
setDocument(response);
|
||||
setInfoItems(formatDocumentInfo(response));
|
||||
const url = `${imagePath}/api/files/${response.file_id}`
|
||||
setFileUrl(url);
|
||||
// ChunkList 会在 useEffect 中根据 document.progress 自动调用
|
||||
} catch (error) {
|
||||
console.error('获取文档详情失败:', error);
|
||||
message.error(t('common.loadFailed') || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const ChunkList = async (pageNum: number = 1, append: boolean = false, force: boolean = false) => {
|
||||
if (!documentId) return;
|
||||
|
||||
// 如果不是强制刷新,且正在加载中,则跳过
|
||||
if (!force && chunkLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有当文档处理完成时才获取分块列表
|
||||
if (document && document.progress !== 1) {
|
||||
return;
|
||||
}
|
||||
setChunkLoading(true);
|
||||
try {
|
||||
const response = await getDocumentChunkList({
|
||||
kb_id: knowledgeBaseId,
|
||||
document_id: documentId,
|
||||
keywords: keywords || undefined,
|
||||
page: pageNum,
|
||||
pagesize: 20,
|
||||
_t: force ? Date.now() : undefined, // 强制刷新时添加时间戳破坏缓存
|
||||
});
|
||||
|
||||
// 转换数据格式以匹配 RecallTestData
|
||||
const formattedChunks: RecallTestData[] = response.items.map((item: any) => ({
|
||||
page_content: item.page_content || item.content || '',
|
||||
vector: null,
|
||||
metadata: {
|
||||
doc_id: item.metadata.doc_id || '',
|
||||
file_id: item.metadata.file_id || document?.file_id || '',
|
||||
file_name: item.metadata.file_name || document?.file_name || '',
|
||||
file_created_at: item.metadata.file_created_at || item.metadata.created_at || '',
|
||||
document_id: item.metadata.document_id || documentId || '',
|
||||
knowledge_id: item.metadata.knowledge_id || knowledgeBaseId || '',
|
||||
sort_id: item.metadata.sort_id || item.id || 0,
|
||||
score: item.metadata.score || null, // chunk 列表没有相似度分数
|
||||
status: item.metadata.status,
|
||||
},
|
||||
children: null,
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
setChunkList(prev => [...prev, ...formattedChunks]);
|
||||
} else {
|
||||
setChunkList(formattedChunks);
|
||||
}
|
||||
|
||||
setHasMore(response.page?.has_next ?? false);
|
||||
} catch (error) {
|
||||
console.error('获取文档详情失败:', error);
|
||||
message.error(t('common.loadFailed') || '加载失败');
|
||||
} finally {
|
||||
setChunkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreChunks = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
ChunkList(nextPage, true);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (knowledgeBaseId) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/private`);
|
||||
}
|
||||
};
|
||||
const handleSearch = (value?: string) => {
|
||||
setKeywords(value || '');
|
||||
};
|
||||
const handleInsert = () => {
|
||||
if (!documentId) {
|
||||
message.error(t('knowledgeBase.documentIdRequired') || '文档ID不能为空');
|
||||
return;
|
||||
}
|
||||
insertModalRef.current?.handleOpen(documentId);
|
||||
};
|
||||
|
||||
// 处理插入/编辑内容
|
||||
const handleInsertContent = async (_docId: string, content: string, chunkId?: string): Promise<boolean> => {
|
||||
try {
|
||||
if (chunkId) {
|
||||
// 编辑模式:更新现有块
|
||||
const response = await updateDocumentChunk(knowledgeBaseId || '', documentId, chunkId, { content });
|
||||
|
||||
// 直接更新前端列表,不等待后端缓存刷新
|
||||
setChunkList(prev => prev.map(item =>
|
||||
item.metadata?.doc_id === chunkId
|
||||
? { ...item, page_content: response.page_content || content }
|
||||
: item
|
||||
));
|
||||
|
||||
// 编辑模式返回特殊标记,告诉 InsertModal 不要调用 onSuccess
|
||||
return true;
|
||||
} else {
|
||||
// 插入模式:创建新块
|
||||
await createDocumentChunk(knowledgeBaseId || '', documentId, { content });
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理点击文本块
|
||||
const handleChunkClick = (item: RecallTestData, index: number) => {
|
||||
if (!documentId) return;
|
||||
const chunkId = String(item.metadata?.doc_id || index);
|
||||
insertModalRef.current?.handleOpen(documentId, item.page_content, chunkId);
|
||||
};
|
||||
|
||||
// 插入成功后的回调(仅用于插入新块,编辑操作已在 handleInsertContent 中同步更新)
|
||||
const handleInsertSuccess = () => {
|
||||
// 设置手动刷新标志,防止 useEffect 重复调用
|
||||
isManualRefreshRef.current = true;
|
||||
|
||||
// 重置页码
|
||||
setPage(1);
|
||||
|
||||
// 等待后端处理完成,然后重新加载数据(仅用于插入新块的情况)
|
||||
setTimeout(() => {
|
||||
ChunkList(1, false, true).then(() => {
|
||||
return fetchDocumentDetail();
|
||||
}).catch(err => {
|
||||
console.error('刷新失败:', err);
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
const handleAdjustmentParameter = () =>{
|
||||
if (!knowledgeBaseId || !document) return;
|
||||
const targetFileId = document.id;
|
||||
// 优先使用从 location 传递的 parentId,其次使用 document.parent_id,最后使用 knowledgeBaseId
|
||||
const parentId = locationParentId ?? document.parent_id ?? document.kb_id ?? knowledgeBaseId;
|
||||
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
|
||||
state: {
|
||||
source: 'local',
|
||||
knowledgeBaseId,
|
||||
parentId,
|
||||
startStep: 'parameterSettings',
|
||||
fileId: targetFileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDownload = () => {
|
||||
if (!document) return;
|
||||
downloadFile(document.file_id || '', document.file_name)
|
||||
};
|
||||
const onChange = (checked: boolean) => {
|
||||
updateDocument(documentId, {
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:h-full">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document?.progress !== 1) {
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<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>
|
||||
{/* 文档预览 */}
|
||||
{fileUrl && (
|
||||
<div className='rb:flex-1 rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4 rb:overflow-hidden'>
|
||||
<h3 className="rb:text-sm rb:font-medium rb:mb-3">
|
||||
{t('knowledgeBase.documentPreview') || '文档预览'}
|
||||
</h3>
|
||||
<DocumentPreview
|
||||
fileUrl={fileUrl}
|
||||
fileName={document?.file_name}
|
||||
fileExt={document?.file_ext}
|
||||
height="calc(100% - 40px)"
|
||||
mode="google"
|
||||
showModeSwitch={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
|
||||
{/* 头部 */}
|
||||
<div className="rb:flex rb:flex-col rb:text-left rb:mb-6">
|
||||
<div className='rb:flex rb:items-center rb:justify-between'>
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<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>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:gap-4">
|
||||
|
||||
<div className="rb:flex rb:gap-2 rb:items-center rb:text-xl rb:font-semibold rb:text-gray-800 ">
|
||||
{document.file_name || t('knowledgeBase.documentDetails') || '文档详情'}
|
||||
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={document.status === 1} onChange={onChange}/>
|
||||
</div>
|
||||
<div className='rb:flex rb:gap-3 rb:items-center'>
|
||||
<SearchInput
|
||||
placeholder={t('knowledgeBase.search')}
|
||||
onSearch={handleSearch}
|
||||
defaultValue={keywords}
|
||||
/>
|
||||
<Button type='primary' onClick={handleAdjustmentParameter}>{t('knowledgeBase.adjustmentParameter') || '调整参数'}</Button>
|
||||
<Button type="primary" onClick={handleInsert}>{t('knowledgeBase.insert') || '插入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="rb:flex rb:h-full rb:gap-4 rb:flex-1 rb:overflow-hidden">
|
||||
{/* 左侧:文档信息 */}
|
||||
<div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'>
|
||||
<div className='rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
|
||||
<InfoPanel
|
||||
title={t('knowledgeBase.documentInfo') || '文档信息'}
|
||||
items={infoItems}
|
||||
/>
|
||||
<Button type='primary' onClick={handleDownload} className="rb:mt-4 rb:w-full">
|
||||
{t('knowledgeBase.downloadOriginal')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:分块列表 */}
|
||||
<div
|
||||
id="chunkScrollableDiv"
|
||||
className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:p-6 rb:overflow-y-auto"
|
||||
>
|
||||
<h2 className="rb:text-lg rb:font-medium rb:mb-4">
|
||||
{t('knowledgeBase.chunkList') || '分块列表'}
|
||||
</h2>
|
||||
<RecallTestResult
|
||||
data={chunkList}
|
||||
showEmpty={false}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMoreChunks}
|
||||
loading={chunkLoading}
|
||||
scrollableTarget="chunkScrollableDiv"
|
||||
editable={true}
|
||||
onItemClick={handleChunkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 插入内容弹窗 */}
|
||||
<InsertModal
|
||||
ref={insertModalRef}
|
||||
onInsert={handleInsertContent}
|
||||
onSuccess={handleInsertSuccess}
|
||||
/>
|
||||
</div>
|
||||
</>);
|
||||
};
|
||||
|
||||
export default DocumentDetails;
|
||||
|
||||
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal file
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* 去掉所有默认白色背景 */
|
||||
.customTree,
|
||||
.customTree *,
|
||||
.customTree .ant-tree,
|
||||
.customTree .ant-tree-list,
|
||||
.customTree .ant-tree-list-holder,
|
||||
.customTree .ant-tree-list-holder-inner,
|
||||
.customTree .ant-tree-list-holder-inner > div,
|
||||
.customTree .ant-tree-list-holder-inner > div > div {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
/* 节点内容区域 - 默认透明 */
|
||||
.customTree .ant-tree-node-content-wrapper {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
height: 40px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease !important;
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:before,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover:before {
|
||||
background: #FFFFFF !important;
|
||||
}
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover{
|
||||
color: #000 !important;
|
||||
}
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-switcher,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-draggable-icon{
|
||||
color: #000;
|
||||
}
|
||||
.customTree .ant-tree-switcher {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 24px !important;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
.customTree .ant-tree-switcher::before{
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Switcher 图标样式 - 收起状态(默认向右) */
|
||||
.customTree .ant-tree-switcher .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher img {
|
||||
transition: transform 0.3s ease !important;
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
|
||||
/* Switcher 图标样式 - 展开状态(向下旋转90度) */
|
||||
.customTree .ant-tree-switcher_open .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher_open img,
|
||||
.customTree .ant-tree-switcher.ant-tree-switcher_open img {
|
||||
transform: rotate(90deg) !important;
|
||||
}
|
||||
|
||||
/* 如果使用 ant-tree-switcher_close 类 */
|
||||
.customTree .ant-tree-switcher_close .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher_close img {
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
margin-right: 4px !important;
|
||||
line-height: 1 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle img {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
display: block !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-title {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
flex: 1 !important;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-child-tree {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle + .ant-tree-title {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal file
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Switch, Button, Dropdown, Space, Modal, message } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import Table, { type TableRef } from '@/components/Table'
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AnyObject } from 'antd/es/_util/type';
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
|
||||
import textIcon from '@/assets/images/knowledgeBase/text.png';
|
||||
import imageIcon from '@/assets/images/knowledgeBase/image.png';
|
||||
import blankIcon from '@/assets/images/knowledgeBase/blankDocument.png';
|
||||
import templateIcon from '@/assets/images/knowledgeBase/template.png';
|
||||
import backupIcon from '@/assets/images/knowledgeBase/backup.png';
|
||||
import editIcon from '@/assets/images/knowledgeBase/edit.png';
|
||||
import { getKnowledgeBaseDetail, deleteDocument, downloadFile, updateKnowledgeBase } from '../service';
|
||||
import type {
|
||||
CreateModalRef,
|
||||
KnowledgeBaseListItem,
|
||||
RecallTestDrawerRef,
|
||||
CreateFolderModalRef,
|
||||
CreateImageModalRef,
|
||||
ShareModalRef,
|
||||
CreateDatasetModalRef,FolderFormData,
|
||||
KnowledgeBaseDocumentData
|
||||
} from '../types';
|
||||
import RecallTestDrawer from '../components/RecallTestDrawer';
|
||||
import CreateFolderModal from '../components/CreateFolderModal';
|
||||
import CreateModal from '../components/CreateModal';
|
||||
import ShareModal from '../components/ShareModal';
|
||||
import CreateDatasetModal from '../components/CreateDatasetModal';
|
||||
import CreateImageDataset from '../components/CreateImageDataset';
|
||||
import FolderTree, { type TreeNodeData } from '../components/FolderTree';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import './Private.css'
|
||||
const { confirm } = Modal
|
||||
// 树节点数据类型
|
||||
|
||||
const Private: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const [parentId, setParentId] = useState<string | undefined>(knowledgeBaseId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const tableRef = useRef<TableRef>(null);
|
||||
const [tableApi, setTableApi] = useState<string | undefined>(undefined);
|
||||
const recallTestDrawerRef = useRef<RecallTestDrawerRef>(null);
|
||||
const createFolderModalRef = useRef<CreateFolderModalRef>(null);
|
||||
const createImageDataset = useRef<CreateImageModalRef>(null)
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
|
||||
const [folder, setFolder] = useState<FolderFormData | null>({
|
||||
kb_id:knowledgeBaseId ?? '',
|
||||
parent_id:parentId ?? ''
|
||||
});
|
||||
const [keywords, setKeywords] = useState<string>('');
|
||||
const [query, setQuery] = useState<Record<string, unknown>>({
|
||||
orderby: 'created_at',
|
||||
desc: true,
|
||||
});
|
||||
const modalRef = useRef<CreateModalRef>(null)
|
||||
const shareModalRef = useRef<ShareModalRef>(null);
|
||||
const datasetModalRef = useRef<CreateDatasetModalRef>(null);
|
||||
const [folderTreeRefreshKey, setFolderTreeRefreshKey] = useState(0);
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId) {
|
||||
let url = `/documents/${knowledgeBaseId}/${parentId}/documents`;
|
||||
setTableApi(url);
|
||||
fetchKnowledgeBaseDetail(knowledgeBaseId);
|
||||
}
|
||||
}, [knowledgeBaseId]);
|
||||
|
||||
// 监听 tableApi 变化,自动刷新表格数据
|
||||
useEffect(() => {
|
||||
if (tableApi) {
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
}, [tableApi]);
|
||||
|
||||
// 监听 location state 变化,如果有 refresh 标志则刷新列表
|
||||
useEffect(() => {
|
||||
const state = location.state as { refresh?: boolean; timestamp?: number } | null;
|
||||
if (state?.refresh) {
|
||||
tableRef.current?.loadData();
|
||||
// 清除 state,避免重复刷新
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const fetchKnowledgeBaseDetail = async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getKnowledgeBaseDetail(id);
|
||||
setKnowledgeBase(res.data || res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树节点选择
|
||||
const onSelect = (selectedKeys: React.Key[], info: any) => {
|
||||
if (!selectedKeys.length) return;
|
||||
if (!folder) return;
|
||||
const node = info.node as TreeNodeData;
|
||||
const f = {
|
||||
...folder,
|
||||
parent_id: String(selectedKeys[0]),
|
||||
}
|
||||
let url = `/documents/${knowledgeBaseId}/${String(selectedKeys[0])}/documents`;
|
||||
setTableApi(url);
|
||||
setParentId(String(selectedKeys[0]))
|
||||
setFolder(f)
|
||||
// 根据节点类型执行不同操作
|
||||
if (node.type === 'folder') {
|
||||
|
||||
// 文件夹:展开/收起
|
||||
} else if (node.type === 'text' || node.type === 'image' || node.type === 'dataset') {
|
||||
// 文件:打开详情
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树节点展开
|
||||
const onExpand = (expandedKeys: React.Key[], info: any) => {
|
||||
};
|
||||
// create / import list
|
||||
const createItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <img src={folderIcon} alt="dataset" style={{ width: 16, height: 16 }} />,
|
||||
label: t('knowledgeBase.folder'),
|
||||
onClick: () => {
|
||||
let f: FolderFormData | null = null;
|
||||
f = {
|
||||
kb_id: knowledgeBase?.id ?? '',
|
||||
parent_id:folder?.parent_id ?? knowledgeBase?.id ?? '',
|
||||
}
|
||||
// setFolder(f);
|
||||
|
||||
createFolderModalRef?.current?.handleOpen(f as FolderFormData);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <img src={textIcon} alt="text" style={{ width: 16, height: 16 }} />,
|
||||
label: (<span>{t('knowledgeBase.text')} {t('knowledgeBase.dataset')}</span>),
|
||||
onClick: () => {
|
||||
datasetModalRef?.current?.handleOpen(knowledgeBase?.id,folder?.parent_id ?? knowledgeBase?.id ?? '');
|
||||
},
|
||||
},
|
||||
// 暂时未实现
|
||||
// {
|
||||
// key: '3',
|
||||
// icon: <img src={imageIcon} alt="image" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.imageDataSet'),
|
||||
// onClick: () => {
|
||||
// createImageDataset?.current?.handleOpen(knowledgeBaseId || '', parentId || '')
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '4',
|
||||
// icon: <img src={blankIcon} alt="blank" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.blankDataset'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '5',
|
||||
// type: 'divider',
|
||||
// },
|
||||
// {
|
||||
// key: '6',
|
||||
// icon: <img src={templateIcon} alt="import" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.importTemplate'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '7',
|
||||
// icon: <img src={backupIcon} alt="import" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.importBackup'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
|
||||
];
|
||||
//
|
||||
const handleCreate = (type: string) => {
|
||||
console.log('create', type);
|
||||
}
|
||||
// 处理开关
|
||||
const onChange = (checked: boolean) => {
|
||||
updateKnowledgeBase(knowledgeBaseId || '', {
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
console.log(`switch to ${checked}`);
|
||||
};
|
||||
// 处理搜索
|
||||
const handleSearch = (value?: string) => {
|
||||
setQuery({ ...query, keywords: value })
|
||||
}
|
||||
|
||||
// 处理分享
|
||||
const handleShare = () => {
|
||||
shareModalRef?.current?.handleOpen(knowledgeBaseId,knowledgeBase);
|
||||
}
|
||||
// 处理分享回调,接收选中的数据
|
||||
const handleShareCallback = (selectedData: { checkedItems: any[], selectedItem: any | null }) => {
|
||||
console.log('选中的数据:', selectedData);
|
||||
// checkedItems: 所有 checked 为 true 的数据
|
||||
// selectedItem: 当前选中的项(curIndex 对应的数据)
|
||||
// 在这里处理分享逻辑
|
||||
}
|
||||
const handleCreateDatasetCallback = (payload: { value: number; title: string; description: string }) => {
|
||||
console.log('创建数据集:', payload);
|
||||
}
|
||||
// 处理设置
|
||||
const handleSetting = () => {
|
||||
modalRef?.current?.handleOpen(knowledgeBase, '');
|
||||
}
|
||||
// 处理召回测试
|
||||
const handleRecallTest = () => {
|
||||
recallTestDrawerRef?.current?.handleOpen(knowledgeBaseId);
|
||||
}
|
||||
|
||||
// new / import
|
||||
const handelCreateOrImport = () => {
|
||||
|
||||
}
|
||||
// 生成下拉菜单项(根据当前 row)
|
||||
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => [
|
||||
{
|
||||
key: '1',
|
||||
label: t('knowledgeBase.rechunking'),
|
||||
onClick: () => {
|
||||
handleRechunking(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('knowledgeBase.download'),
|
||||
onClick: () => {
|
||||
handleDownload(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('knowledgeBase.delete'),
|
||||
onClick: () => {
|
||||
handleDelete(row);
|
||||
},
|
||||
}
|
||||
];
|
||||
const handleRechunking = (item: KnowledgeBaseListItem) => {
|
||||
if (!knowledgeBaseId) return;
|
||||
const document = item as unknown as KnowledgeBaseDocumentData;
|
||||
const targetFileId = document?.id || document?.file_id;
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
|
||||
state: {
|
||||
source: 'local',
|
||||
knowledgeBaseId,
|
||||
parentId: parentId ?? knowledgeBaseId,
|
||||
startStep: 'parameterSettings',
|
||||
fileId: targetFileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDownload = (item: KnowledgeBaseListItem) => {
|
||||
const document = item as unknown as KnowledgeBaseDocumentData;
|
||||
const targetFileId = document?.file_id ?? '';
|
||||
const fileName = document?.file_name ?? '';
|
||||
downloadFile(targetFileId, fileName);
|
||||
}
|
||||
const handleDelete = (item: any) => {
|
||||
confirm({
|
||||
title: t('common.deleteWarning'),
|
||||
content: t('common.deleteWarningContent', { content: item.file_name }),
|
||||
onOk: () => {
|
||||
deleteDocument(item.id)
|
||||
.then(() => {
|
||||
messageApi.success(t('common.deleteSuccess'));
|
||||
// 刷新表格数据
|
||||
tableRef.current?.loadData();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.log('删除失败', err);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('取消删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
// 表格列配置
|
||||
const columns: ColumnsType = [
|
||||
{
|
||||
title: t('knowledgeBase.name'),
|
||||
dataIndex: 'file_name',
|
||||
key: 'file_name',
|
||||
render: (text: string, record: AnyObject) => {
|
||||
const document = record as KnowledgeBaseDocumentData;
|
||||
return (
|
||||
<span
|
||||
className="rb:text-blue-600 rb:cursor-pointer rb:hover:underline"
|
||||
onClick={() => {
|
||||
if (knowledgeBaseId && document.id) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/DocumentDetails`,{
|
||||
state: {
|
||||
documentId: document.id,
|
||||
parentId: parentId ?? knowledgeBaseId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.processingMode'),
|
||||
dataIndex: 'parser_id',
|
||||
key: 'parser_id',
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.dataSize'),
|
||||
dataIndex: 'file_size',
|
||||
key: 'file_size',
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.createUpdateTime'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render:(value:string) => {
|
||||
return(
|
||||
<span>{formatDateTime(value,'YYYY-MM-DD HH:mm:ss')}</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (value: string | number, record: AnyObject) => {
|
||||
|
||||
return (
|
||||
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
|
||||
<span
|
||||
className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full"
|
||||
style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }}
|
||||
></span>
|
||||
<span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Dropdown
|
||||
menu={{ items: getOptMenuItems(record as KnowledgeBaseListItem) }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<MoreOutlined className='rb:text-base rb:font-semibold'/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
// 刷新列表数据
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return <div>知识库不存在</div>;
|
||||
}
|
||||
const refreshDirectoryTree = async () => {
|
||||
// 先刷新知识库详情,确保数据是最新的
|
||||
await fetchKnowledgeBaseDetail(knowledgeBase.id);
|
||||
// 添加短暂延迟,确保后端数据已经完全更新
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
// 然后刷新文件夹树
|
||||
setFolderTreeRefreshKey((prev) => prev + 1);
|
||||
if (!folder) {
|
||||
setFolder({
|
||||
kb_id: knowledgeBaseId ?? '',
|
||||
parent_id: parentId ?? knowledgeBaseId ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
const handleRootTreeLoad = (nodes: TreeNodeData[] | null) => {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
setFolder(null);
|
||||
} else {
|
||||
// 如果有节点且 folder 为 null,重新设置 folder
|
||||
if (!folder) {
|
||||
setFolder({
|
||||
kb_id: knowledgeBaseId ?? '',
|
||||
parent_id: parentId ?? knowledgeBaseId ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleEditFolder = () => {
|
||||
const f = {
|
||||
id:knowledgeBase.id,
|
||||
parent_id:knowledgeBase.parent_id,
|
||||
kb_id:knowledgeBase.id,
|
||||
folder_name:knowledgeBase.name
|
||||
}
|
||||
// setFolder(f)
|
||||
createFolderModalRef?.current?.handleOpen(f,'edit');
|
||||
}
|
||||
|
||||
const handleRefreshTable = () => {
|
||||
// 刷新表格数据
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className="rb:flex rb:h-full rb:gap-4">
|
||||
{folder && (
|
||||
<div className="rb:w-80 rb:flex-shrink-0 rb:h-[calc(100%+40px)] rb:mt-[-16px] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
|
||||
<FolderTree
|
||||
multiple
|
||||
className="customTree"
|
||||
style={{ background: 'transparent' }}
|
||||
onSelect={onSelect}
|
||||
onExpand={onExpand}
|
||||
knowledgeBaseId={knowledgeBaseId ?? ''}
|
||||
refreshKey={folderTreeRefreshKey}
|
||||
onRootLoad={handleRootTreeLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='rb:flex-1 rb:min-w-0'>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mb-4">
|
||||
|
||||
<div className="rb:flex-col">
|
||||
<div className="rb:flex rb:items-center rb:gap-3">
|
||||
<h1 className="rb:text-xl rb:font-medium rb:text-gray-800">{knowledgeBase.name}</h1>
|
||||
<div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded"
|
||||
onClick={handleEditFolder}
|
||||
>
|
||||
<img src={editIcon} alt="edit" className="rb:w-[14px] rb:h-[14px" />
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span>
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className='rb:flex'> */}
|
||||
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={knowledgeBase.status === 1} onChange={onChange}/>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
<div className='rb:flex rb:items-center rb:justify-between rb:mb-4'>
|
||||
<SearchInput placeholder={t('knowledgeBase.search')} onSearch={handleSearch} />
|
||||
<div className='rb:flex-1 rb:flex rb:items-center rb:justify-end rb:gap-2.5'>
|
||||
<Button onClick={handleShare}>{t('knowledgeBase.share')}</Button>
|
||||
<Button onClick={handleRecallTest}>{t('knowledgeBase.recallTest')}</Button>
|
||||
<Button onClick={handleSetting}>{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.setting')}</Button>
|
||||
<Dropdown menu={{ items: createItems }} trigger={['click']}>
|
||||
<Button type="primary" onClick={handelCreateOrImport} >+ {t('knowledgeBase.createImport')}</Button>
|
||||
</Dropdown>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="rb:rounded rb:max-h-[calc(100%-100px)] rb:overflow-y-auto">
|
||||
<Table
|
||||
ref={tableRef}
|
||||
apiUrl={tableApi}
|
||||
apiParams={query as Record<string, unknown>}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
scrollX={1500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RecallTestDrawer
|
||||
ref={recallTestDrawerRef}
|
||||
/>
|
||||
<CreateFolderModal
|
||||
ref={createFolderModalRef}
|
||||
refreshTable={refreshDirectoryTree}
|
||||
/>
|
||||
<CreateModal
|
||||
ref={modalRef}
|
||||
refreshTable={handleRefreshTable}
|
||||
/>
|
||||
<ShareModal
|
||||
ref={shareModalRef}
|
||||
handleShare={handleShareCallback}
|
||||
/>
|
||||
<CreateDatasetModal
|
||||
ref={datasetModalRef}
|
||||
handleCreateDataset={handleCreateDatasetCallback}
|
||||
/>
|
||||
<CreateImageDataset
|
||||
ref={createImageDataset}
|
||||
refreshTable={refreshDirectoryTree}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Private;
|
||||
|
||||
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal file
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { KnowledgeBaseListItem, RecallTestDrawerRef } from '../types';
|
||||
import RecallTest from '../components/RecallTest';
|
||||
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
|
||||
import shareUserIcon from '@/assets/images/knowledgeBase/share-user.png';
|
||||
import timestampIcon from '@/assets/images/knowledgeBase/timestamp.png';
|
||||
//
|
||||
import kbNameIcon from '@/assets/images/knowledgeBase/kb-name.png';
|
||||
import kbDataIcon from '@/assets/images/knowledgeBase/kb-data.png';
|
||||
import kbSizeIcon from '@/assets/images/knowledgeBase/kb-size.png';
|
||||
import kbModelIcon from '@/assets/images/knowledgeBase/kb-model.png';
|
||||
|
||||
import kbHistoryIcon from '@/assets/images/knowledgeBase/kb-history.png';
|
||||
import { getKnowledgeBaseDetail } from '../service';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
|
||||
const Share: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ knowledgeBaseId: string }>();
|
||||
const knowledgeBaseId = params.knowledgeBaseId;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
|
||||
const recallTestRef = useRef<RecallTestDrawerRef>(null);
|
||||
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
|
||||
useEffect(() => {
|
||||
console.log('Share.tsx - useParams result:', params);
|
||||
console.log('Share.tsx - knowledgeBaseId:', knowledgeBaseId);
|
||||
console.log('Share.tsx - typeof knowledgeBaseId:', typeof knowledgeBaseId);
|
||||
|
||||
if (knowledgeBaseId) {
|
||||
fetchKnowledgeBaseDetail(knowledgeBaseId);
|
||||
// 打开召回测试组件
|
||||
setTimeout(() => {
|
||||
console.log('Share.tsx - calling handleOpen with:', knowledgeBaseId);
|
||||
recallTestRef.current?.handleOpen(knowledgeBaseId);
|
||||
}, 100);
|
||||
} else {
|
||||
console.warn('Share.tsx - knowledgeBaseId is undefined or empty');
|
||||
}
|
||||
}, [knowledgeBaseId]);
|
||||
const formatInfoItems = (data: KnowledgeBaseListItem): InfoItem[] => {
|
||||
const items: InfoItem[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('knowledgeBase.knowledgeBase') + ' ' + t('knowledgeBase.name'),
|
||||
value: data.name ?? '-',
|
||||
icon: kbNameIcon,
|
||||
},
|
||||
{
|
||||
key: 'doc_num',
|
||||
label: t('knowledgeBase.doc_num'),
|
||||
value: data.doc_num ?? 0,
|
||||
icon: kbDataIcon,
|
||||
},
|
||||
{
|
||||
key: 'chunk_num',
|
||||
label: t('knowledgeBase.chunk_num'),
|
||||
value: data.chunk_num ?? 0,
|
||||
icon: kbSizeIcon,
|
||||
},
|
||||
{
|
||||
key: 'embedding_id',
|
||||
label: t('knowledgeBase.embedding_id') + ' ' + 'model',
|
||||
value: data.embedding?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'llm_id',
|
||||
label: t('knowledgeBase.llm_id') + ' ' + 'model',
|
||||
value: data.llm?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'image2text_id',
|
||||
label: t('knowledgeBase.image2text_id') + ' ' + 'model',
|
||||
value: data.image2text?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
label: t('knowledgeBase.last_at'),
|
||||
value: formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
icon: kbHistoryIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return items.filter((item) => {
|
||||
return item.value !== null && item.value !== undefined && item.value !== '';
|
||||
});
|
||||
}
|
||||
const fetchKnowledgeBaseDetail = (id: string) => {
|
||||
setLoading(true);
|
||||
getKnowledgeBaseDetail(id)
|
||||
.then((res: any) => {
|
||||
const data = res.data || res;
|
||||
setKnowledgeBase(data);
|
||||
setInfoItems(formatInfoItems(data));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// const handleBack = () => {
|
||||
// navigate('/knowledge-base');
|
||||
// };
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return <div>知识库不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:max-h-full rb:overflow-hidden">
|
||||
|
||||
<div className="rb:flex rb:w-full rb:items-center rb:mb-2 rb:gap-2">
|
||||
<h1 className="rb:text-xl rb:font-bold">{knowledgeBase.name}
|
||||
<span className='rb:text-gray-500 rb:text-sm rb:ml-2 rb:font-normal'>(ID: {knowledgeBase.id})</span></h1>
|
||||
|
||||
{/* <p className="rb:text-gray-600 rb:mt-2">{knowledgeBase.description || t('knowledgeBase.noDescription')}</p> */}
|
||||
<span className='rb:text-gray-800 rb:text-xs rb:border rb:border-[#369F21] rb:bg-[rgba(54,159,33,0.2)] rb:px-1 rb:py-[2px] rb:rounded'>{knowledgeBase.permission_id}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:w-full rb:items-center rb:mb-5 rb:gap-2">
|
||||
<img src={shareUserIcon} className='rb:size-4 rb:ml-2' />
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{knowledgeBase.created_by}</span>
|
||||
<img src={timestampIcon} className='rb:size-4 rb:ml-2' />
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{formatDateTime(knowledgeBase.created_at)}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:flex-1 rb:gap-4 rb:min-h-0">
|
||||
<div className="rb:flex-1 rb:p-4 rb:border rb:flex rb:flex-col rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:overflow-hidden">
|
||||
<div className='rb:flex rb:flex-col rb:txt-left rb:mb-5 rb:gap-2 rb:flex-shrink-0'>
|
||||
<h1 className="rb:text-lg rb:font-bold">{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.recallTest')}</h1>
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{t('knowledgeBase.recallTestDescription')}</span>
|
||||
</div>
|
||||
<div className='rb:flex-1 rb:min-h-0'>
|
||||
<RecallTest ref={recallTestRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:w-80 rb:border rb:overflow-y-auto rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
|
||||
<InfoPanel
|
||||
title={t('knowledgeBase.knowledgeBaseInfo')}
|
||||
items={infoItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Share;
|
||||
|
||||
150
web/src/views/KnowledgeBase/components/CreateDatasetModal.tsx
Normal file
150
web/src/views/KnowledgeBase/components/CreateDatasetModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-24 11:23:33
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import { Flex, Radio } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { CreateDatasetModalRef, CreateDatasetModalRefProps} from '../types';
|
||||
import RbModal from '@/components/RbModal'
|
||||
const style: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
};
|
||||
const radioWrapperBaseStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
columnGap: 14, // 点与文字更宽的间距
|
||||
width: '100%',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
};
|
||||
const getActiveRadioStyle = (active: boolean): React.CSSProperties => ({
|
||||
...radioWrapperBaseStyle,
|
||||
border: active ? '1px solid #1677ff' : radioWrapperBaseStyle.border,
|
||||
});
|
||||
const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRefProps>((_props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const [knowledgeBaseId, setKnowledgeBaseId] = useState<string | undefined>(undefined);
|
||||
const [parentId, setParentId] = useState<string | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [value, setValue] = useState(0);
|
||||
// const { handleCreateDataset: onCreate } = props || {};
|
||||
const items = [
|
||||
{
|
||||
title: t('knowledgeBase.localFile'),
|
||||
description: t('knowledgeBase.uploadFileTypes'),
|
||||
},
|
||||
// 暂时隐藏
|
||||
// {
|
||||
// title: t('knowledgeBase.webLink'),
|
||||
// description: t('knowledgeBase.readStaticWebPage')
|
||||
// },
|
||||
// {
|
||||
// title: t('knowledgeBase.customText'),
|
||||
// description: t('knowledgeBase.manuallyInputText')
|
||||
// },
|
||||
]
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setLoading(false)
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleOpen = (kb_id?: string,parent_id?: string) => {
|
||||
setKnowledgeBaseId(kb_id);
|
||||
setParentId(parent_id);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleCreateDataset = () => {
|
||||
// // 获取所有 checked 为 true 的数据
|
||||
// const checkedItems = testData.filter(item => item.checked);
|
||||
// // 获取当前选中的项(curIndex 对应的数据)
|
||||
// const selectedItem = curIndex !== 9999 ? testData[curIndex] : null;
|
||||
|
||||
// // 调用父组件传递的回调函数,传递选中的数据
|
||||
// onShare?.({
|
||||
// checkedItems,
|
||||
// selectedItem
|
||||
// });
|
||||
// const selected = items[value];
|
||||
// onCreate?.({
|
||||
// value,
|
||||
// title: selected.title,
|
||||
// description: selected.description,
|
||||
// });
|
||||
// 跳转到创建数据集页面并携带来源参数
|
||||
const source = value === 0 ? 'local' : value === 1 ? 'link' : 'text';
|
||||
if (knowledgeBaseId) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
|
||||
state: {
|
||||
source: source,
|
||||
knowledgeBaseId: knowledgeBaseId,
|
||||
parentId: parentId,
|
||||
}
|
||||
});
|
||||
}
|
||||
// 关闭弹窗
|
||||
handleClose();
|
||||
}
|
||||
const onChange = (e: RadioChangeEvent) => {
|
||||
setValue(e.target.value);
|
||||
};
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose,
|
||||
handleCreateDataset
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.text') + ' ' + t('knowledgeBase.dataset')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.create')}
|
||||
onOk={handleCreateDataset}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<div className='rb:flex rb:flex-col rb:text-left'>
|
||||
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.selectSource')}</h4>
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
|
||||
<Radio.Group onChange={onChange} value={value} style={style}>
|
||||
<Radio value={0} style={getActiveRadioStyle(value === 0)} 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[0].title}</span>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{items[0].description}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
{/* <Radio value={1} style={getActiveRadioStyle(value === 1)} 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[1].title}</span>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
<Radio value={2} style={getActiveRadioStyle(value === 2)} 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>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreateDatasetModal;
|
||||
114
web/src/views/KnowledgeBase/components/CreateFolderModal.tsx
Normal file
114
web/src/views/KnowledgeBase/components/CreateFolderModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FolderFormData, KnowledgeBaseFormData, CreateFolderModalRef, CreateFolderModalRefProps } from '../types';
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { createFolder, updateKnowledgeBase } from '../service';
|
||||
const CreateFolderModal = forwardRef<CreateFolderModalRef,CreateFolderModalRefProps>(({
|
||||
refreshTable
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [folder, setFolder] = useState<FolderFormData>({} as FolderFormData);
|
||||
const [form] = Form.useForm<FolderFormData>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setFolder({} as FolderFormData);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleOpen = (folder?: FolderFormData | null) => {
|
||||
if (folder) {
|
||||
setFolder(folder);
|
||||
// 设置表单值
|
||||
form.setFieldsValue({
|
||||
folder_name: folder.folder_name,
|
||||
parent_id: folder.parent_id ?? '',
|
||||
kb_id: folder.kb_id ?? '',
|
||||
});
|
||||
} else {
|
||||
// 新建时,重置表单并设置默认值
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
parent_id: '',
|
||||
kb_id: ''
|
||||
});
|
||||
}
|
||||
setVisible(true);
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields({ validateOnly: true })
|
||||
.then(async () => {
|
||||
setLoading(true)
|
||||
const formValues = form.getFieldsValue();
|
||||
const payload: FolderFormData = {
|
||||
...formValues,
|
||||
parent_id: folder.parent_id ?? '',
|
||||
kb_id: folder.kb_id ?? '',
|
||||
}
|
||||
const updatePayload: KnowledgeBaseFormData = {
|
||||
id: folder.id ?? '',
|
||||
name: formValues.folder_name ?? '',
|
||||
}
|
||||
const data = await (folder.id ? updateKnowledgeBase(folder.id ?? '', updatePayload) : createFolder(payload)) as any;
|
||||
if(data) {
|
||||
if (refreshTable) {
|
||||
await refreshTable();
|
||||
}
|
||||
setLoading(false)
|
||||
handleClose()
|
||||
}else {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('err', err)
|
||||
setLoading(false)
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
// 根据 type 获取标题
|
||||
const getTitle = () => {
|
||||
if (folder.id) {
|
||||
return t('common.edit') + ' ' + (folder.folder_name || '');
|
||||
}
|
||||
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.folder');
|
||||
}
|
||||
return (
|
||||
<RbModal
|
||||
title={getTitle()}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={folder.id ? t('common.save') : t('common.create')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* <div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('model.basicParameters')}</div> */}
|
||||
<Form.Item
|
||||
name="folder_name"
|
||||
label={t('knowledgeBase.name')}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.name')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreateFolderModal;
|
||||
151
web/src/views/KnowledgeBase/components/CreateImageDataset.tsx
Normal file
151
web/src/views/KnowledgeBase/components/CreateImageDataset.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UploadFile } from 'antd';
|
||||
import type { CreateImageModalRef, CreateImageMoealRefProps,UploadFileResponse } from '../types';
|
||||
import type { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
import RbModal from '@/components/RbModal';
|
||||
import UploadFiles from '@/components/Upload/UploadFiles';
|
||||
import { uploadFile } from '../service';
|
||||
|
||||
interface ImageDatasetFormData {
|
||||
name: string;
|
||||
images: UploadFile[];
|
||||
}
|
||||
|
||||
const CreateImageDataset = forwardRef<CreateImageModalRef, CreateImageMoealRefProps>(
|
||||
({ refreshTable }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<ImageDatasetFormData>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [kbId, setKbId] = useState<string>('');
|
||||
const [parentId, setParentId] = useState<string>('');
|
||||
const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
form.resetFields();
|
||||
uploadRef.current?.clearFiles();
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
setKbId('');
|
||||
setParentId('');
|
||||
};
|
||||
|
||||
const handleOpen = (kb_id: string, parent_id: string) => {
|
||||
setKbId(kb_id);
|
||||
setParentId(parent_id);
|
||||
form.resetFields();
|
||||
uploadRef.current?.clearFiles();
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const fileList = uploadRef.current?.fileList || [];
|
||||
|
||||
if (fileList.length === 0) {
|
||||
throw new Error(t('knowledgeBase.pleaseUploadImages'));
|
||||
}
|
||||
|
||||
// 上传所有图片
|
||||
const uploadPromises = fileList.map(async (file) => {
|
||||
if (file.originFileObj) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file.originFileObj);
|
||||
|
||||
return uploadFile(formData, {
|
||||
kb_id: kbId,
|
||||
parent_id: parentId,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
if (refreshTable) {
|
||||
await refreshTable();
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('创建图片数据集失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
// 上传文件
|
||||
const handleUpload = (options: UploadRequestOption) => {
|
||||
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(filename, file as File);
|
||||
if (kbId) {
|
||||
formData.append('kb_id', kbId);
|
||||
}
|
||||
if (parentId) {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
uploadFile(formData, {
|
||||
kb_id: kbId,
|
||||
parent_id: parentId,
|
||||
onUploadProgress: (event) => {
|
||||
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) {
|
||||
// 上传成功
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error as Error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<RbModal
|
||||
title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.imageDataSet')}`}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.create')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('knowledgeBase.datasetName')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterDatasetName') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterDatasetName')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('knowledgeBase.uploadImages')}>
|
||||
<UploadFiles
|
||||
isCanDrag={true}
|
||||
fileSize={50}
|
||||
multiple={true}
|
||||
maxCount={99}
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']}
|
||||
customRequest={handleUpload}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default CreateImageDataset;
|
||||
260
web/src/views/KnowledgeBase/components/CreateModal.tsx
Normal file
260
web/src/views/KnowledgeBase/components/CreateModal.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { Form, Input, Select, Modal } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { KnowledgeBaseListItem, KnowledgeBaseFormData, CreateModalRef, CreateModalRefProps } from '../types';
|
||||
import { getModelTypeList, getModelList, createKnowledgeBase, updateKnowledgeBase } from '../service'
|
||||
import RbModal from '@/components/RbModal'
|
||||
const { TextArea } = Input;
|
||||
const { confirm } = Modal
|
||||
|
||||
const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
refreshTable
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [modelTypeList, setModelTypeList] = useState<string[]>([]);
|
||||
const [modelOptionsByType, setModelOptionsByType] = useState<Record<string, { label: string; value: string }[]>>({});
|
||||
const [datasets, setDatasets] = useState<KnowledgeBaseListItem | null>(null);
|
||||
const [currentType, setCurrentType] = useState<string>('General'); // 保存当前 type
|
||||
const [form] = Form.useForm<KnowledgeBaseFormData>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setDatasets(null);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const typeToFieldKey = (type: string): string => {
|
||||
switch ((type || '').toLowerCase()) {
|
||||
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 `${type.toLowerCase()}_id`;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModelLists = async (types: string[]) => {
|
||||
// 如果 types 中包含 'llm',也需要获取 'chat' 的数据
|
||||
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types;
|
||||
|
||||
const entries = await Promise.all(typesToFetch.map(async (tp) => {
|
||||
try {
|
||||
const res = await getModelList(tp === 'image2text' ? 'chat' : tp, { page: 1, pagesize: 100 });
|
||||
const options = (res?.items || []).map((m: any) => ({ label: m.name, value: m.id }));
|
||||
return [tp, options] as [string, { label: string; value: string }[]];
|
||||
} catch {
|
||||
return [tp, []] as [string, { label: string; value: string }[]];
|
||||
}
|
||||
}));
|
||||
const next: Record<string, { label: string; value: string }[]> = {};
|
||||
entries.forEach(([k, v]) => { next[k] = v; });
|
||||
setModelOptionsByType(next);
|
||||
};
|
||||
|
||||
const setBaseFields = (record: KnowledgeBaseListItem | null, type?: string) => {
|
||||
if (!record) {
|
||||
form.resetFields();
|
||||
const defaults: Partial<KnowledgeBaseFormData> = {
|
||||
permission_id: 'Private',
|
||||
type: type || currentType,
|
||||
};
|
||||
form.setFieldsValue(defaults);
|
||||
return;
|
||||
}
|
||||
const baseValues: Partial<KnowledgeBaseFormData> = {
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
permission_id: record.permission_id || 'Private',
|
||||
type: type || record.type || currentType,
|
||||
status: record.status,
|
||||
};
|
||||
form.setFieldsValue(baseValues);
|
||||
};
|
||||
|
||||
const setDynamicModelFields = (record: KnowledgeBaseListItem | null, types: string[]) => {
|
||||
if (!record || !types.length) return;
|
||||
const dynamicValues: Record<string, string | undefined> = {};
|
||||
const source = record as unknown as Record<string, unknown>;
|
||||
types.forEach((tp) => {
|
||||
const fieldKey = typeToFieldKey(tp);
|
||||
const fieldValue = source[fieldKey];
|
||||
if (typeof fieldValue === 'string') {
|
||||
dynamicValues[fieldKey] = fieldValue;
|
||||
}
|
||||
});
|
||||
if (Object.keys(dynamicValues).length) {
|
||||
form.setFieldsValue(dynamicValues as Partial<KnowledgeBaseFormData>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (record?: KnowledgeBaseListItem | null, type?: string) => {
|
||||
setDatasets(record || null);
|
||||
const nextType = type || currentType;
|
||||
setCurrentType(nextType);
|
||||
setBaseFields(record || null, nextType);
|
||||
getTypeList(record || null);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const getTypeList = async (record: KnowledgeBaseListItem | null) => {
|
||||
const response = await getModelTypeList();
|
||||
const types = Array.isArray(response) ? [...response.filter(type => type !== 'chat'),'image2text'] : [];
|
||||
setModelTypeList(types);
|
||||
if (types.length) {
|
||||
await fetchModelLists(types);
|
||||
setDynamicModelFields(record, types);
|
||||
} else {
|
||||
setModelOptionsByType({});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setBaseFields(datasets, currentType);
|
||||
setDynamicModelFields(datasets, modelTypeList);
|
||||
}, [visible, datasets, currentType, modelTypeList]);
|
||||
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
setLoading(true)
|
||||
const formValues = form.getFieldsValue();
|
||||
const payload: KnowledgeBaseFormData = {
|
||||
...formValues,
|
||||
type: formValues.type || currentType,
|
||||
permission_id: formValues.permission_id || 'Private',
|
||||
parent_id: datasets?.parent_id || undefined,
|
||||
};
|
||||
const submit = datasets?.id
|
||||
? updateKnowledgeBase(datasets.id, payload)
|
||||
: createKnowledgeBase(payload);
|
||||
submit
|
||||
.then(() => {
|
||||
if (refreshTable) {
|
||||
refreshTable();
|
||||
}
|
||||
handleClose();
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
}).catch((err) => {
|
||||
console.log('Validation failed:', err)
|
||||
});
|
||||
}
|
||||
const handleChange = (value: string, tp: string) => {
|
||||
// 只在编辑模式且类型为 embedding 时触发提示
|
||||
if (datasets?.id && tp.toLowerCase() === 'embedding') {
|
||||
const fieldKey = typeToFieldKey(tp);
|
||||
// 从原始 datasets 对象中获取之前的值
|
||||
const previousValue = (datasets as any)[fieldKey];
|
||||
|
||||
confirm({
|
||||
title: t('common.updateWarning'),
|
||||
content: t('knowledgeBase.updateEmbeddingContent'),
|
||||
onOk: () => {
|
||||
// 确定时什么也不做,保持新值
|
||||
},
|
||||
onCancel: () => {
|
||||
// 取消时恢复之前的值
|
||||
form.setFieldsValue({ [fieldKey]: previousValue } as any);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
// 根据 type 获取标题
|
||||
const getTitle = () => {
|
||||
if (datasets?.id) {
|
||||
return t('knowledgeBase.edit') + ' ' + datasets.name;
|
||||
}
|
||||
if (currentType === 'Folder') {
|
||||
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.folder');
|
||||
}
|
||||
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.knowledgeBase');
|
||||
};
|
||||
|
||||
const dynamicTypeList = useMemo(() => modelTypeList.filter((tp) => (modelOptionsByType[tp] || []).length), [modelTypeList, modelOptionsByType]);
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={getTitle()}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={datasets?.id ? t('common.save') : t('common.create')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
permission_id: 'Private', // 设置 permission_id 的默认值
|
||||
type: currentType,
|
||||
}}
|
||||
>
|
||||
{/* <div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('model.basicParameters')}</div> */}
|
||||
{!datasets?.id && (
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('knowledgeBase.createForm.name')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.createForm.name')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="description" label={t('knowledgeBase.createForm.description')}>
|
||||
<TextArea rows={2} placeholder={t('knowledgeBase.createForm.description')} />
|
||||
</Form.Item>
|
||||
|
||||
{currentType !== 'Folder' && dynamicTypeList.map((tp) => {
|
||||
const fieldKey = typeToFieldKey(tp);
|
||||
// 当 tp 为 'llm' 时,合并 llm 和 chat 的选项
|
||||
const options = tp.toLowerCase() === 'llm'
|
||||
? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])]
|
||||
: modelOptionsByType[tp] || [];
|
||||
return (
|
||||
<Form.Item
|
||||
key={tp}
|
||||
name={fieldKey as keyof KnowledgeBaseFormData}
|
||||
label={t(`knowledgeBase.createForm.${fieldKey}`) + ' ' + 'model'}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.modelRequired') }]}
|
||||
>
|
||||
<Select
|
||||
options={options}
|
||||
placeholder={t(`knowledgeBase.createForm.${fieldKey}`)}
|
||||
allowClear={false}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={(value) => handleChange(value, tp)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreateModal;
|
||||
91
web/src/views/KnowledgeBase/components/DelimiterSelector.tsx
Normal file
91
web/src/views/KnowledgeBase/components/DelimiterSelector.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Select, Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DELIMITER_OPTIONS, isCustomDelimiter } from '../constants/delimiter';
|
||||
|
||||
interface DelimiterSelectorProps {
|
||||
value?: string | null;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DelimiterSelector: FC<DelimiterSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// 默认值为空字符串(不设置)
|
||||
const [selectedValue, setSelectedValue] = useState<string>(value || '');
|
||||
const [customValue, setCustomValue] = useState<string>('');
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检查当前值是否为自定义值
|
||||
if (value && isCustomDelimiter(value) && value !== 'custom') {
|
||||
setSelectedValue('custom');
|
||||
setCustomValue(value);
|
||||
setShowCustomInput(true);
|
||||
} else {
|
||||
setSelectedValue(value || '');
|
||||
setShowCustomInput(value === 'custom');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleSelectChange = (val: string) => {
|
||||
setSelectedValue(val);
|
||||
|
||||
if (val === 'custom') {
|
||||
setShowCustomInput(true);
|
||||
// 如果已有自定义值,使用它;否则等待用户输入
|
||||
if (customValue) {
|
||||
onChange?.(customValue);
|
||||
} else {
|
||||
// 自定义但还没输入值,暂不触发 onChange
|
||||
onChange?.(undefined);
|
||||
}
|
||||
} else if (val === '') {
|
||||
// 选择"不设置"时,返回 undefined(不传递该参数)
|
||||
setShowCustomInput(false);
|
||||
onChange?.(undefined);
|
||||
} else {
|
||||
setShowCustomInput(false);
|
||||
onChange?.(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setCustomValue(val);
|
||||
// 只有当输入不为空时才触发 onChange
|
||||
onChange?.(val || undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rb:flex rb:gap-2 ${className}`}>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onChange={handleSelectChange}
|
||||
placeholder={placeholder || t('knowledgeBase.selectDelimiter') || '请选择分隔符'}
|
||||
className='rb:w-full'
|
||||
options={DELIMITER_OPTIONS.map(opt => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
value={customValue}
|
||||
onChange={handleCustomInputChange}
|
||||
placeholder={t('knowledgeBase.customDelimiterPlaceholder') || '请输入自定义分隔符'}
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DelimiterSelector;
|
||||
365
web/src/views/KnowledgeBase/components/FolderTree.tsx
Normal file
365
web/src/views/KnowledgeBase/components/FolderTree.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import type { CSSProperties, Key, ReactNode } from 'react';
|
||||
import { Tree } from 'antd';
|
||||
import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
|
||||
import textIcon from '@/assets/images/knowledgeBase/text.png';
|
||||
import imageIcon from '@/assets/images/knowledgeBase/image.png';
|
||||
import datasetsIcon from '@/assets/images/knowledgeBase/datasets.png';
|
||||
import switcherIcon from '@/assets/images/knowledgeBase/switcher.png';
|
||||
import { getFolderList } from '../service';
|
||||
|
||||
const { DirectoryTree } = Tree;
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'rtf',
|
||||
'doc',
|
||||
'docx',
|
||||
'pdf',
|
||||
'csv',
|
||||
'json',
|
||||
'xml',
|
||||
'html',
|
||||
'htm',
|
||||
'log',
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'webp',
|
||||
'svg',
|
||||
'tiff',
|
||||
'ico',
|
||||
]);
|
||||
|
||||
export interface TreeNodeData {
|
||||
key: Key;
|
||||
title: ReactNode;
|
||||
icon?: string;
|
||||
switcherIcon?: string;
|
||||
type?: string;
|
||||
isLeaf?: boolean;
|
||||
children?: TreeNodeData[];
|
||||
}
|
||||
|
||||
interface FolderTreeProps {
|
||||
knowledgeBaseId: string;
|
||||
onSelect?: TreeProps['onSelect'];
|
||||
onExpand?: TreeProps['onExpand'];
|
||||
multiple?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
refreshKey?: number;
|
||||
onRootLoad?: (nodes: TreeNodeData[] | null) => void;
|
||||
}
|
||||
|
||||
const renderIcon = (icon?: string) => {
|
||||
if (!icon) return undefined;
|
||||
return <img src={icon} alt="icon" style={{ width: 16, height: 16 }} />;
|
||||
};
|
||||
|
||||
const transformTreeData = (nodes: TreeNodeData[]): DataNode[] =>
|
||||
nodes.map((node) => {
|
||||
const children = node.children && node.children.length > 0 ? transformTreeData(node.children) : undefined;
|
||||
return {
|
||||
key: node.key,
|
||||
title: node.title ?? '',
|
||||
icon: renderIcon(node.icon),
|
||||
switcherIcon: renderIcon(node.switcherIcon),
|
||||
isLeaf: node.isLeaf,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
const buildMockTreeData = (): TreeNodeData[] => ([
|
||||
{
|
||||
title: '数据集文件夹',
|
||||
key: '0',
|
||||
icon: folderIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
title: '文本数据集',
|
||||
key: '0-0',
|
||||
icon: textIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'text',
|
||||
children: [
|
||||
{
|
||||
title: '子文件夹1',
|
||||
key: '0-0-0',
|
||||
icon: folderIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
title: '文档1.txt',
|
||||
key: '0-0-0-0',
|
||||
icon: textIcon,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
title: '文档2.txt',
|
||||
key: '0-0-0-1',
|
||||
icon: textIcon,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '子文件夹2',
|
||||
key: '0-0-1',
|
||||
icon: folderIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
title: '嵌套文件夹',
|
||||
key: '0-0-1-0',
|
||||
icon: folderIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
title: '深度文档.txt',
|
||||
key: '0-0-1-0-0',
|
||||
icon: textIcon,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '图片数据集',
|
||||
key: '0-1',
|
||||
icon: imageIcon,
|
||||
switcherIcon: switcherIcon,
|
||||
type: 'image',
|
||||
children: [
|
||||
{
|
||||
title: '图片1.jpg',
|
||||
key: '0-1-0',
|
||||
icon: imageIcon,
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
title: '图片2.png',
|
||||
key: '0-1-1',
|
||||
icon: imageIcon,
|
||||
type: 'image',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通用数据集',
|
||||
key: '0-2',
|
||||
icon: datasetsIcon,
|
||||
type: 'dataset',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const normalizeExt = (ext?: string): string => {
|
||||
if (typeof ext !== 'string') return '';
|
||||
return ext.trim().replace(/^\./, '').toLowerCase();
|
||||
};
|
||||
|
||||
const isFolderLike = (node: any): boolean => {
|
||||
const ext = normalizeExt(node?.file_ext);
|
||||
if (ext) {
|
||||
return ext === 'folder';
|
||||
}
|
||||
const type = typeof node?.type === 'string' ? node.type.toLowerCase() : '';
|
||||
if (type === 'folder' || type === 'directory') return true;
|
||||
if (typeof node?.is_directory === 'boolean') return node.is_directory;
|
||||
if (typeof node?.is_dir === 'boolean') return node.is_dir;
|
||||
if (node?.folder_name || node?.children) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNodeTitle = (node: any): string => (
|
||||
node?.folder_name
|
||||
?? node?.file_name
|
||||
?? node?.name
|
||||
?? node?.title
|
||||
?? '未命名节点'
|
||||
);
|
||||
|
||||
const getNodeIcon = (node: any, isFolder: boolean): string => {
|
||||
if (isFolder) return folderIcon;
|
||||
const type = typeof node?.type === 'string' ? node.type.toLowerCase() : '';
|
||||
if (type === 'image') return imageIcon;
|
||||
if (type === 'text') return textIcon;
|
||||
const ext = normalizeExt(node?.file_ext);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return imageIcon;
|
||||
if (TEXT_EXTENSIONS.has(ext)) return textIcon;
|
||||
return datasetsIcon;
|
||||
};
|
||||
|
||||
const extractItems = (resp: any): any[] => {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (Array.isArray(resp?.items)) return resp.items;
|
||||
if (Array.isArray(resp?.list)) return resp.list;
|
||||
if (Array.isArray(resp?.data?.items)) return resp.data.items;
|
||||
return [];
|
||||
};
|
||||
|
||||
// 只加载当前层级的节点,不递归加载子节点
|
||||
const buildTreeNodes = async (
|
||||
kbId: string,
|
||||
parentId: string,
|
||||
): Promise<TreeNodeData[]> => {
|
||||
const currentParent = String(parentId ?? '');
|
||||
if (!currentParent) return [];
|
||||
|
||||
// 只请求一次当前层级的数据,不分页
|
||||
const response = await getFolderList({
|
||||
kb_id: kbId,
|
||||
parent_id: currentParent,
|
||||
page: 1,
|
||||
pagesize: 1000
|
||||
} as any);
|
||||
|
||||
const rawItems = extractItems(response);
|
||||
const nodes: TreeNodeData[] = [];
|
||||
|
||||
for (let index = 0; index < rawItems.length; index += 1) {
|
||||
const raw = rawItems[index];
|
||||
const keySource = raw?.id ?? raw?.file_id ?? raw?.key ?? raw?.folder_id ?? `${currentParent}-${index}`;
|
||||
const nodeKey = String(keySource);
|
||||
const isFolder = isFolderLike(raw);
|
||||
|
||||
// 只显示文件夹
|
||||
if (!isFolder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 文件夹节点初始不加载子节点,isLeaf设为false表示可能有子节点
|
||||
nodes.push({
|
||||
key: nodeKey,
|
||||
title: getNodeTitle(raw),
|
||||
icon: getNodeIcon(raw, isFolder),
|
||||
switcherIcon: isFolder ? switcherIcon : undefined,
|
||||
type: isFolder ? 'folder' : (typeof raw?.type === 'string' ? raw.type : normalizeExt(raw?.file_ext) || 'file'),
|
||||
isLeaf: false, // 文件夹节点初始设为false,表示可能有子节点,需要展开时加载
|
||||
children: undefined, // 初始不加载子节点
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const FolderTree: FC<FolderTreeProps> = ({
|
||||
knowledgeBaseId,
|
||||
onSelect,
|
||||
onExpand,
|
||||
multiple,
|
||||
className,
|
||||
style,
|
||||
refreshKey = 0,
|
||||
onRootLoad,
|
||||
}) => {
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>([]);
|
||||
|
||||
// 更新树节点数据的辅助函数
|
||||
const updateTreeData = (nodes: TreeNodeData[], key: Key, children: TreeNodeData[]): TreeNodeData[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.key === key) {
|
||||
return {
|
||||
...node,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
isLeaf: children.length === 0,
|
||||
};
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: updateTreeData(node.children, key, children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// 加载根节点
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
setTreeData([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nodes = await buildTreeNodes(knowledgeBaseId, knowledgeBaseId);
|
||||
if (!cancelled) {
|
||||
setTreeData(nodes);
|
||||
if (onRootLoad) {
|
||||
onRootLoad(nodes.length > 0 ? nodes : null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载文件夹树失败:', e);
|
||||
if (!cancelled) {
|
||||
const fallback = buildMockTreeData();
|
||||
setTreeData(fallback);
|
||||
if (onRootLoad) {
|
||||
onRootLoad(fallback.length > 0 ? fallback : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [knowledgeBaseId, refreshKey]);
|
||||
|
||||
// 懒加载子节点 - 只在展开时加载
|
||||
const onLoadData = async (node: any) => {
|
||||
const { key } = node;
|
||||
|
||||
// 如果已经加载过子节点,不再重复加载
|
||||
if (node.children !== undefined) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用节点的 key 作为 parent_id 加载子文件夹
|
||||
const children = await buildTreeNodes(knowledgeBaseId, String(key));
|
||||
setTreeData((prevData) => updateTreeData(prevData, key, children));
|
||||
} catch (e) {
|
||||
console.error('加载子节点失败:', e);
|
||||
// 加载失败时,将该节点标记为叶子节点(没有子节点)
|
||||
setTreeData((prevData) => updateTreeData(prevData, key, []));
|
||||
}
|
||||
};
|
||||
|
||||
const treeNodes = useMemo(() => transformTreeData(treeData), [treeData]);
|
||||
|
||||
return (
|
||||
<DirectoryTree
|
||||
multiple={multiple}
|
||||
className={className}
|
||||
style={style}
|
||||
onSelect={onSelect}
|
||||
onExpand={onExpand}
|
||||
loadData={onLoadData}
|
||||
treeData={treeNodes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderTree;
|
||||
44
web/src/views/KnowledgeBase/components/InfoPanel.tsx
Normal file
44
web/src/views/KnowledgeBase/components/InfoPanel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-18 16:27:41
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-19 19:59:36
|
||||
*/
|
||||
import { Divider } from 'antd';
|
||||
|
||||
export interface InfoItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface InfoPanelProps {
|
||||
title: string;
|
||||
items: InfoItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InfoPanel = ({ title, items, className = '' }: InfoPanelProps) => {
|
||||
return (
|
||||
<div className={`rb:w-full ${className}`}>
|
||||
<h2 className="rb:text-lg rb:font-medium">{title}</h2>
|
||||
<Divider />
|
||||
<div className='rb:flex rb:flex-col rb:items-start rb:gap-6'>
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className='rb:flex rb:w-full rb:items-start rb:justify-start rb:gap-2'>
|
||||
{item.icon && <img src={item.icon} className='rb:size-4 rb:mt-[2px]' alt="" />}
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-2'>
|
||||
<span className='rb:text-gray-500 rb:text-sm'>{item.label}</span>
|
||||
<span className='rb:text-gray-800'>{item.value ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoPanel;
|
||||
156
web/src/views/KnowledgeBase/components/InsertModal.tsx
Normal file
156
web/src/views/KnowledgeBase/components/InsertModal.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Input, message, Tabs } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RbModal from '@/components/RbModal';
|
||||
import RbMarkdown from '@/components/Markdown';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface InsertModalRef {
|
||||
handleOpen: (documentId: string, initialContent?: string, chunkId?: string) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface InsertModalProps {
|
||||
onInsert?: (documentId: string, content: string, chunkId?: string) => Promise<boolean>;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const InsertModal = forwardRef<InsertModalRef, InsertModalProps>(({ onInsert, onSuccess }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [documentId, setDocumentId] = useState<string>('');
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [chunkId, setChunkId] = useState<string | undefined>(undefined);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('edit');
|
||||
|
||||
const handleOpen = (docId: string, initialContent?: string, chunkIdParam?: string) => {
|
||||
setDocumentId(docId);
|
||||
setContent(initialContent || '');
|
||||
setChunkId(chunkIdParam);
|
||||
setIsEditMode(!!initialContent);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setContent('');
|
||||
setDocumentId('');
|
||||
setChunkId(undefined);
|
||||
setIsEditMode(false);
|
||||
setActiveTab('edit');
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!content.trim()) {
|
||||
message.warning(t('knowledgeBase.pleaseEnterContent') || '请输入内容');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!documentId) {
|
||||
message.error(t('knowledgeBase.documentIdRequired') || '文档ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onInsert) {
|
||||
const success = await onInsert(documentId, content.trim(), chunkId);
|
||||
if (success) {
|
||||
const successMsg = isEditMode
|
||||
? (t('knowledgeBase.updateSuccess') || '更新成功')
|
||||
: (t('knowledgeBase.insertSuccess') || '插入成功');
|
||||
message.success(successMsg);
|
||||
handleClose();
|
||||
// 只有插入模式才调用 onSuccess(编辑模式已在 handleInsertContent 中直接更新列表)
|
||||
if (!isEditMode) {
|
||||
onSuccess?.();
|
||||
}
|
||||
} else {
|
||||
const errorMsg = isEditMode
|
||||
? (t('knowledgeBase.updateFailed') || '更新失败')
|
||||
: (t('knowledgeBase.insertFailed') || '插入失败');
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
const errorMsg = isEditMode
|
||||
? (t('knowledgeBase.updateFailed') || '更新失败')
|
||||
: (t('knowledgeBase.insertFailed') || '插入失败');
|
||||
message.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value);
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose,
|
||||
}));
|
||||
|
||||
// 构建标签页项目,content 为空或新增时不显示预览
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('knowledgeBase.edit') || '编辑',
|
||||
children: (
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder={t('knowledgeBase.insertContentPlaceholder') || '请输入内容...'}
|
||||
rows={10}
|
||||
maxLength={10000}
|
||||
showCount
|
||||
autoFocus
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 只有在编辑模式且有内容时才显示预览标签页
|
||||
if (isEditMode && content) {
|
||||
tabItems.push({
|
||||
key: 'preview',
|
||||
label: t('knowledgeBase.preview') || '预览',
|
||||
children: (
|
||||
<div className='rb:border rb:border-[#D9D9D9] rb:rounded rb:p-4 rb:min-h-[280px] rb:max-h-[400px] rb:overflow-y-auto rb:bg-white'>
|
||||
<RbMarkdown content={content} showHtmlComments={true} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={isEditMode
|
||||
? (t('knowledgeBase.editContent') || '编辑内容')
|
||||
: (t('knowledgeBase.insertContent') || '插入内容')
|
||||
}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('common.confirm') || '确认'}
|
||||
cancelText={t('common.cancel') || '取消'}
|
||||
width={600}
|
||||
>
|
||||
<div className='rb:flex rb:flex-col rb:gap-4'>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default InsertModal;
|
||||
199
web/src/views/KnowledgeBase/components/RecallTest.tsx
Normal file
199
web/src/views/KnowledgeBase/components/RecallTest.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||
import { Form, Input, Select, Button, InputNumber } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { RecallTestDrawerRef, RecallTestData, RecallTestParams } from '../types';
|
||||
// import refreshIcon from '@/assets/images/knowledgeBase/refresh-blue.png';
|
||||
import RecallTestResult from './RecallTestResult';
|
||||
import { reChunks, getRetrievalModeType } from '../service';
|
||||
import { hybrid } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface RetrievalModeOption {
|
||||
label: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
const RecallTest = forwardRef<RecallTestDrawerRef>(({},ref) => {
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<RecallTestData[]>([]);
|
||||
const [knowledgeBaseId, setKnowledgeBaseId] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retrieveType, setRetrieveType] = useState<string>('hybrid');
|
||||
const [retrievalModeOptions, setRetrievalModeOptions] = useState<RetrievalModeOption[]>([
|
||||
{ label: t('knowledgeBase.hybrid'), value: true },
|
||||
{ label: t('knowledgeBase.vector'), value: false },
|
||||
]);
|
||||
|
||||
// 获取检索模式选项
|
||||
useEffect(() => {
|
||||
fetchRetrievalModeOptions();
|
||||
}, []);
|
||||
|
||||
const fetchRetrievalModeOptions = async () => {
|
||||
try {
|
||||
const response = await getRetrievalModeType();
|
||||
if (response && Array.isArray(response)) {
|
||||
// 将 API 返回的数据转换为选项格式
|
||||
const options = response.map((item: any) => {
|
||||
// 支持多种数据格式
|
||||
let label = t(`knowledgeBase.${item}`) + ' ' + t(`knowledgeBase.retrieve`);
|
||||
let value = item;
|
||||
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
if (options.length > 0) {
|
||||
setRetrievalModeOptions(options);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取检索模式选项失败:', error);
|
||||
// 保持默认选项
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (kbId?: string) => {
|
||||
console.log('RecallTest - handleOpen called with kbId:', kbId);
|
||||
setKnowledgeBaseId(kbId || '');
|
||||
form.resetFields();
|
||||
setData([]);
|
||||
setRetrieveType('hybrid'); // 重置为默认值
|
||||
// 确保表单字段也设置为默认值
|
||||
form.setFieldsValue({ retrieve_type: 'hybrid' });
|
||||
}
|
||||
const fetchData = (params: RecallTestParams) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
console.log('params', params);
|
||||
reChunks(params)
|
||||
.then((res) => {
|
||||
const response = res as RecallTestData[] ;
|
||||
setData(response || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
const handleStartTest = () => {
|
||||
form.validateFields().then((values) => {
|
||||
const params: RecallTestParams = {
|
||||
query: values.query || '',
|
||||
kb_ids: knowledgeBaseId ? [knowledgeBaseId] : [],
|
||||
similarity_threshold: values.similarity_threshold || 0.2,
|
||||
vector_similarity_weight: values.vector_similarity_weight || 0.3,
|
||||
top_k: values.top_k || 1024,
|
||||
// hybrid: values.retrieve_type !== hybrid ? true : false,
|
||||
retrieve_type: retrieveType,
|
||||
};
|
||||
console.log('RecallTest - params:', params);
|
||||
fetchData(params);
|
||||
}).catch((error) => {
|
||||
console.error('表单验证失败:', error);
|
||||
});
|
||||
}
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
return (
|
||||
<div className='rb:w-full rb:h-full rb:flex rb:flex-col rb:overflow-hidden'>
|
||||
<div className='rb:flex-shrink-0'>
|
||||
<div className='rb:flexx rb:mb-2 rb:items-center rb:justify-between'>
|
||||
<span className='rb:font-medium'>{ t('knowledgeBase.testQuestion')}</span>
|
||||
{/* <div className='rb:flex rb:items-center rb:justify-end'>
|
||||
<img src={refreshIcon} alt="refresh" className='rb:w-4 rb:h-4 rb:mr-2' />
|
||||
<span className='rb:text-[#155eef]'>{ t('knowledgeBase.loadSampleQuestions')}</span>
|
||||
</div> */}
|
||||
</div>
|
||||
<Form form={form} >
|
||||
<Form.Item name="query">
|
||||
<TextArea rows={4} placeholder={t('knowledgeBase.testQuestionPlaceholder')}/>
|
||||
</Form.Item>
|
||||
<div className='rb:grid rb:grid-cols-2 rb:gap-x-4'>
|
||||
<Form.Item
|
||||
name="retrieve_type"
|
||||
label={t('knowledgeBase.retrieveMode')}
|
||||
initialValue="hybrid"
|
||||
>
|
||||
<Select
|
||||
options={retrievalModeOptions}
|
||||
placeholder={t('knowledgeBase.retrieveMode')}
|
||||
onChange={(value) => setRetrieveType(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="top_k" label={t('knowledgeBase.recallQuantity')}>
|
||||
<InputNumber
|
||||
placeholder='1 ~ 1024'
|
||||
min={1}
|
||||
max={1024}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 当 retrieve_type = semantic 或 hybrid 时显示 */}
|
||||
{(retrieveType === 'semantic' || retrieveType === 'hybrid') && (
|
||||
<Form.Item name="similarity_threshold" label={t('knowledgeBase.similarityThreshold')}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '0.1', value: 0.1 },
|
||||
{ label: '0.2', value: 0.2 },
|
||||
{ label: '0.3', value: 0.3 },
|
||||
{ label: '0.4', value: 0.4 },
|
||||
{ label: '0.5', value: 0.5 },
|
||||
{ label: '0.6', value: 0.6 },
|
||||
{ label: '0.7', value: 0.7 },
|
||||
{ label: '0.8', value: 0.8 },
|
||||
{ label: '0.9', value: 0.9 },
|
||||
{ label: '1.0', value: 1.0 },
|
||||
]}
|
||||
placeholder={t('knowledgeBase.similarityThreshold')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 当 retrieve_type = participle 或 hybrid 时显示 */}
|
||||
{(retrieveType === 'participle' || retrieveType === 'hybrid') && (
|
||||
<Form.Item name="vector_similarity_weight" label={t('knowledgeBase.semanticSimilarity')}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '0.1', value: 0.1 },
|
||||
{ label: '0.2', value: 0.2 },
|
||||
{ label: '0.3', value: 0.3 },
|
||||
{ label: '0.4', value: 0.4 },
|
||||
{ label: '0.5', value: 0.5 },
|
||||
{ label: '0.6', value: 0.6 },
|
||||
{ label: '0.7', value: 0.7 },
|
||||
{ label: '0.8', value: 0.8 },
|
||||
{ label: '0.9', value: 0.9 },
|
||||
{ label: '1.0', value: 1.0 },
|
||||
]}
|
||||
placeholder={t('knowledgeBase.semanticSimilarity')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* <Form.Item name="hybrid" valuePropName="checked" initialValue={true} label={t('knowledgeBase.hybrid') || 'Hybrid'}>
|
||||
<Switch checkedChildren={t('common.yes') || 'Yes'} unCheckedChildren={t('common.no') || 'No'} />
|
||||
</Form.Item> */}
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleStartTest} loading={loading}>{ t('knowledgeBase.startTesting')}</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* <div className='rb:flex rb:items-center rb:justify-end'>
|
||||
|
||||
</div> */}
|
||||
</Form>
|
||||
</div>
|
||||
<div className='rb:flex-1 rb:overflow-y-auto rb:min-h-0'>
|
||||
<RecallTestResult data={data} showEmpty={true} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RecallTest;
|
||||
60
web/src/views/KnowledgeBase/components/RecallTestDrawer.tsx
Normal file
60
web/src/views/KnowledgeBase/components/RecallTestDrawer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RbDrawer from '@/components/RbDrawer';
|
||||
import type { RecallTestDrawerRef } from '../types';
|
||||
import RecallTest from './RecallTest';
|
||||
|
||||
const RecallTestDrawer = forwardRef<RecallTestDrawerRef>(({},ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const recallTestRef = useRef<any>(null);
|
||||
const pendingKbIdRef = useRef<string | undefined>(undefined);
|
||||
const shouldCallHandleOpenRef = useRef(false);
|
||||
|
||||
// 调用 RecallTest 的 handleOpen 方法
|
||||
const callRecallTestHandleOpen = useCallback(() => {
|
||||
if (recallTestRef.current && shouldCallHandleOpenRef.current) {
|
||||
recallTestRef.current.handleOpen(pendingKbIdRef.current);
|
||||
shouldCallHandleOpenRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpen = (kbId?: string) => {
|
||||
pendingKbIdRef.current = kbId;
|
||||
shouldCallHandleOpenRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
// 当 Drawer 打开时,尝试调用 handleOpen
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
callRecallTestHandleOpen();
|
||||
}
|
||||
}, [open, callRecallTestHandleOpen]);
|
||||
|
||||
// 使用回调 ref 确保在组件挂载后立即调用
|
||||
const setRecallTestRef = useCallback((node: any) => {
|
||||
recallTestRef.current = node;
|
||||
if (open && shouldCallHandleOpenRef.current) {
|
||||
callRecallTestHandleOpen();
|
||||
}
|
||||
}, [open, callRecallTestHandleOpen]);
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbDrawer
|
||||
title={t('knowledgeBase.recallTest')}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<RecallTest ref={setRecallTestRef} />
|
||||
</RbDrawer>
|
||||
);
|
||||
});
|
||||
|
||||
export default RecallTestDrawer;
|
||||
187
web/src/views/KnowledgeBase/components/RecallTestResult.tsx
Normal file
187
web/src/views/KnowledgeBase/components/RecallTestResult.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* @Description: 滚动列表
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-18 16:19:58
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-29 19:08:40
|
||||
*/
|
||||
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { RecallTestData } from '../types';
|
||||
import { NoData } from './noData';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import RbMarkdown from '@/components/Markdown';
|
||||
|
||||
interface RecallTestResultProps {
|
||||
data: RecallTestData[];
|
||||
showEmpty?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
loading?: boolean;
|
||||
scrollableTarget?: string;
|
||||
editable?: boolean; // 是否可编辑
|
||||
onItemClick?: (item: RecallTestData, index: number) => void; // 点击项的回调
|
||||
}
|
||||
|
||||
const RecallTestResult = ({
|
||||
data,
|
||||
showEmpty = true,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
loading = false,
|
||||
scrollableTarget,
|
||||
editable = false,
|
||||
onItemClick,
|
||||
}: RecallTestResultProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent, item: RecallTestData, index: number) => {
|
||||
// 检查点击的是否是图片或图片相关元素
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 检查是否点击了图片本身、图片的容器、预览层、关闭按钮或 SVG 图标
|
||||
if (
|
||||
target.tagName === 'IMG' ||
|
||||
target.tagName === 'SVG' || // SVG 图标
|
||||
target.tagName === 'PATH' || // SVG 路径
|
||||
target.closest('.ant-image') ||
|
||||
target.closest('.ant-image-preview') ||
|
||||
target.closest('.ant-image-preview-wrap') ||
|
||||
target.closest('.ant-image-preview-operations') ||
|
||||
target.closest('.anticon') || // Ant Design 图标
|
||||
target.classList.contains('ant-image-img') ||
|
||||
target.classList.contains('ant-image-mask') ||
|
||||
target.classList.contains('ant-image-preview-close') ||
|
||||
target.classList.contains('anticon')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editable && onItemClick) {
|
||||
onItemClick(item, index);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据分数获取颜色类名
|
||||
const getScoreColorClass = (score: number): string => {
|
||||
const percentage = score * 100;
|
||||
if (percentage >= 90) {
|
||||
return 'rb:text-[#155EEF]';
|
||||
} else if (percentage >= 80) {
|
||||
return 'rb:text-[#369F21]';
|
||||
} else {
|
||||
return 'rb:text-[#FF5D34]';
|
||||
}
|
||||
};
|
||||
|
||||
if (data.length === 0 && showEmpty) {
|
||||
return (
|
||||
<NoData
|
||||
title={t('knowledgeBase.recallTestUnStart')}
|
||||
subTitle={t('knowledgeBase.recallTestUnStartSubTitle')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderContent = () => (
|
||||
<div className='rb:flex rb:flex-col rb:mt-4'>
|
||||
{data.map((item, index) => {
|
||||
const score = item.metadata?.score ?? 1;
|
||||
const scorePercentage = score * 100;
|
||||
const colorClass = getScoreColorClass(score);
|
||||
const showScore = item.metadata?.score !== null && item.metadata?.score !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.metadata?.sort_id || index}-${index}`}
|
||||
className={`rb:flex rb:flex-col rb:mb-4 rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:p-4 rb:pt-2 rb:pb-3 rb:relative rb:group ${editable ? 'rb:cursor-pointer rb:transition-all hover:rb:border-[#155EEF] hover:rb:shadow-md' : ''}`}
|
||||
onClick={(e) => handleItemClick(e, item, index)}
|
||||
>
|
||||
{editable && (
|
||||
<div className='rb:absolute rb:top-2 rb:right-2 rb:opacity-0 group-hover:rb:opacity-100 rb:transition-opacity'>
|
||||
<EditOutlined className='rb:text-[#155EEF] rb:text-base' />
|
||||
</div>
|
||||
)}
|
||||
<div className='rb:flex rb:items-center rb:justify-between'>
|
||||
{showScore && (
|
||||
<span className={`${colorClass} rb:text-xl rb:font-semibold`}>
|
||||
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
|
||||
</span>
|
||||
)}
|
||||
<div className={`rb:flex rb:mt-2 rb:flex-col rb:items-end rb:justify-end rb:gap-1 ${!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-[#F0F3F8] rb:px-1 rb:py-[2px] rb:rounded'>
|
||||
chunk_{item.metadata?.sort_id || index}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-[#F0F3F8] 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'>
|
||||
<RbMarkdown content={item.page_content} showHtmlComments={true} />
|
||||
</div>
|
||||
</div>
|
||||
{item.metadata?.file_created_at && (
|
||||
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'>
|
||||
<span className='rb:text-gray-500 rb:text-xs'>
|
||||
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loading && (
|
||||
<div className='rb:mb-4'>
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果提供了 loadMore 和 hasMore,使用 InfiniteScroll
|
||||
if (loadMore && hasMore !== undefined) {
|
||||
return (
|
||||
<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-[#155EEF]'>{data.length}</span> results)
|
||||
</span>
|
||||
</div>
|
||||
<InfiniteScroll
|
||||
dataLength={data.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />}
|
||||
scrollableTarget={scrollableTarget}
|
||||
>
|
||||
{renderContent()}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 否则使用普通渲染
|
||||
return (
|
||||
<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-[#155EEF]'>{data.length}</span> results)
|
||||
</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecallTestResult;
|
||||
137
web/src/views/KnowledgeBase/components/ShareModal.tsx
Normal file
137
web/src/views/KnowledgeBase/components/ShareModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-29 12:29:31
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { message } from 'antd';
|
||||
import type { ShareModalRef, ShareModalRefProps, KnowledgeBase} from '../types';
|
||||
import RbModal from '@/components/RbModal'
|
||||
// import betchControlIcon from '@/assets/images/knowledgeBase/betch-control.png';
|
||||
import kbIcon from '@/assets/images/knowledgeBase/knowledge-management.png';
|
||||
// import robotIcon from '@/assets/images/knowledgeBase/robot.png';
|
||||
import { updateKnowledgeBase, getWorkspaceAuthorizationList } from '../service';
|
||||
import { NoData } from './noData';
|
||||
import type { ListQuery, ShareSpaceModalRef } from '../types';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import ShareSpaceModal from './ShareSpaceModal'
|
||||
const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare: onShare }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const shareSpaceModalRef = useRef<ShareSpaceModalRef>(null);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [curIndex, setCurIndex] = useState(9999);
|
||||
const [query, setQuery] = useState<ListQuery>({});
|
||||
|
||||
const [kbId, setKbId] = useState<string>('');
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
|
||||
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setCurIndex(9999);
|
||||
setLoading(false)
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleOpen = (kb_id?: string,knowledgeBase?: KnowledgeBase | null) => {
|
||||
setKbId(kb_id ?? '');
|
||||
setKnowledgeBase(knowledgeBase ?? null);
|
||||
setVisible(true);
|
||||
getShareSpaceList(kb_id || '')
|
||||
// getSpaceListFn()
|
||||
};
|
||||
const getShareSpaceList = async(id: string) => {
|
||||
try{
|
||||
const response = await getWorkspaceAuthorizationList(id)
|
||||
setSpaceList(response?.items as any[]);
|
||||
} catch (error) {
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const handleShare = async() => {
|
||||
const workspaceIds = spaceList
|
||||
.map(item => item.target_kb?.workspace_id)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
console.log('Workspace IDs:', workspaceIds);
|
||||
shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds);
|
||||
|
||||
// 分享后关闭弹窗
|
||||
handleClose();
|
||||
}
|
||||
const handleChange = (checked: boolean, item: any) => {
|
||||
// 打开/关闭分享出去的数据库
|
||||
console.log('Switch changed:', checked, item);
|
||||
updateKnowledgeBase(item.target_kb?.id, {
|
||||
status: checked ? 1 : 2
|
||||
}).then(() => {
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
getShareSpaceList(kbId);
|
||||
}).catch(() => {
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose,
|
||||
handleShare
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<RbModal
|
||||
title={t('knowledgeBase.toWorkspace')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('knowledgeBase.share')}
|
||||
onOk={handleShare}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<div className='rb:flex rb:flex-col rb:text-left'>
|
||||
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareTitle')}</h4>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareNote')}</span>
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
|
||||
{spaceList.length === 0 && (
|
||||
<NoData />
|
||||
)}
|
||||
{spaceList.map((item,index) => (
|
||||
<div key={index}
|
||||
className={`rb:flex rb:items-center rb:justify-between rb:border-gray-200 rb:gap-2 rb:rounded-lg rb:p-4 rb:border`}
|
||||
|
||||
>
|
||||
<div className='rb:flex rb:items-center rb:gap-2'>
|
||||
<img src={item.icon || kbIcon} className='rb:size-[20px]' />
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-1'>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{item.target_workspace?.name}</span>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.authorizedPerson')}:{item.shared_user?.username} {formatDateTime((item.target_workspace?.created_at || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={item.target_kb?.status === 1} onChange={(checked) => handleChange(checked, item)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</RbModal>
|
||||
<ShareSpaceModal
|
||||
ref={shareSpaceModalRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ShareModal;
|
||||
127
web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx
Normal file
127
web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-25 17:46:36
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { message } from 'antd';
|
||||
import type { ShareModalRef, ShareModalRefProps, KnowledgeBase} from '../types';
|
||||
import RbModal from '@/components/RbModal'
|
||||
// import betchControlIcon from '@/assets/images/knowledgeBase/betch-control.png';
|
||||
import kbIcon from '@/assets/images/knowledgeBase/knowledge-management.png';
|
||||
// import robotIcon from '@/assets/images/knowledgeBase/robot.png';
|
||||
import { getSpaceList, shareKnowledgeBase } from '../service';
|
||||
import { NoData } from './noData';
|
||||
import type { SpaceItem } from '../types';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare: onShare }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [curIndex, setCurIndex] = useState(9999);
|
||||
const [kbId, setKbId] = useState<string>('');
|
||||
const [spaceIds, setSpaceIds] = useState<string>('');
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
|
||||
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setCurIndex(9999);
|
||||
setLoading(false)
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleOpen = (kb_id?: string,knowledgeBase?: KnowledgeBase | null, spaceIds?:string) => {
|
||||
setKbId(kb_id ?? '');
|
||||
setSpaceIds(spaceIds ?? '')
|
||||
setKnowledgeBase(knowledgeBase ?? null);
|
||||
setVisible(true);
|
||||
getSpaceListFn(spaceIds ?? '')
|
||||
};
|
||||
const getSpaceListFn = async (ids:string) => {
|
||||
const response = await getSpaceList();
|
||||
const filteredItems = response.items.filter(item => !ids.includes(item.id));
|
||||
setSpaceList(filteredItems as SpaceItem[]);
|
||||
}
|
||||
const handleShare = async() => {
|
||||
|
||||
// 获取所有 checked 为 true 的数据
|
||||
const checkedItems = spaceList.filter(item => item.is_active);
|
||||
// 获取当前选中的项(curIndex 对应的数据)
|
||||
const selectedItem = curIndex !== 9999 ? spaceList[curIndex] : null;
|
||||
const payload = {
|
||||
source_kb_id: kbId ?? '',
|
||||
target_workspace_id: selectedItem?.id ?? '',
|
||||
}
|
||||
const respose = await shareKnowledgeBase(payload)
|
||||
if(respose){
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
}else{
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
}
|
||||
// 调用父组件传递的回调函数,传递选中的数据
|
||||
onShare?.({
|
||||
checkedItems,
|
||||
selectedItem
|
||||
});
|
||||
|
||||
// 分享后关闭弹窗
|
||||
handleClose();
|
||||
}
|
||||
const handleClick = (index: number, checked: boolean) => {
|
||||
if (!checked) return;
|
||||
setCurIndex(index);
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose,
|
||||
handleShare
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<RbModal
|
||||
title={t('knowledgeBase.toWorkspace')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('knowledgeBase.share')}
|
||||
onOk={handleShare}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<div className='rb:flex rb:flex-col rb:text-left'>
|
||||
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareTitle')}</h4>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareNote')}</span>
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
|
||||
{spaceList.length === 0 && (
|
||||
<NoData />
|
||||
)}
|
||||
{spaceList.map((item,index) => (
|
||||
<div key={index}
|
||||
className={`rb:flex rb:items-center rb:justify-between ${curIndex === index ? 'rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF]' : 'rb:border-gray-200'} ${item.is_active ? 'rb:cursor-pointer rb:hover:bg-[rgba(21,94,239,0.06)] rb:hover:border-[#155EEF]' : 'rb:cursor-not-allowed rb:bg-[#F9F9F9]'} rb:gap-2 rb:rounded-lg rb:p-4 rb:border`}
|
||||
onClick={item.is_active ? () => handleClick(index, item.is_active) : undefined}
|
||||
>
|
||||
<div className='rb:flex rb:items-center rb:gap-2'>
|
||||
<img src={item.icon || kbIcon} className='rb:size-[20px]' />
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-1'>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</RbModal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ShareModal;
|
||||
16
web/src/views/KnowledgeBase/components/noData.tsx
Normal file
16
web/src/views/KnowledgeBase/components/noData.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import blankImage from '@/assets/images/knowledgeBase/blankImage.png';
|
||||
|
||||
interface NoDataProps {
|
||||
title?: string;
|
||||
subTitle?: string;
|
||||
image?: string;
|
||||
}
|
||||
export const NoData = ({ title = 'No data', subTitle, image = blankImage }: NoDataProps) => {
|
||||
return (
|
||||
<div className='rb:flex rb:flex-col rb:items-center rb:justify-center rb:mt-9'>
|
||||
<img src={image} alt="blank" className='rb:w-[200px] rb:h-[200px]' />
|
||||
<span className='rb:text-lg'>{title}</span>
|
||||
{subTitle && <span className='rb:text-gray-500 rb:mt-2 rb:text-xs'>{subTitle}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
web/src/views/KnowledgeBase/constants/delimiter.ts
Normal file
71
web/src/views/KnowledgeBase/constants/delimiter.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 文档分隔符选项配置
|
||||
*/
|
||||
|
||||
export interface DelimiterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
displayValue?: string; // 用于显示的值(如果和实际值不同)
|
||||
}
|
||||
|
||||
export const DELIMITER_OPTIONS: DelimiterOption[] = [
|
||||
{
|
||||
label: '不设置',
|
||||
value: '',
|
||||
description: '不使用分隔符(不传递该参数)',
|
||||
},
|
||||
{
|
||||
label: '1个换行符',
|
||||
value: '\n',
|
||||
displayValue: '\\n',
|
||||
description: '使用单个换行符作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '2个换行符',
|
||||
value: '\n\n',
|
||||
displayValue: '\\n\\n',
|
||||
description: '使用两个换行符作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '句号',
|
||||
value: '。',
|
||||
description: '使用句号作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '感叹号',
|
||||
value: '!',
|
||||
description: '使用感叹号作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '问号',
|
||||
value: '?',
|
||||
description: '使用问号作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '分号',
|
||||
value: ';',
|
||||
description: '使用分号作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '=====',
|
||||
value: '=====',
|
||||
description: '使用五个等号作为分隔符',
|
||||
},
|
||||
{
|
||||
label: '自定义',
|
||||
value: 'custom',
|
||||
description: '自定义分隔符',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取分隔符的显示文本
|
||||
export const getDelimiterDisplay = (value: string): string => {
|
||||
const option = DELIMITER_OPTIONS.find(opt => opt.value === value);
|
||||
return option?.displayValue || option?.label || value;
|
||||
};
|
||||
|
||||
// 判断是否为自定义分隔符
|
||||
export const isCustomDelimiter = (value: string): boolean => {
|
||||
return value === 'custom' || !DELIMITER_OPTIONS.some(opt => opt.value === value);
|
||||
};
|
||||
72
web/src/views/KnowledgeBase/datasets.tsx
Normal file
72
web/src/views/KnowledgeBase/datasets.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState, type FC } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import type { KnowledgeBase } from './types';
|
||||
|
||||
const Datasets: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchKnowledgeBaseDetail(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchKnowledgeBaseDetail = (knowledgeBaseId: string) => {
|
||||
setLoading(true);
|
||||
request.get(`/knowledgeBase/${knowledgeBaseId}`)
|
||||
.then((res: any) => {
|
||||
setKnowledgeBase(res.data || res);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/knowledge-base');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return <div>知识库不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:p-6">
|
||||
<div className="rb:mb-4">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rb:mb-4">
|
||||
<h1 className="rb:text-2xl rb:font-bold">{knowledgeBase.name}</h1>
|
||||
<p className="rb:text-gray-600 rb:mt-2">{knowledgeBase.description || t('knowledgeBase.noDescription')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rb:bg-white rb:p-4 rb:rounded">
|
||||
<h2 className="rb:text-lg rb:font-semibold rb:mb-4">{t('knowledgeBase.datasets')}</h2>
|
||||
{/* TODO: 添加数据集列表 */}
|
||||
<div>{t('knowledgeBase.noDataSets')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Datasets;
|
||||
|
||||
4
web/src/views/KnowledgeBase/index.module.css
Normal file
4
web/src/views/KnowledgeBase/index.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.textBg:hover,
|
||||
.textBg.active{
|
||||
background-color: rgba(21, 94, 239, 0.08);
|
||||
}
|
||||
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
|
||||
|
||||
280
web/src/views/KnowledgeBase/service.ts
Normal file
280
web/src/views/KnowledgeBase/service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { request } from "@/utils/request";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type {
|
||||
ShareRequestParams,
|
||||
SpaceItem,
|
||||
UploadFileFormData,
|
||||
FolderFormData,
|
||||
UploadFileResponse,
|
||||
Model,
|
||||
PageRequest,
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseFormData,
|
||||
ListQuery,
|
||||
PathQuery,
|
||||
KnowledgeBaseDocumentData,
|
||||
KnowledgeBaseListResponse,
|
||||
KnowledgeBaseShareListResponse,
|
||||
} from "./types";
|
||||
|
||||
const apiPrefix = '';
|
||||
|
||||
// 从路由中获取空间ID (#号后第一个路径段)
|
||||
export const getSpaceIdFromRoute = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const hash = window.location.hash;
|
||||
if (!hash || hash === '#') return null;
|
||||
// 移除 # 号,然后分割路径
|
||||
const path = hash.slice(1); // 移除 #
|
||||
const segments = path.split('/').filter(Boolean); // 分割并过滤空字符串
|
||||
return segments.length > 0 ? segments[0] : null;
|
||||
};
|
||||
|
||||
export const spaceId = getSpaceIdFromRoute();
|
||||
//获取知识库类型 (返回字符串数组,每个字符串是 KnowledgeBase 的 type 值)
|
||||
export const getKnowledgeBaseTypeList = async (): Promise<string[]> => {
|
||||
const response = await request.get(`${apiPrefix}/knowledges/knowledgetype`);
|
||||
// 如果直接返回字符串数组,直接返回
|
||||
if (Array.isArray(response)) {
|
||||
return response.map(item => {
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof item === 'string') {
|
||||
return item;
|
||||
}
|
||||
// 如果是对象且有 type 字段,提取 type 值
|
||||
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||
return String(item.type);
|
||||
}
|
||||
// 其他情况转换为字符串
|
||||
return String(item);
|
||||
});
|
||||
}
|
||||
// 如果不是数组,返回空数组
|
||||
return [];
|
||||
};
|
||||
// 知识库文档解析类型
|
||||
export const getKnowledgeBaseDocumentParseTypeList = async () => {
|
||||
const response = await request.get(`${apiPrefix}/knowledges/parsertype`);
|
||||
return response as any[];
|
||||
};
|
||||
|
||||
//获取模型类型
|
||||
export const getModelTypeList = async () => {
|
||||
const response = await request.get(`${apiPrefix}/models/type`);
|
||||
return response as any[];
|
||||
};
|
||||
// 获取模型列表
|
||||
export const getModelList = async (type: string | string[], pageInfo: PageRequest) => {
|
||||
const response = await request.get(`${apiPrefix}/models`, { type, ...pageInfo });
|
||||
return response as any;
|
||||
};
|
||||
//获取模型提供者
|
||||
export const getModelProviderList = async () => {
|
||||
const response = await request.get(`${apiPrefix}/models/provider`);
|
||||
return response as any[];
|
||||
};
|
||||
// 获取模型信息
|
||||
export const getModelDetail = async (id: string) => {
|
||||
const response = await request.get(`${apiPrefix}/models/${id}`);
|
||||
return response as Model;
|
||||
};
|
||||
|
||||
// 知识库列表
|
||||
export const getKnowledgeBaseList = async (parent_id?: string, query?: ListQuery) => {
|
||||
const response = await request.get(`${apiPrefix}/knowledges/knowledges`, query);
|
||||
return response as KnowledgeBaseListResponse;
|
||||
};
|
||||
// 知识库详情
|
||||
export const getKnowledgeBaseDetail = async (id: string) => {
|
||||
const response = await request.get(`${apiPrefix}/knowledges/${id}`);
|
||||
return response as KnowledgeBase;
|
||||
};
|
||||
// 创建知识库
|
||||
export const createKnowledgeBase = async (data: KnowledgeBaseFormData) => {
|
||||
const payload: KnowledgeBaseFormData = {
|
||||
...data,
|
||||
permission_id: data.permission_id ?? 'private',
|
||||
};
|
||||
const response = await request.post(`${apiPrefix}/knowledges/knowledge`, payload);
|
||||
return response as KnowledgeBase;
|
||||
};
|
||||
// 更新知识库
|
||||
export const updateKnowledgeBase = async (id: string, data: KnowledgeBaseFormData) => {
|
||||
const payload: KnowledgeBaseFormData = {
|
||||
...data,
|
||||
};
|
||||
const response = await request.put(`${apiPrefix}/knowledges/${id}`, payload);
|
||||
return response as any;
|
||||
};
|
||||
// 删除知识库(软删除)
|
||||
export const deleteKnowledgeBase = async (id: string) => {
|
||||
const response = await request.delete(`${apiPrefix}/knowledges/${id}`);
|
||||
return response as any;
|
||||
}
|
||||
|
||||
// 知识库分享 获取分享空间列表
|
||||
export const getShareSpaceList = async (id: string) => {
|
||||
const response = await request.get(`${apiPrefix}/knowledgeshares/${id}/knowledgeshares`);
|
||||
return response as KnowledgeBaseShareListResponse;
|
||||
}
|
||||
|
||||
// 获取文件夹列表
|
||||
export const getFolderList = async (query: FolderFormData) => {
|
||||
const id = query.parent_id ?? query.kb_id;
|
||||
const response = await request.get(`${apiPrefix}/files/${query.kb_id}/${id}/files`);
|
||||
return response as any;
|
||||
};
|
||||
// 创建文件夹
|
||||
export const createFolder = async (params: FolderFormData) => {
|
||||
const response = await request.post(`${apiPrefix}/files/folder`, undefined, {
|
||||
params,
|
||||
});
|
||||
return response as FolderFormData;
|
||||
};
|
||||
interface UploadFileOptions {
|
||||
kb_id?: string;
|
||||
parent_id?: string;
|
||||
onUploadProgress?: (event: AxiosProgressEvent) => void;
|
||||
}
|
||||
// 上传文件
|
||||
export const uploadFile = async (data: FormData, options?: UploadFileOptions) => {
|
||||
const { kb_id, parent_id, onUploadProgress } = 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(`${apiPrefix}/files/file`, data, {
|
||||
params,
|
||||
onUploadProgress,
|
||||
});
|
||||
return response as UploadFileResponse;
|
||||
};
|
||||
|
||||
// 下载文件
|
||||
export const downloadFile = async (fileId: string, fileName?: string) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const url = `${apiPrefix}/files/${fileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('下载失败');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 创建临时链接触发下载
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.style.display = 'none';
|
||||
if (fileName) {
|
||||
link.setAttribute('download', fileName);
|
||||
}
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 释放 blob URL
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
// 更新文件信息
|
||||
export const updateFile = async (id: string, data: UploadFileFormData) => {
|
||||
const response = await request.put(`${apiPrefix}/files/${id}`, data);
|
||||
return response as UploadFileResponse;
|
||||
};
|
||||
// 删除文件 文件夹 id
|
||||
export const deleteFile = async (id: string) => {
|
||||
const response = await request.delete(`${apiPrefix}/files/${id}`);
|
||||
return response as any;
|
||||
};
|
||||
|
||||
// 获取文档列表
|
||||
export const getDocumentList = async (query: PathQuery) => {
|
||||
const response = await request.get(`${apiPrefix}/documents/${query.kb_id}/${query.parent_id}/documents`, query);
|
||||
return response as KnowledgeBaseDocumentData[];
|
||||
};
|
||||
// 文档详情
|
||||
export const getDocumentDetail = async (id: string) => {
|
||||
const response = await request.get(`${apiPrefix}/documents/${id}`);
|
||||
return response as KnowledgeBaseDocumentData;
|
||||
};
|
||||
// 创建文档
|
||||
export const createDocument = async (data: KnowledgeBaseDocumentData) => {
|
||||
const response = await request.post(`${apiPrefix}/documents/document`, data);
|
||||
return response as KnowledgeBaseDocumentData;
|
||||
};
|
||||
// 更新文档
|
||||
export const updateDocument = async (id: string, data: KnowledgeBaseDocumentData) => {
|
||||
const response = await request.put(`${apiPrefix}/documents/${id}`, data);
|
||||
return response as KnowledgeBaseDocumentData;
|
||||
};
|
||||
// 删除文档
|
||||
export const deleteDocument = async (id: string) => {
|
||||
const response = await request.delete(`${apiPrefix}/documents/${id}`);
|
||||
return response;
|
||||
};
|
||||
// 文档解析
|
||||
export const parseDocument = async (id: string) => {
|
||||
const response = await request.post(`${apiPrefix}/documents/${id}/chunks`);
|
||||
return response as any;
|
||||
};
|
||||
// 文档分块预览
|
||||
export const previewDocumentChunk = async (kb_id:string,id: string) => { // id document_id
|
||||
const response = await request.get(`${apiPrefix}/chunks/${kb_id}/${id}/previewchunks`);
|
||||
return response as any;
|
||||
};
|
||||
//文档分块列表
|
||||
export const getDocumentChunkList = async (query: PathQuery) => {
|
||||
const response = await request.get(`${apiPrefix}/chunks/${query.kb_id}/${query.document_id}/chunks`, query);
|
||||
return response as any;
|
||||
};
|
||||
// 回归测试
|
||||
export const reChunks = async (data: any) => {
|
||||
const response = await request.post(`${apiPrefix}/chunks/retrieval`, data);
|
||||
return response as any;
|
||||
};
|
||||
// 知识库授权 分享空间列表
|
||||
export const getWorkspaceAuthorizationList = async (kb_id: string) => {
|
||||
const response = await request.get(`${apiPrefix}/knowledgeshares/${kb_id}/knowledgeshares`);
|
||||
return response as any;
|
||||
};
|
||||
// 知识库分享
|
||||
export const shareKnowledgeBase = async (data: ShareRequestParams) => {
|
||||
const response = await request.post(`${apiPrefix}/knowledgeshares/knowledgeshare`, data);
|
||||
return response as KnowledgeBase;
|
||||
}
|
||||
// 空间列表
|
||||
export const getSpaceList = async () => {
|
||||
const response = await request.get(`${apiPrefix}/workspaces`,{include_current:false});
|
||||
// API 返回的 data 直接是数组,需要包装成 { items: [] } 格式以保持一致性
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response };
|
||||
}
|
||||
return response as { items: SpaceItem[] };
|
||||
};
|
||||
// 更新文档块儿
|
||||
export const updateDocumentChunk = async (kb_id:string, document_id:string, doc_id:string, data: any) => {
|
||||
const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data);
|
||||
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);
|
||||
return response as any;
|
||||
};
|
||||
// 获取检索模式类型
|
||||
export const getRetrievalModeType = async () => {
|
||||
const response = await request.get(`${apiPrefix}/chunks/retrieve_type`);
|
||||
return response as any;
|
||||
};
|
||||
362
web/src/views/KnowledgeBase/types.ts
Normal file
362
web/src/views/KnowledgeBase/types.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
// 知识库表单数据类型
|
||||
export interface KnowledgeBaseFormData {
|
||||
workspace_id?: string; // 工作空间ID
|
||||
id?: string; // 知识库ID 新建时为空
|
||||
name?: string; // 知识库名称
|
||||
description?: string; // 描述
|
||||
avatar?: string; // 头像
|
||||
embedding_id?: string; // 嵌入模型ID
|
||||
llm_id?: string; // LLM模型ID
|
||||
image2text_id?: string; // 图片转文本模型ID
|
||||
reranker_id?: string; // 重排模型ID
|
||||
chat_id?: string; // 聊天模型ID
|
||||
permission_id?: string; // 权限ID
|
||||
parent_id?: string; // 父ID
|
||||
type?: string; // 知识库类型
|
||||
status?: number; // 状态
|
||||
}
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_by?: string; // 创建者
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
doc_num?: number; // 文档数量(数据集数理)
|
||||
chunk_num?: number; // 总数据量
|
||||
parser_id?: string; // 解析器ID
|
||||
parser_config?: ParserConfig; // 解析器配置
|
||||
embedding_id?: string;
|
||||
llm_id?: string;
|
||||
image2text_id?: string;
|
||||
reranker_id?: string;
|
||||
permission_id?: string;
|
||||
type: string;
|
||||
status?: number; // 状态 1 启用 0 禁用
|
||||
descriptionItems?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface RecallTestMetadata {
|
||||
doc_id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
file_created_at: string | number;
|
||||
document_id: string;
|
||||
knowledge_id: string;
|
||||
sort_id: number;
|
||||
score: number | null;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface RecallTestData {
|
||||
page_content: string;
|
||||
vector: null | number[];
|
||||
metadata: RecallTestMetadata;
|
||||
children: null | RecallTestData[];
|
||||
}
|
||||
|
||||
export interface RecallTestParams {
|
||||
query?: string; // 查询问题
|
||||
kb_ids?: string[]; // 知识库ID
|
||||
similarity_threshold?: number; // 相似度阈值
|
||||
vector_similarity_weight?: number; //语义相似度权重
|
||||
top_k?: number;
|
||||
hybrid?: boolean; // 是否混合检索
|
||||
hybrid_weight?: string;
|
||||
}
|
||||
// 文件夹
|
||||
export interface FolderFormData {
|
||||
id?: string; // 文件夹ID 新建时为空
|
||||
kb_id: string; // 知识库ID
|
||||
parent_id: string; // 父ID 最顶层=知识库id
|
||||
folder_name?: string; // 文件夹名称
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
// description: string; // 描述
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
export interface FileMeta {
|
||||
tag: string; // 标签
|
||||
}
|
||||
export interface ParserConfig {
|
||||
layout_recognize?: string; // 布局识别
|
||||
chunk_token_num?: number; // 分块token数量
|
||||
delimiter?: string; // 分隔符
|
||||
auto_keywords?: number; // 自动关键词
|
||||
auto_questions?: number; // 自动问题
|
||||
html4excel?: boolean; // 是否为Excel文件
|
||||
}
|
||||
// 文件数据
|
||||
export interface KnowledgeBaseDocumentData { // 知识库文档数据
|
||||
id?: string; // 文件ID 新建时为空
|
||||
file_id?: string; // 文件ID
|
||||
kb_id?: string; // 知识库ID
|
||||
parent_id?: string; // 文件夹ID
|
||||
file_name?: string; // 文件名称
|
||||
file_ext?: string; // 文件扩展名
|
||||
file_size?: number; // 文件大小
|
||||
file_meta?: FileMeta; // 文件元数据
|
||||
parser_id?: string; // 解析器ID
|
||||
parser_config?: ParserConfig; // 解析器配置
|
||||
chunk_num?: number; // 分块数量
|
||||
progress?: number; // 进度 1 完成
|
||||
progress_msg?: string; // 进度消息
|
||||
process_begin_at?: string; // 处理开始时间
|
||||
process_duration?: number; // 处理持续时间
|
||||
run?: number; // 运行次数
|
||||
status?: number; // 状态 1 可检索 0 不可检索
|
||||
created_at?: string; // 创建时间
|
||||
updated_at?: string; // 更新时间
|
||||
}
|
||||
export interface DocumentModalRef {
|
||||
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;
|
||||
}
|
||||
export interface DocumentModalRefProps {
|
||||
refreshTable?: () => void;
|
||||
}
|
||||
export interface KnowledgeBaseFormRef {
|
||||
handleOpen: (knowledgeBase?: KnowledgeBase | null) => void;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseModalRef {
|
||||
handleOpen: (knowledgeBase?: KnowledgeBase | null) => void;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseModalProps {
|
||||
refreshTable?: () => void;
|
||||
}
|
||||
// 定义组件暴露的方法接口
|
||||
export interface CreateModalRef {
|
||||
handleOpen: (knowledgeBaseListItem?: KnowledgeBaseListItem | null, type?: string) => void;
|
||||
}
|
||||
export interface CreateModalRefProps {
|
||||
refreshTable?: () => void;
|
||||
}
|
||||
//
|
||||
export interface RecallTestDrawerRef {
|
||||
handleOpen: (knowledgeBaseId?: string) => void;
|
||||
}
|
||||
|
||||
export interface CreateFolderModalRef {
|
||||
handleOpen: (folder?: FolderFormData | null,type?:string) => void;
|
||||
}
|
||||
|
||||
export interface CreateFolderModalRefProps{
|
||||
refreshTable?: () => void;
|
||||
}
|
||||
|
||||
//他建图片数据集
|
||||
export interface CreateImageModalRef{
|
||||
handleOpen: (kb_id:string,parent_id:string) => void;
|
||||
}
|
||||
export interface CreateImageMoealRefProps{
|
||||
refreshTable?: () => void;
|
||||
}
|
||||
|
||||
// 分享
|
||||
export interface ShareModalRef {
|
||||
handleOpen: (kb_id?: string,knowledgeBase?: KnowledgeBase | null) => void;
|
||||
}
|
||||
|
||||
export interface ShareModalRefProps {
|
||||
handleShare?: (selectedData: { checkedItems: any[], selectedItem: any | null }) => void;
|
||||
}
|
||||
|
||||
// 创建数据集
|
||||
export interface CreateDatasetModalRef {
|
||||
handleOpen: (kb_id?: string,parent_id?: string) => void;
|
||||
}
|
||||
|
||||
export interface CreateDatasetModalRefProps {
|
||||
handleCreateDataset?: (payload: { value: number; title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
// ========== API 相关类型 ==========
|
||||
// 分页请求信息
|
||||
export interface PageRequest {
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
// 分页信息
|
||||
export interface PageInfo {
|
||||
page_num?: number;
|
||||
page_size?: number;
|
||||
total?: number;
|
||||
has_next?: boolean;
|
||||
}
|
||||
|
||||
// 列表查询参数
|
||||
export interface ListQuery {
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
orderby?: string;
|
||||
desc?: boolean;
|
||||
keywords?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// API Key 信息
|
||||
export interface ModelAPIKey {
|
||||
model_name: string;
|
||||
provider: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
config: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
priority: string;
|
||||
id: string;
|
||||
model_config_id: string;
|
||||
usage_count: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 模型信息
|
||||
export interface Model {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
config: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
is_public: boolean;
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
api_keys: ModelAPIKey[];
|
||||
}
|
||||
|
||||
// 创建用户信息
|
||||
export interface CreatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 知识库列表项(包含嵌套对象)
|
||||
export interface KnowledgeBaseListItem extends KnowledgeBase {
|
||||
workspace_id: string;
|
||||
parent_id: string;
|
||||
avatar?: string;
|
||||
reranker_id?: string;
|
||||
created_user: CreatedUser;
|
||||
embedding?: Model;
|
||||
reranker?: Model;
|
||||
llm?: Model;
|
||||
image2text?: Model;
|
||||
}
|
||||
|
||||
// 知识库列表响应
|
||||
export interface KnowledgeBaseListResponse {
|
||||
items: KnowledgeBaseListItem[];
|
||||
page: PageInfo;
|
||||
}
|
||||
|
||||
// 目标空间(分享的目标工作空间)
|
||||
export interface ShareSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 分享用户信息
|
||||
export interface SharedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
export interface ShareRequestParams {
|
||||
source_kb_id?: string;
|
||||
source_workspace_id?: string;
|
||||
target_workspace_id?: string;
|
||||
}
|
||||
// 知识库分享记录
|
||||
export interface KnowledgeBaseShare {
|
||||
id: string;
|
||||
source_kb_id: string;
|
||||
source_workspace_id: string;
|
||||
target_kb_id: string;
|
||||
target_workspace_id: string;
|
||||
shared_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
target_kb: KnowledgeBase;
|
||||
target_workspace: ShareSpace;
|
||||
shared_user: SharedUser;
|
||||
}
|
||||
|
||||
// 知识库分享列表响应
|
||||
export interface KnowledgeBaseShareListResponse {
|
||||
list: KnowledgeBaseShare[];
|
||||
page: PageInfo;
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
export interface UploadFileFormData {
|
||||
kb_id?: string;
|
||||
parent_id?: string;
|
||||
file: File;
|
||||
}
|
||||
export interface UploadFileResponse extends UploadFileFormData{
|
||||
id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
file_ext: string;
|
||||
file_meta: FileMeta;
|
||||
parser_id: string; // 解析器ID
|
||||
parser_config: ParserConfig; // 解析器配置
|
||||
chunk_num: number; // 分块数量
|
||||
progress: number; // 进度 1 完成
|
||||
progress_msg: string; // 进度消息
|
||||
process_begin_at: string; // 处理开始时间
|
||||
process_duration: number; // 处理持续时间
|
||||
run: number; // 运行次数
|
||||
status: number; // 状态 1 可检索 0 不可检索
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface FileMeta {
|
||||
tag: string; // 标签
|
||||
}
|
||||
|
||||
export interface PathQuery extends ListQuery {
|
||||
kb_id?: string;
|
||||
parent_id?: string;
|
||||
workspace_id?: string;
|
||||
}
|
||||
|
||||
//
|
||||
export interface SpaceItem {
|
||||
id: string; // 空间ID
|
||||
name: string; // 空间名称
|
||||
icon?: string | null; // 空间图标
|
||||
iconType?: string | null; // 空间图标类型
|
||||
tenant_id: string; // 租户ID
|
||||
description?: string | null; // 描述
|
||||
created_at?: number; // 创建时间(时间戳)
|
||||
updated_at?: string; // 更新时间
|
||||
is_active: boolean; // 是否启用
|
||||
}
|
||||
|
||||
// 分享空item
|
||||
export interface ShareSpaceItem{
|
||||
|
||||
}
|
||||
// 分享 to 空间
|
||||
export interface ShareSpaceModalRef{
|
||||
handleOpen: (kb_id?: string,knowledgeBase?: KnowledgeBase | null, spaceIds?:string) => void;
|
||||
}
|
||||
|
||||
export interface ShareSpaceModalRefProps {
|
||||
handleShare?: () => void;
|
||||
}
|
||||
Reference in New Issue
Block a user