feat: Add base project structure with API and web components

This commit is contained in:
Ke Sun
2025-12-02 20:28:01 +08:00
parent f3de6d6cc9
commit c1adc62ec6
817 changed files with 111226 additions and 106 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};