Merge branch 'PingChuan' into shiy-login
# Conflicts: # app/config/api-config.ts
This commit is contained in:
@@ -1,16 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, message, Spin } from 'antd';
|
||||
import { Form, Input, Button, Card, Spin } from 'antd';
|
||||
import { SaveOutlined } from '@ant-design/icons';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import { updateDatasetName } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { useDatasetSettings } from '~/hooks/dify-dataset-manager/dataset-settings';
|
||||
import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface DatasetSettingsProps {
|
||||
dataset: Dataset | null;
|
||||
onDatasetUpdated: (dataset: Dataset) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库设置组件
|
||||
* 用于修改知识库名称和描述
|
||||
@@ -20,70 +14,14 @@ export default function DatasetSettings({
|
||||
onDatasetUpdated,
|
||||
}: DatasetSettingsProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (dataset) {
|
||||
form.setFieldsValue({
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [dataset, form]);
|
||||
|
||||
/**
|
||||
* 处理表单值变化
|
||||
*/
|
||||
const handleValuesChange = () => {
|
||||
const values = form.getFieldsValue();
|
||||
const changed =
|
||||
values.name !== dataset?.name ||
|
||||
values.description !== (dataset?.description || '');
|
||||
setHasChanges(changed);
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
if (!dataset) {
|
||||
message.error('知识库不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// 目前只支持修改名称
|
||||
const updatedDataset = await updateDatasetName(dataset.id, values.name);
|
||||
|
||||
message.success('保存成功');
|
||||
onDatasetUpdated(updatedDataset);
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
console.error('保存设置失败:', err);
|
||||
message.error(err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
const handleReset = () => {
|
||||
if (dataset) {
|
||||
form.setFieldsValue({
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
saving,
|
||||
hasChanges,
|
||||
handleValuesChange,
|
||||
handleSave,
|
||||
handleReset,
|
||||
} = useDatasetSettings(dataset, form, onDatasetUpdated);
|
||||
|
||||
if (!dataset) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
Card,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
@@ -17,45 +15,8 @@ import {
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||
import type { Segment } from '~/api/dify-dataset/type';
|
||||
import { fetchSegments } from '~/api/dify-dataset/api/segmentApi';
|
||||
import { updateDocumentWithSettings } from '~/api/dify-dataset/api/documentApi';
|
||||
|
||||
interface DocumentDetailProps {
|
||||
datasetId: string;
|
||||
document: Document | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分段设置配置
|
||||
* 注意:Dify API 支持的参数有限
|
||||
* - separator: ✅ 支持
|
||||
* - maxTokens: ✅ 支持
|
||||
* - removeExtraSpaces: ✅ 支持
|
||||
* - removeUrlsEmails: ✅ 支持
|
||||
* - useQASegment: ⚠️ 需要 doc_form: "qa_model"
|
||||
*/
|
||||
interface SegmentationSettings {
|
||||
separator: string;
|
||||
maxTokens: number;
|
||||
removeExtraSpaces: boolean;
|
||||
removeUrlsEmails: boolean;
|
||||
useQASegment: boolean;
|
||||
qaLanguage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认分段设置
|
||||
*/
|
||||
const DEFAULT_SETTINGS: SegmentationSettings = {
|
||||
separator: '\\n\\n',
|
||||
maxTokens: 500,
|
||||
removeExtraSpaces: true,
|
||||
removeUrlsEmails: false,
|
||||
useQASegment: false,
|
||||
qaLanguage: 'Chinese',
|
||||
};
|
||||
import { useDocumentDetail } from '~/hooks/dify-dataset-manager/document-detail';
|
||||
import type { DocumentDetailProps } from '~/types/dify-dataset-manager/document-detail';
|
||||
|
||||
/**
|
||||
* 文档详情组件
|
||||
@@ -65,98 +26,17 @@ export default function DocumentDetail({
|
||||
datasetId,
|
||||
document,
|
||||
}: DocumentDetailProps) {
|
||||
// 分段设置状态
|
||||
const [settings, setSettings] = useState<SegmentationSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// 预览状态
|
||||
const [previewSegments, setPreviewSegments] = useState<Segment[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// 保存状态
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 当文档变化时重置设置
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
// 可以从文档中读取已有的设置,这里使用默认值
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setPreviewSegments([]);
|
||||
setShowPreview(false);
|
||||
}
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* 更新设置
|
||||
*/
|
||||
const updateSettings = (key: keyof SegmentationSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置设置
|
||||
*/
|
||||
const handleReset = () => {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setPreviewSegments([]);
|
||||
setShowPreview(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 预览分段
|
||||
*/
|
||||
const handlePreview = async () => {
|
||||
if (!document) return;
|
||||
|
||||
setPreviewLoading(true);
|
||||
setShowPreview(true);
|
||||
try {
|
||||
// 获取当前文档的分段作为预览
|
||||
const response = await fetchSegments(datasetId, document.id, 1, 50);
|
||||
setPreviewSegments(response.data || []);
|
||||
if (response.data?.length === 0) {
|
||||
message.info('该文档暂无分段数据');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('预览分段失败:', err);
|
||||
message.error(err.message || '预览失败');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存并处理
|
||||
*/
|
||||
const handleSaveAndProcess = async () => {
|
||||
if (!document) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDocumentWithSettings(datasetId, document.id, {
|
||||
indexing_technique: 'high_quality',
|
||||
process_rule: {
|
||||
mode: 'custom',
|
||||
rules: {
|
||||
pre_processing_rules: [
|
||||
{ id: 'remove_extra_spaces', enabled: settings.removeExtraSpaces },
|
||||
{ id: 'remove_urls_emails', enabled: settings.removeUrlsEmails },
|
||||
],
|
||||
segmentation: {
|
||||
separator: settings.separator.replace(/\\n/g, '\n'),
|
||||
max_tokens: settings.maxTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
message.success('设置已保存,文档正在重新处理...');
|
||||
} catch (err: any) {
|
||||
console.error('保存设置失败:', err);
|
||||
message.error(err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
settings,
|
||||
previewSegments,
|
||||
previewLoading,
|
||||
showPreview,
|
||||
saving,
|
||||
updateSettings,
|
||||
handleReset,
|
||||
handlePreview,
|
||||
handleSaveAndProcess,
|
||||
} = useDocumentDetail(datasetId, document);
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -8,10 +7,8 @@ import {
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Switch,
|
||||
message,
|
||||
Empty,
|
||||
Spin,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -20,32 +17,14 @@ import {
|
||||
FileTextOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||
import { deleteDocument, toggleDocumentStatus, uploadDocument } from '~/api/dify-dataset/api/documentApi';
|
||||
import { useDocumentList } from '~/hooks/dify-dataset-manager/document-list';
|
||||
import type { DocumentListProps } from '~/types/dify-dataset-manager/document-list';
|
||||
import DocumentUpload from './document-upload';
|
||||
import '../../styles/components/dify-dataset-manager/index.css';
|
||||
|
||||
interface DocumentListProps {
|
||||
datasetId: string;
|
||||
datasetName: string;
|
||||
documents: Document[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onDocumentDeleted: (documentId: string) => void;
|
||||
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onViewDocument?: (document: Document) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档列表组件
|
||||
*/
|
||||
@@ -62,114 +41,24 @@ export default function DocumentList({
|
||||
onRefresh,
|
||||
onViewDocument,
|
||||
}: DocumentListProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 获取状态标签配置
|
||||
*/
|
||||
const getStatusConfig = (status: IndexingStatus) => {
|
||||
const configs: Record<IndexingStatus, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
|
||||
indexing: { color: 'processing', icon: <SyncOutlined spin />, text: '索引中' },
|
||||
waiting: { color: 'warning', icon: <ClockCircleOutlined />, text: '等待中' },
|
||||
parsing: { color: 'processing', icon: <SyncOutlined spin />, text: '解析中' },
|
||||
cleaning: { color: 'processing', icon: <SyncOutlined spin />, text: '清洗中' },
|
||||
splitting: { color: 'processing', icon: <SyncOutlined spin />, text: '分段中' },
|
||||
paused: { color: 'default', icon: <PauseCircleOutlined />, text: '已暂停' },
|
||||
error: { color: 'error', icon: <ExclamationCircleOutlined />, text: '错误' },
|
||||
};
|
||||
return configs[status] || { color: 'default', icon: null, text: status };
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
*/
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除文档
|
||||
*/
|
||||
const handleDelete = async (documentId: string) => {
|
||||
setDeletingId(documentId);
|
||||
try {
|
||||
await deleteDocument(datasetId, documentId);
|
||||
message.success('删除成功');
|
||||
onDocumentDeleted(documentId);
|
||||
} catch (err: any) {
|
||||
console.error('删除文档失败:', err);
|
||||
message.error(err.message || '删除失败');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理启用/禁用文档
|
||||
*/
|
||||
const handleToggleStatus = async (documentId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleDocumentStatus(datasetId, documentId, enabled);
|
||||
message.success(enabled ? '已启用' : '已禁用');
|
||||
onDocumentStatusChanged(documentId, enabled);
|
||||
} catch (err: any) {
|
||||
console.error('切换文档状态失败:', err);
|
||||
message.error(err.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文件上传
|
||||
*/
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!datasetId) {
|
||||
message.error('请先选择知识库');
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadDocument(datasetId, file, (percent) => {
|
||||
console.log('上传进度:', percent);
|
||||
});
|
||||
message.success('上传成功,正在处理...');
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error('上传文件失败:', err);
|
||||
message.error(err.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
const {
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
deletingId,
|
||||
showUploadPage,
|
||||
getStatusConfig,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
handleDelete,
|
||||
handleToggleStatus,
|
||||
handleUploadClick,
|
||||
handleUploadClose,
|
||||
handleUploadSuccess,
|
||||
filterDocuments,
|
||||
} = useDocumentList(datasetId, onDocumentDeleted, onDocumentStatusChanged, onRefresh);
|
||||
|
||||
// 过滤文档
|
||||
const filteredDocuments = documents.filter((doc) =>
|
||||
doc.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
const filteredDocuments = filterDocuments(documents);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<Document> = [
|
||||
@@ -271,115 +160,114 @@ export default function DocumentList({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="document-list-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<div className="header-left">
|
||||
<h1>文档</h1>
|
||||
{/* <p className="page-description">
|
||||
知识库的所有文件都在这里显示,整个知识库都可以被接到 Dify 引用或通过 Chat 插件进行索引。
|
||||
</p> */}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
|
||||
disabled={!datasetId}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={uploading}
|
||||
disabled={!datasetId}
|
||||
>
|
||||
添加文件
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="document-search-bar">
|
||||
<Input
|
||||
placeholder="搜索文档..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
<>
|
||||
{/* 上传页面 */}
|
||||
{showUploadPage ? (
|
||||
<DocumentUpload
|
||||
datasetId={datasetId}
|
||||
onClose={handleUploadClose}
|
||||
onSuccess={handleUploadSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文档表格 */}
|
||||
<div className="document-table-wrapper">
|
||||
{loading && documents.length === 0 ? (
|
||||
<div className="loading-state">
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">加载中...</div>
|
||||
) : (
|
||||
<div className="document-list-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<div className="header-left">
|
||||
<h1>文档</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!datasetId}
|
||||
>
|
||||
添加文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Empty description={searchValue ? '未找到匹配的文档' : '暂无文档'}>
|
||||
{!searchValue && (
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="document-search-bar">
|
||||
<Input
|
||||
placeholder="搜索文档..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文档表格 */}
|
||||
<div className="document-table-wrapper">
|
||||
{loading && documents.length === 0 ? (
|
||||
<div className="loading-state">
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">加载中...</div>
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Empty description={searchValue ? '未找到匹配的文档' : '暂无文档'}>
|
||||
{!searchValue && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
上传第一个文档
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
className="document-table"
|
||||
columns={columns}
|
||||
dataSource={filteredDocuments}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部分页器 */}
|
||||
{filteredDocuments.length > 0 && (
|
||||
<div className="document-pagination">
|
||||
<span className="pagination-total">共 {total} 条</span>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
size="small"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<Button type="primary" icon={<CloudUploadOutlined />}>
|
||||
上传第一个文档
|
||||
</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
className="document-table"
|
||||
columns={columns}
|
||||
dataSource={filteredDocuments}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部分页器 */}
|
||||
{filteredDocuments.length > 0 && (
|
||||
<div className="document-pagination">
|
||||
<span className="pagination-total">共 {total} 条</span>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
size="small"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="pagination-info">
|
||||
第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="pagination-info">
|
||||
第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FileTextOutlined,
|
||||
InboxOutlined,
|
||||
LoadingOutlined,
|
||||
QuestionCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Progress,
|
||||
Select,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Segment } from '~/api/dify-dataset/type';
|
||||
import { useDocumentUpload } from '~/hooks/dify-dataset-manager/document-upload';
|
||||
import type { DocumentUploadProps, UploadedDocument } from '~/types/dify-dataset-manager/document-upload';
|
||||
import { SUPPORTED_FORMATS } from '~/types/dify-dataset-manager/document-upload';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
/**
|
||||
* 文档上传组件
|
||||
* 支持多文件上传,两步流程:选择文件 → 上传并配置分段
|
||||
*/
|
||||
export default function DocumentUpload({
|
||||
datasetId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: DocumentUploadProps) {
|
||||
const {
|
||||
// 状态
|
||||
step,
|
||||
fileList,
|
||||
uploadedDocuments,
|
||||
currentSettings,
|
||||
previewLoading,
|
||||
|
||||
// 方法
|
||||
handleFileChange,
|
||||
handleRemoveFile,
|
||||
handleNextStep,
|
||||
handleDocumentChange,
|
||||
handleReprocess,
|
||||
handlePrevStep,
|
||||
handleGoToDocuments,
|
||||
updateCurrentSettings,
|
||||
|
||||
// 计算属性方法
|
||||
getCurrentDocument,
|
||||
getCurrentProgress,
|
||||
getStatusText,
|
||||
isCurrentDocProcessing,
|
||||
getCompletionStats,
|
||||
} = useDocumentUpload(datasetId, onClose, onSuccess);
|
||||
|
||||
const selectedFiles = fileList.filter((f: UploadFile) => f.originFileObj).map((f: UploadFile) => f.originFileObj as File);
|
||||
|
||||
// 平滑进度条逻辑
|
||||
const [displayPercent, setDisplayPercent] = useState(0);
|
||||
const targetPercent = getCurrentProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (targetPercent > displayPercent) {
|
||||
// 如果目标进度大于当前显示进度,启动动画
|
||||
const diff = targetPercent - displayPercent;
|
||||
// 动态步长:差距越大跑得越快,但最小步长为1
|
||||
const step = Math.max(1, Math.ceil(diff / 10));
|
||||
|
||||
const timer = requestAnimationFrame(() => {
|
||||
setDisplayPercent(prev => Math.min(targetPercent, prev + step));
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(timer);
|
||||
} else if (targetPercent < displayPercent && targetPercent === 0) {
|
||||
// 如果目标重置为0(例如重新开始),立即重置
|
||||
setDisplayPercent(0);
|
||||
}
|
||||
}, [targetPercent, displayPercent]);
|
||||
|
||||
/**
|
||||
* 渲染步骤指示器(两步流程)
|
||||
*/
|
||||
const renderSteps = () => (
|
||||
<div className="upload-steps">
|
||||
<div className={`step-item ${step === 1 ? 'active' : ''} ${step > 1 ? 'completed' : ''}`}>
|
||||
<span className="step-number">1</span>
|
||||
<span className="step-title">选择数据源</span>
|
||||
</div>
|
||||
<div className={`step-divider ${step > 1 ? 'completed' : ''}`}></div>
|
||||
<div className={`step-item ${step === 2 ? 'active' : ''}`}>
|
||||
<span className="step-number">2</span>
|
||||
<span className="step-title">文本分段与清洗</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 渲染第一步:选择文件(支持多文件)
|
||||
*/
|
||||
const renderStep1 = () => (
|
||||
<div className="upload-step-content step1">
|
||||
<h2 className="step-heading">上传文本文件</h2>
|
||||
<p className="step-description">
|
||||
文档需上传至知识智能理解法治知识库,广东烟草智能理解将按照于知识库,你可以在聊后指数文档所据案中检索它
|
||||
</p>
|
||||
|
||||
<div className="file-drop-zone">
|
||||
<Dragger
|
||||
fileList={fileList}
|
||||
onChange={handleFileChange}
|
||||
beforeUpload={() => false}
|
||||
multiple={true}
|
||||
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.csv,.vtt,.properties"
|
||||
showUploadList={false}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">拖拽文件或至此,或者 <span className="upload-link">选择文件</span></p>
|
||||
<p className="ant-upload-hint">
|
||||
已支持 {SUPPORTED_FORMATS},每个文件不超过 15MB。支持批量上传多个文件。
|
||||
</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
|
||||
{/* 已选文件列表 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="selected-files-section">
|
||||
<h3 className="section-subtitle">嵌入已就绪 ({selectedFiles.length} 个文件)</h3>
|
||||
<div className="selected-files-list">
|
||||
{fileList.map((file: UploadFile) => (
|
||||
<div key={file.uid} className="selected-file-item">
|
||||
<FileTextOutlined className="file-icon" />
|
||||
<div className="file-info">
|
||||
<span className="file-name">{file.name}</span>
|
||||
<span className="file-size">
|
||||
{file.originFileObj
|
||||
? `${file.originFileObj.type?.split('/')[1]?.toUpperCase() || 'FILE'},${(file.originFileObj.size / 1024 / 1024).toFixed(2)}MB`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveFile(file)}
|
||||
className="remove-file-btn"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="step-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNextStep}
|
||||
disabled={selectedFiles.length === 0}
|
||||
className="next-btn"
|
||||
>
|
||||
下一步 →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 渲染第二步:分段配置与预览
|
||||
* 左侧始终显示配置面板,右侧预览框内显示进度或分段内容
|
||||
*/
|
||||
const renderStep2 = () => {
|
||||
const currentDoc = getCurrentDocument();
|
||||
const isProcessing = isCurrentDocProcessing();
|
||||
const stats = getCompletionStats();
|
||||
|
||||
return (
|
||||
<div className="upload-step-content step2">
|
||||
{/* 分段配置与预览 */}
|
||||
<div className="document-detail-content">
|
||||
{/* 左侧设置区域 */}
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">分段设置</h3>
|
||||
|
||||
{/* 分段标识符 */}
|
||||
<div className="setting-item">
|
||||
<label className="setting-label">
|
||||
分段标识符
|
||||
<Tooltip title="系统会在遇到指定分隔符时自动分段,默认值为 \n\n(按段落分段)">
|
||||
<QuestionCircleOutlined className="help-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Input
|
||||
value={currentSettings.separator}
|
||||
onChange={(e) => updateCurrentSettings('separator', e.target.value)}
|
||||
placeholder="\n\n"
|
||||
className="setting-input"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分段最大长度 */}
|
||||
<div className="setting-item">
|
||||
<label className="setting-label">
|
||||
分段最大长度
|
||||
<Tooltip title="指定每个分段允许的最大字符数(100-4000),超过此限制系统会强制分段">
|
||||
<QuestionCircleOutlined className="help-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="setting-input-with-suffix">
|
||||
<InputNumber
|
||||
value={currentSettings.maxTokens}
|
||||
onChange={(value) => updateCurrentSettings('maxTokens', value || 1024)}
|
||||
min={100}
|
||||
max={4000}
|
||||
className="setting-input-number"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<span className="input-suffix">characters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分段重叠长度 */}
|
||||
<div className="setting-item">
|
||||
<label className="setting-label">
|
||||
分段重叠长度
|
||||
<Tooltip title="相邻分段之间重叠的字符数,有助于保持上下文连贯性">
|
||||
<QuestionCircleOutlined className="help-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="setting-input-with-suffix">
|
||||
<InputNumber
|
||||
value={currentSettings.chunkOverlap}
|
||||
onChange={(value) => updateCurrentSettings('chunkOverlap', value || 50)}
|
||||
min={0}
|
||||
max={500}
|
||||
className="setting-input-number"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<span className="input-suffix">characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 文本预处理规则 */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">文本预处理规则</h3>
|
||||
<div className="checkbox-group">
|
||||
<Checkbox
|
||||
checked={currentSettings.removeExtraSpaces}
|
||||
onChange={(e) => updateCurrentSettings('removeExtraSpaces', e.target.checked)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
替换掉连续的空格、换行符和制表符
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={currentSettings.removeUrlsEmails}
|
||||
onChange={(e) => updateCurrentSettings('removeUrlsEmails', e.target.checked)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
删除所有 URL 和电子邮件地址
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 索引方式 */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">索引方式</h3>
|
||||
<div className="index-options">
|
||||
<div
|
||||
className={`index-option ${currentSettings.indexingTechnique === 'high_quality' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`}
|
||||
onClick={() => !isProcessing && updateCurrentSettings('indexingTechnique', 'high_quality')}
|
||||
>
|
||||
<span className="option-radio"></span>
|
||||
<span className="option-label">高质量</span>
|
||||
<span className="option-badge recommended">推荐</span>
|
||||
</div>
|
||||
<div
|
||||
className={`index-option ${currentSettings.indexingTechnique === 'economy' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`}
|
||||
onClick={() => !isProcessing && updateCurrentSettings('indexingTechnique', 'economy')}
|
||||
>
|
||||
<span className="option-radio"></span>
|
||||
<span className="option-label">经济</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="settings-actions">
|
||||
<Button onClick={handlePrevStep} disabled={isProcessing}>
|
||||
<ArrowLeftOutlined /> 上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleReprocess}
|
||||
loading={isProcessing}
|
||||
disabled={isProcessing || !currentDoc?.documentId}
|
||||
>
|
||||
更新嵌入配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧预览区域 */}
|
||||
<div className="preview-panel">
|
||||
<Card
|
||||
title={
|
||||
<div className="preview-header">
|
||||
<span>预览</span>
|
||||
{uploadedDocuments.length > 0 && (
|
||||
<>
|
||||
<Select
|
||||
value={currentDoc?.documentId || currentDoc?.file.name}
|
||||
style={{ width: 500 }}
|
||||
onChange={handleDocumentChange}
|
||||
options={uploadedDocuments.map((doc: UploadedDocument) => ({
|
||||
value: doc.documentId || doc.file.name,
|
||||
label: (
|
||||
<span className="file-select-option">
|
||||
{doc.stage === 'completed' && <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />}
|
||||
{(doc.stage === 'uploading' || doc.stage === 'indexing') && <LoadingOutlined style={{ color: '#00684a', marginRight: 4 }} />}
|
||||
{doc.stage === 'error' && <ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />}
|
||||
{doc.file.name}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
{!isProcessing && currentDoc?.segments && (
|
||||
<span className="segment-count">
|
||||
{currentDoc.segments.length} 段块
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="preview-card"
|
||||
>
|
||||
{/* 处理进度(在预览框内显示) */}
|
||||
{isProcessing ? (
|
||||
<div className="preview-processing">
|
||||
<div className="processing-file">
|
||||
<FileTextOutlined className="file-icon" />
|
||||
<span className="file-name">{currentDoc?.file.name}</span>
|
||||
<LoadingOutlined className="status-icon loading" />
|
||||
</div>
|
||||
<Progress
|
||||
percent={displayPercent}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#00684a',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
/>
|
||||
<div className="status-text">{getStatusText()}</div>
|
||||
</div>
|
||||
) : currentDoc?.stage === 'error' ? (
|
||||
<div className="preview-error">
|
||||
<ExclamationCircleOutlined className="error-icon" />
|
||||
<div className="error-text">{currentDoc.error || '处理失败'}</div>
|
||||
</div>
|
||||
) : previewLoading ? (
|
||||
<div className="preview-loading">
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">加载中...</div>
|
||||
</div>
|
||||
) : (currentDoc?.segments?.length ?? 0) === 0 ? (
|
||||
<div className="preview-empty">
|
||||
<Empty description="等待处理完成后显示分段预览" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="preview-segments">
|
||||
{currentDoc?.segments.map((segment: Segment, index: number) => (
|
||||
<div key={segment.id} className="segment-item">
|
||||
<div className="segment-header">
|
||||
<span className="segment-index">#{index + 1}</span>
|
||||
<span className="segment-chars">
|
||||
{segment.word_count} 字符
|
||||
</span>
|
||||
</div>
|
||||
<div className="segment-content">
|
||||
{segment.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成状态底部操作 */}
|
||||
{stats.completed > 0 && (
|
||||
<div className="completion-actions">
|
||||
<span className="completion-stats">
|
||||
{stats.completed}/{stats.total} 个文档处理完成
|
||||
</span>
|
||||
<Button type="primary" onClick={handleGoToDocuments}>
|
||||
前往知识库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="document-upload-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="upload-header">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onClose}
|
||||
className="back-btn"
|
||||
>
|
||||
知识库
|
||||
</Button>
|
||||
{renderSteps()}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="upload-content">
|
||||
{step === 1 && renderStep1()}
|
||||
{step === 2 && renderStep2()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { message, Spin } from 'antd';
|
||||
import DatasetLayout, { type MenuTab } from './layout';
|
||||
import { Spin } from 'antd';
|
||||
import DatasetLayout from './layout';
|
||||
import DocumentList from './document-list';
|
||||
import DocumentDetail from './document-detail';
|
||||
import RetrieveTest from './retrieve-test';
|
||||
import DatasetSettings from './dataset-settings';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||
import { fetchDatasets } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { fetchDocuments } from '~/api/dify-dataset/api/documentApi';
|
||||
import { useDatasetManager } from '~/hooks/dify-dataset-manager';
|
||||
import '../../styles/components/dify-dataset-manager/index.css';
|
||||
|
||||
/**
|
||||
@@ -16,162 +12,30 @@ import '../../styles/components/dify-dataset-manager/index.css';
|
||||
* 带左侧菜单栏的完整布局
|
||||
*/
|
||||
export default function DatasetManager() {
|
||||
// 知识库状态
|
||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
||||
const [loadingDataset, setLoadingDataset] = useState(true);
|
||||
|
||||
// 文档状态
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||||
const [documentTotal, setDocumentTotal] = useState(0);
|
||||
const [documentPage, setDocumentPage] = useState(1);
|
||||
const [documentPageSize] = useState(20);
|
||||
|
||||
// 初始化状态
|
||||
const [inited, setInited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 菜单状态
|
||||
const [activeTab, setActiveTab] = useState<MenuTab>('documents');
|
||||
|
||||
// 选中的文档(用于查看文档详情)
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
|
||||
/**
|
||||
* 加载知识库(获取第一个知识库)
|
||||
*/
|
||||
const loadDataset = async () => {
|
||||
setLoadingDataset(true);
|
||||
try {
|
||||
console.log('[DatasetManager] 加载知识库...');
|
||||
const response = await fetchDatasets(1, 1);
|
||||
console.log('[DatasetManager] 知识库响应:', response);
|
||||
|
||||
if (response && response.data && response.data.length > 0) {
|
||||
const firstDataset = response.data[0];
|
||||
setDataset(firstDataset);
|
||||
// 立即加载文档
|
||||
await loadDocuments(firstDataset.id, 1);
|
||||
} else {
|
||||
setError('未找到知识库,请先在Dify中创建知识库');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[DatasetManager] 加载知识库失败:', err);
|
||||
setError(err.message || '加载知识库失败');
|
||||
message.error('加载知识库失败');
|
||||
} finally {
|
||||
setLoadingDataset(false);
|
||||
setInited(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载文档列表
|
||||
*/
|
||||
const loadDocuments = async (datasetId: string, page: number = 1) => {
|
||||
if (!datasetId) return;
|
||||
|
||||
setLoadingDocuments(true);
|
||||
try {
|
||||
console.log('[DatasetManager] 加载文档列表:', { datasetId, page });
|
||||
const response = await fetchDocuments(datasetId, page, documentPageSize);
|
||||
console.log('[DatasetManager] 文档列表响应:', response);
|
||||
|
||||
if (response && response.data) {
|
||||
setDocuments(response.data);
|
||||
setDocumentTotal(response.total);
|
||||
setDocumentPage(page);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[DatasetManager] 加载文档列表失败:', err);
|
||||
message.error('加载文档列表失败');
|
||||
} finally {
|
||||
setLoadingDocuments(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文档页码变化
|
||||
*/
|
||||
const handlePageChange = (page: number) => {
|
||||
if (dataset) {
|
||||
loadDocuments(dataset.id, page);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文档删除
|
||||
*/
|
||||
const handleDocumentDeleted = (documentId: string) => {
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== documentId));
|
||||
setDocumentTotal((prev) => prev - 1);
|
||||
|
||||
// 更新知识库的文档数量
|
||||
if (dataset) {
|
||||
setDataset({
|
||||
...dataset,
|
||||
document_count: dataset.document_count - 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文档状态变化
|
||||
*/
|
||||
const handleDocumentStatusChanged = (documentId: string, enabled: boolean) => {
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.id === documentId ? { ...doc, enabled } : doc
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新文档列表
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
if (dataset) {
|
||||
loadDocuments(dataset.id, documentPage);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 查看文档详情(分段管理)
|
||||
*/
|
||||
const handleViewDocument = (doc: Document) => {
|
||||
console.log('[DatasetManager] 查看文档详情:', doc);
|
||||
setSelectedDocument(doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回文档列表
|
||||
*/
|
||||
const handleBackToDocuments = () => {
|
||||
setSelectedDocument(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理菜单切换
|
||||
*/
|
||||
const handleTabChange = (tab: MenuTab) => {
|
||||
setActiveTab(tab);
|
||||
// 切换菜单时清除选中的文档
|
||||
if (tab !== 'documents') {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理知识库更新
|
||||
*/
|
||||
const handleDatasetUpdated = (updatedDataset: Dataset) => {
|
||||
setDataset(updatedDataset);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
loadDataset();
|
||||
}, []);
|
||||
const {
|
||||
// 状态
|
||||
dataset,
|
||||
loadingDataset,
|
||||
documents,
|
||||
loadingDocuments,
|
||||
documentTotal,
|
||||
documentPage,
|
||||
documentPageSize,
|
||||
inited,
|
||||
error,
|
||||
activeTab,
|
||||
selectedDocument,
|
||||
|
||||
// 方法
|
||||
handlePageChange,
|
||||
handleDocumentDeleted,
|
||||
handleDocumentStatusChanged,
|
||||
handleRefresh,
|
||||
handleViewDocument,
|
||||
handleBackToDocuments,
|
||||
handleTabChange,
|
||||
handleDatasetUpdated,
|
||||
} = useDatasetManager();
|
||||
|
||||
// 加载中状态
|
||||
if (!inited || loadingDataset) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
@@ -7,27 +6,7 @@ import {
|
||||
ArrowLeftOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
|
||||
/**
|
||||
* 菜单项类型
|
||||
*/
|
||||
export type MenuTab = 'documents' | 'retrieve' | 'settings';
|
||||
|
||||
interface DatasetLayoutProps {
|
||||
/** 知识库信息 */
|
||||
dataset: Dataset | null;
|
||||
/** 当前激活的菜单 */
|
||||
activeTab: MenuTab;
|
||||
/** 菜单切换回调 */
|
||||
onTabChange: (tab: MenuTab) => void;
|
||||
/** 是否显示返回按钮(在文档详情页时显示) */
|
||||
showBackButton?: boolean;
|
||||
/** 返回按钮点击回调 */
|
||||
onBack?: () => void;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout';
|
||||
|
||||
/**
|
||||
* 知识库布局组件
|
||||
@@ -41,7 +20,7 @@ export default function DatasetLayout({
|
||||
onBack,
|
||||
children,
|
||||
}: DatasetLayoutProps) {
|
||||
const menuItems: { key: MenuTab; icon: ReactNode; label: string }[] = [
|
||||
const menuItems: MenuItem[] = [
|
||||
{ key: 'documents', icon: <FileTextOutlined />, label: '文档' },
|
||||
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' },
|
||||
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
|
||||
@@ -51,20 +30,6 @@ export default function DatasetLayout({
|
||||
<div className="dataset-layout">
|
||||
{/* 左侧侧边栏 */}
|
||||
<aside className="dataset-sidebar">
|
||||
{/* 返回按钮 */}
|
||||
{showBackButton && onBack && (
|
||||
<div className="sidebar-back">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="back-btn"
|
||||
>
|
||||
返回文档列表
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库信息 */}
|
||||
<div className="sidebar-header">
|
||||
<div className="dataset-icon">
|
||||
@@ -102,8 +67,24 @@ export default function DatasetLayout({
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<main className="dataset-main">
|
||||
{/* 返回按钮 */}
|
||||
{showBackButton && onBack && (
|
||||
<div className="sidebar-back">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="back-btn"
|
||||
>
|
||||
返回文档列表
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 重新导出类型,保持向后兼容
|
||||
export type { MenuTab } from '~/types/dify-dataset-manager/layout';
|
||||
|
||||
@@ -1,202 +1,306 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Select,
|
||||
Slider,
|
||||
Table,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FileSearchOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { SearchOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Tag, Input, Slider, Spin, Select, Flex } from 'antd';
|
||||
import type { RetrieveRecord } from '~/api/dify-dataset/type';
|
||||
import { retrieveDataset } from '~/api/dify-dataset/api/segmentApi';
|
||||
import { useRetrieveTest } from '~/hooks/dify-dataset-manager/retrieve-test';
|
||||
import type { RetrieveTestProps } from '~/types/dify-dataset-manager/retrieve-test';
|
||||
|
||||
interface RetrieveTestProps {
|
||||
datasetId: string;
|
||||
// 颜色常量
|
||||
const colors = {
|
||||
bgContainer: '#fff',
|
||||
bgLayout: '#f5f5f5',
|
||||
bgElevated: '#fafafa',
|
||||
border: '#e8e8e8',
|
||||
text: '#262626',
|
||||
textSecondary: '#8c8c8c',
|
||||
textTertiary: '#bfbfbf',
|
||||
textQuaternary: '#d9d9d9',
|
||||
fillTertiary: '#f0f0f0',
|
||||
};
|
||||
|
||||
/**
|
||||
* 检索结果项组件
|
||||
*/
|
||||
function ResultItem({ record, index }: { record: RetrieveRecord; index: number }) {
|
||||
const scorePercent = (record.score * 100).toFixed(1);
|
||||
const scoreColor = record.score > 0.8 ? '#52c41a' : record.score > 0.5 ? '#faad14' : '#666';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
padding: 16,
|
||||
background: colors.bgContainer,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex gap={8} align="center">
|
||||
<Tag style={{ background: scoreColor, color: '#fff', border: 'none' }}>
|
||||
{scorePercent}%
|
||||
</Tag>
|
||||
<span style={{ color: colors.textSecondary, fontSize: 12 }}>
|
||||
#{index + 1} · {record.segment.word_count} 字 · 命中 {record.segment.hit_count} 次
|
||||
</span>
|
||||
</Flex>
|
||||
{record.segment.document && (
|
||||
<span style={{ color: colors.textTertiary, fontSize: 12 }}>
|
||||
来源: {record.segment.document.name}
|
||||
</span>
|
||||
)}
|
||||
</Flex>
|
||||
<div style={{
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{record.segment.content.length > 500
|
||||
? record.segment.content.substring(0, 500) + '...'
|
||||
: record.segment.content}
|
||||
</div>
|
||||
{record.segment.answer && (
|
||||
<Flex
|
||||
vertical
|
||||
gap={4}
|
||||
style={{
|
||||
padding: 12,
|
||||
background: colors.fillTertiary,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: colors.textSecondary, fontSize: 12 }}>
|
||||
答案:
|
||||
</span>
|
||||
<span style={{ color: colors.text, fontSize: 14 }}>
|
||||
{record.segment.answer.length > 200
|
||||
? record.segment.answer.substring(0, 200) + '...'
|
||||
: record.segment.answer}
|
||||
</span>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 召回测试组件
|
||||
* 用于测试知识库的检索效果
|
||||
*/
|
||||
export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [retrieveResults, setRetrieveResults] = useState<RetrieveRecord[]>([]);
|
||||
const [retrieving, setRetrieving] = useState(false);
|
||||
const [searchMethod, setSearchMethod] = useState<string>('hybrid_search');
|
||||
const [topK, setTopK] = useState<number>(5);
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
retrieveResults,
|
||||
retrieving,
|
||||
searchMethod,
|
||||
setSearchMethod,
|
||||
topK,
|
||||
setTopK,
|
||||
handleRetrieve,
|
||||
} = useRetrieveTest(datasetId);
|
||||
|
||||
/**
|
||||
* 执行检索
|
||||
*/
|
||||
const handleRetrieve = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
message.warning('请输入检索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!datasetId) {
|
||||
message.warning('知识库ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
setRetrieving(true);
|
||||
try {
|
||||
const response = await retrieveDataset(datasetId, searchQuery, {
|
||||
search_method: searchMethod as any,
|
||||
top_k: topK,
|
||||
});
|
||||
setRetrieveResults(response.records || []);
|
||||
if (response.records?.length === 0) {
|
||||
message.info('未找到匹配的结果');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('检索失败:', err);
|
||||
message.error(err.message || '检索失败');
|
||||
} finally {
|
||||
setRetrieving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 检索结果列定义
|
||||
const columns: ColumnsType<RetrieveRecord> = [
|
||||
{
|
||||
title: '相关度',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 100,
|
||||
render: (score: number) => (
|
||||
<Tag color={score > 0.8 ? 'green' : score > 0.5 ? 'orange' : 'default'}>
|
||||
{(score * 100).toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'content',
|
||||
render: (_, record) => (
|
||||
<div className="retrieve-result-content">
|
||||
<div className="content-text">
|
||||
{record.segment.content.length > 300
|
||||
? record.segment.content.substring(0, 300) + '...'
|
||||
: record.segment.content}
|
||||
</div>
|
||||
{record.segment.answer && (
|
||||
<div className="answer-text">
|
||||
<strong>答案:</strong>
|
||||
{record.segment.answer.length > 150
|
||||
? record.segment.answer.substring(0, 150) + '...'
|
||||
: record.segment.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '字数',
|
||||
key: 'word_count',
|
||||
width: 80,
|
||||
render: (_, record) => record.segment.word_count,
|
||||
},
|
||||
{
|
||||
title: '命中次数',
|
||||
key: 'hit_count',
|
||||
width: 100,
|
||||
render: (_, record) => record.segment.hit_count,
|
||||
},
|
||||
// 检索方式选项(只有3种)
|
||||
const searchMethodOptions = [
|
||||
{ label: '向量检索', value: 'semantic_search' },
|
||||
{ label: '全文检索', value: 'full_text_search' },
|
||||
{ label: '混合检索', value: 'hybrid_search' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="retrieve-test-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header">
|
||||
<h1>召回测试</h1>
|
||||
<p className="page-description">
|
||||
输入查询内容,测试知识库的检索效果
|
||||
</p>
|
||||
</div>
|
||||
<Flex
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: 'calc(100vh - 120px)',
|
||||
}}
|
||||
>
|
||||
{/* 左侧面板 - 输入区域 */}
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
style={{
|
||||
width: 400,
|
||||
minWidth: 400,
|
||||
padding: 20,
|
||||
background: colors.bgLayout,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<Flex vertical gap={4}>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: colors.text,
|
||||
}}>
|
||||
召回测试
|
||||
</h2>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
}}>
|
||||
根据给定的查询文本测试知识库召回效果
|
||||
</span>
|
||||
</Flex>
|
||||
|
||||
{/* 检索设置 */}
|
||||
<Card className="retrieve-settings" size="small">
|
||||
<div className="search-row">
|
||||
<Input
|
||||
placeholder="输入检索关键词..."
|
||||
prefix={<FileSearchOutlined />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onPressEnter={handleRetrieve}
|
||||
className="search-input"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleRetrieve}
|
||||
loading={retrieving}
|
||||
>
|
||||
检索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="options-row">
|
||||
<div className="option-item">
|
||||
<span className="option-label">检索方式:</span>
|
||||
{/* 查询输入区 */}
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
源文本
|
||||
</span>
|
||||
<Select
|
||||
value={searchMethod}
|
||||
onChange={setSearchMethod}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'keyword_search', label: '关键词搜索' },
|
||||
{ value: 'semantic_search', label: '语义搜索' },
|
||||
{ value: 'full_text_search', label: '全文搜索' },
|
||||
{ value: 'hybrid_search', label: '混合搜索' },
|
||||
]}
|
||||
onChange={(value) => setSearchMethod(value as any)}
|
||||
options={searchMethodOptions}
|
||||
style={{ width: 130 }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="option-item">
|
||||
<span className="option-label">返回数量 (Top K):</span>
|
||||
</Flex>
|
||||
<Input.TextArea
|
||||
placeholder="请输入文本,建议使用简短的陈述句。"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleRetrieve();
|
||||
}
|
||||
}}
|
||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||
style={{
|
||||
background: colors.bgContainer,
|
||||
resize: 'none',
|
||||
}}
|
||||
/>
|
||||
<Flex justify="space-between" align="center">
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: colors.textTertiary,
|
||||
}}>
|
||||
{searchQuery.length} / 200
|
||||
</span>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleRetrieve}
|
||||
loading={retrieving}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 检索设置 */}
|
||||
<Flex vertical gap={12}>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
检索设置
|
||||
</span>
|
||||
<Flex align="center" gap={12}>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
返回数量 (Top K):
|
||||
</span>
|
||||
<Slider
|
||||
value={topK}
|
||||
onChange={setTopK}
|
||||
min={1}
|
||||
max={20}
|
||||
style={{ width: 120 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span className="option-value">{topK}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
minWidth: 24,
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{topK}
|
||||
</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 检索结果 */}
|
||||
<div className="retrieve-results">
|
||||
{/* 右侧面板 - 结果展示 */}
|
||||
<Flex
|
||||
vertical
|
||||
flex={1}
|
||||
gap={16}
|
||||
style={{
|
||||
padding: 20,
|
||||
background: colors.bgElevated,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{retrieving ? (
|
||||
<div className="loading-state">
|
||||
<Flex
|
||||
flex={1}
|
||||
align="center"
|
||||
justify="center"
|
||||
vertical
|
||||
gap={12}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">检索中...</div>
|
||||
</div>
|
||||
<span style={{ color: colors.textSecondary }}>
|
||||
检索中...
|
||||
</span>
|
||||
</Flex>
|
||||
) : retrieveResults.length === 0 ? (
|
||||
<Empty
|
||||
description="请输入关键词进行检索"
|
||||
className="empty-state"
|
||||
/>
|
||||
<Flex
|
||||
flex={1}
|
||||
align="center"
|
||||
justify="center"
|
||||
vertical
|
||||
gap={12}
|
||||
>
|
||||
<FileSearchOutlined style={{
|
||||
fontSize: 48,
|
||||
color: colors.textQuaternary,
|
||||
}} />
|
||||
<span style={{ color: colors.textTertiary }}>
|
||||
召回测试结果将展示在这里
|
||||
</span>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<div className="results-header">
|
||||
<span>找到 {retrieveResults.length} 条结果</span>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={retrieveResults}
|
||||
rowKey={(record) => record.segment.id}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
<Flex justify="space-between" align="center">
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
检索结果
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
}}>
|
||||
共找到 {retrieveResults.length} 条结果
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex vertical gap={12}>
|
||||
{retrieveResults.map((record, index) => (
|
||||
<ResultItem
|
||||
key={record.segment.id}
|
||||
record={record}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user