feat: Add base project structure with API and web components
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user