feat: 完善Dify知识库管理召回测试模块,优化知识库上传文件时的分段配置设置
This commit is contained in:
@@ -332,7 +332,7 @@ export async function deleteChildChunk(
|
|||||||
*
|
*
|
||||||
* @param datasetId - 知识库 ID
|
* @param datasetId - 知识库 ID
|
||||||
* @param query - 检索关键词
|
* @param query - 检索关键词
|
||||||
* @param retrievalModel - 检索模型配置
|
* @param retrievalModel - 检索模型配置(完整的 Dify API 格式)
|
||||||
* @returns 检索结果
|
* @returns 检索结果
|
||||||
*/
|
*/
|
||||||
export async function retrieveDataset(
|
export async function retrieveDataset(
|
||||||
@@ -340,7 +340,7 @@ export async function retrieveDataset(
|
|||||||
query: string,
|
query: string,
|
||||||
retrievalModel?: RetrieveRequest['retrieval_model']
|
retrievalModel?: RetrieveRequest['retrieval_model']
|
||||||
): Promise<RetrieveResponse> {
|
): Promise<RetrieveResponse> {
|
||||||
console.log('[Dataset Client] 检索知识库:', { datasetId, query });
|
console.log('[Dataset Client] 检索知识库:', { datasetId, query, retrievalModel });
|
||||||
|
|
||||||
const requestBody: RetrieveRequest = {
|
const requestBody: RetrieveRequest = {
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type {
|
|||||||
MetadataFilterCondition,
|
MetadataFilterCondition,
|
||||||
MetadataFilteringConditions,
|
MetadataFilteringConditions,
|
||||||
RetrieveRequest,
|
RetrieveRequest,
|
||||||
|
RetrieveSegment,
|
||||||
RetrieveRecord,
|
RetrieveRecord,
|
||||||
RetrieveResponse,
|
RetrieveResponse,
|
||||||
} from './segmentTypes';
|
} from './segmentTypes';
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* @module api/dify-dataset/type/segmentTypes
|
* @module api/dify-dataset/type/segmentTypes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { RetrievalModel } from './datasetTypes';
|
||||||
|
|
||||||
|
// 重新导出以便其他模块使用
|
||||||
|
export type { RetrievalModel };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 分段类型
|
// 分段类型
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -154,25 +159,27 @@ export interface MetadataFilteringConditions {
|
|||||||
*/
|
*/
|
||||||
export interface RetrieveRequest {
|
export interface RetrieveRequest {
|
||||||
query: string;
|
query: string;
|
||||||
retrieval_model?: {
|
retrieval_model?: RetrievalModel;
|
||||||
search_method: 'keyword_search' | 'semantic_search' | 'full_text_search' | 'hybrid_search';
|
|
||||||
reranking_enable?: boolean;
|
|
||||||
reranking_model?: {
|
|
||||||
reranking_provider_name: string;
|
|
||||||
reranking_model_name: string;
|
|
||||||
};
|
|
||||||
top_k?: number;
|
|
||||||
score_threshold_enabled?: boolean;
|
|
||||||
score_threshold?: number;
|
|
||||||
};
|
|
||||||
metadata_filtering_conditions?: MetadataFilteringConditions;
|
metadata_filtering_conditions?: MetadataFilteringConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索结果中的分段信息(包含关联文档)
|
||||||
|
*/
|
||||||
|
export interface RetrieveSegment extends Segment {
|
||||||
|
document?: {
|
||||||
|
id: string;
|
||||||
|
data_source_type: string;
|
||||||
|
name: string;
|
||||||
|
doc_type: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检索结果记录
|
* 检索结果记录
|
||||||
*/
|
*/
|
||||||
export interface RetrieveRecord {
|
export interface RetrieveRecord {
|
||||||
segment: Segment;
|
segment: RetrieveSegment;
|
||||||
score: number;
|
score: number;
|
||||||
tsne_position?: {
|
tsne_position?: {
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { Form, Input, Button, Card, Spin } from 'antd';
|
||||||
import { Form, Input, Button, Card, message, Spin } from 'antd';
|
|
||||||
import { SaveOutlined } from '@ant-design/icons';
|
import { SaveOutlined } from '@ant-design/icons';
|
||||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
import { useDatasetSettings } from '~/hooks/dify-dataset-manager/dataset-settings';
|
||||||
import { updateDatasetName } from '~/api/dify-dataset/api/datasetApi';
|
import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
interface DatasetSettingsProps {
|
|
||||||
dataset: Dataset | null;
|
|
||||||
onDatasetUpdated: (dataset: Dataset) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识库设置组件
|
* 知识库设置组件
|
||||||
* 用于修改知识库名称和描述
|
* 用于修改知识库名称和描述
|
||||||
@@ -20,70 +14,14 @@ export default function DatasetSettings({
|
|||||||
onDatasetUpdated,
|
onDatasetUpdated,
|
||||||
}: DatasetSettingsProps) {
|
}: DatasetSettingsProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const {
|
||||||
|
saving,
|
||||||
// 初始化表单数据
|
hasChanges,
|
||||||
useEffect(() => {
|
handleValuesChange,
|
||||||
if (dataset) {
|
handleSave,
|
||||||
form.setFieldsValue({
|
handleReset,
|
||||||
name: dataset.name,
|
} = useDatasetSettings(dataset, form, onDatasetUpdated);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
Spin,
|
Spin,
|
||||||
message,
|
|
||||||
Divider,
|
Divider,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -17,45 +15,8 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
import { useDocumentDetail } from '~/hooks/dify-dataset-manager/document-detail';
|
||||||
import type { Segment } from '~/api/dify-dataset/type';
|
import type { DocumentDetailProps } from '~/types/dify-dataset-manager/document-detail';
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文档详情组件
|
* 文档详情组件
|
||||||
@@ -65,98 +26,17 @@ export default function DocumentDetail({
|
|||||||
datasetId,
|
datasetId,
|
||||||
document,
|
document,
|
||||||
}: DocumentDetailProps) {
|
}: DocumentDetailProps) {
|
||||||
// 分段设置状态
|
const {
|
||||||
const [settings, setSettings] = useState<SegmentationSettings>(DEFAULT_SETTINGS);
|
settings,
|
||||||
|
previewSegments,
|
||||||
// 预览状态
|
previewLoading,
|
||||||
const [previewSegments, setPreviewSegments] = useState<Segment[]>([]);
|
showPreview,
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
saving,
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
updateSettings,
|
||||||
|
handleReset,
|
||||||
// 保存状态
|
handlePreview,
|
||||||
const [saving, setSaving] = useState(false);
|
handleSaveAndProcess,
|
||||||
|
} = useDocumentDetail(datasetId, document);
|
||||||
// 当文档变化时重置设置
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Switch,
|
Switch,
|
||||||
message,
|
|
||||||
Empty,
|
Empty,
|
||||||
Spin,
|
Spin,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -19,33 +17,14 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
ClockCircleOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
SyncOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
|
||||||
PauseCircleOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||||
import { deleteDocument, toggleDocumentStatus } 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 DocumentUpload from './document-upload';
|
||||||
import '../../styles/components/dify-dataset-manager/index.css';
|
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,116 +41,24 @@ export default function DocumentList({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onViewDocument,
|
onViewDocument,
|
||||||
}: DocumentListProps) {
|
}: DocumentListProps) {
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const {
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
searchValue,
|
||||||
|
setSearchValue,
|
||||||
// 显示上传页面的状态
|
deletingId,
|
||||||
const [showUploadPage, setShowUploadPage] = useState(false);
|
showUploadPage,
|
||||||
|
getStatusConfig,
|
||||||
/**
|
formatDate,
|
||||||
* 获取状态标签配置
|
formatNumber,
|
||||||
*/
|
handleDelete,
|
||||||
const getStatusConfig = (status: IndexingStatus) => {
|
handleToggleStatus,
|
||||||
const configs: Record<IndexingStatus, { color: string; icon: React.ReactNode; text: string }> = {
|
handleUploadClick,
|
||||||
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
|
handleUploadClose,
|
||||||
indexing: { color: 'processing', icon: <SyncOutlined spin />, text: '索引中' },
|
handleUploadSuccess,
|
||||||
waiting: { color: 'warning', icon: <ClockCircleOutlined />, text: '等待中' },
|
filterDocuments,
|
||||||
parsing: { color: 'processing', icon: <SyncOutlined spin />, text: '解析中' },
|
} = useDocumentList(datasetId, onDocumentDeleted, onDocumentStatusChanged, onRefresh);
|
||||||
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 handleUploadClick = () => {
|
|
||||||
if (!datasetId) {
|
|
||||||
message.error('请先选择知识库');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowUploadPage(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭上传页面
|
|
||||||
*/
|
|
||||||
const handleUploadClose = () => {
|
|
||||||
setShowUploadPage(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传成功回调
|
|
||||||
*/
|
|
||||||
const handleUploadSuccess = () => {
|
|
||||||
setShowUploadPage(false);
|
|
||||||
onRefresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 过滤文档
|
// 过滤文档
|
||||||
const filteredDocuments = documents.filter((doc) =>
|
const filteredDocuments = filterDocuments(documents);
|
||||||
doc.name.toLowerCase().includes(searchValue.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns: ColumnsType<Document> = [
|
const columns: ColumnsType<Document> = [
|
||||||
|
|||||||
@@ -1,110 +1,36 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Input,
|
ArrowLeftOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd';
|
||||||
|
import {
|
||||||
Button,
|
Button,
|
||||||
InputNumber,
|
|
||||||
Checkbox,
|
|
||||||
Select,
|
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Checkbox,
|
||||||
Spin,
|
|
||||||
message,
|
|
||||||
Divider,
|
Divider,
|
||||||
Tooltip,
|
Empty,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
Progress,
|
Progress,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Tooltip,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { UploadFile, UploadProps } from 'antd';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
QuestionCircleOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
|
||||||
import type { Segment } from '~/api/dify-dataset/type';
|
import type { Segment } from '~/api/dify-dataset/type';
|
||||||
import {
|
import { useDocumentUpload } from '~/hooks/dify-dataset-manager/document-upload';
|
||||||
uploadDocumentWithConfig,
|
import type { DocumentUploadProps, UploadedDocument } from '~/types/dify-dataset-manager/document-upload';
|
||||||
updateDocumentByFile,
|
import { SUPPORTED_FORMATS } from '~/types/dify-dataset-manager/document-upload';
|
||||||
fetchIndexingStatus,
|
|
||||||
} from '~/api/dify-dataset/api/documentApi';
|
|
||||||
import { fetchSegments } from '~/api/dify-dataset/api/segmentApi';
|
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
interface DocumentUploadProps {
|
|
||||||
datasetId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分段设置配置
|
|
||||||
*/
|
|
||||||
interface SegmentationSettings {
|
|
||||||
separator: string;
|
|
||||||
maxTokens: number;
|
|
||||||
chunkOverlap: number;
|
|
||||||
removeExtraSpaces: boolean;
|
|
||||||
removeUrlsEmails: boolean;
|
|
||||||
indexingTechnique: 'high_quality' | 'economy';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认分段设置
|
|
||||||
*/
|
|
||||||
const DEFAULT_SETTINGS: SegmentationSettings = {
|
|
||||||
separator: '\\n\\n',
|
|
||||||
maxTokens: 1024,
|
|
||||||
chunkOverlap: 50,
|
|
||||||
removeExtraSpaces: true,
|
|
||||||
removeUrlsEmails: false,
|
|
||||||
indexingTechnique: 'high_quality',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个文档的上传状态
|
|
||||||
*/
|
|
||||||
type DocumentStage = 'pending' | 'uploading' | 'indexing' | 'completed' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传的文档信息(支持多文件)
|
|
||||||
*/
|
|
||||||
interface UploadedDocument {
|
|
||||||
file: File;
|
|
||||||
documentId: string;
|
|
||||||
batch: string;
|
|
||||||
stage: DocumentStage;
|
|
||||||
indexingStatus: IndexingStatus;
|
|
||||||
uploadProgress: number;
|
|
||||||
error?: string;
|
|
||||||
settings: SegmentationSettings;
|
|
||||||
segments: Segment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 索引状态配置
|
|
||||||
*/
|
|
||||||
const INDEXING_STATUS_CONFIG: Record<IndexingStatus, { text: string; percent: number }> = {
|
|
||||||
waiting: { text: '等待处理...', percent: 10 },
|
|
||||||
parsing: { text: '解析文档...', percent: 30 },
|
|
||||||
cleaning: { text: '清洗文本...', percent: 50 },
|
|
||||||
splitting: { text: '分段处理...', percent: 70 },
|
|
||||||
indexing: { text: '建立索引...', percent: 85 },
|
|
||||||
completed: { text: '处理完成', percent: 100 },
|
|
||||||
paused: { text: '已暂停', percent: 0 },
|
|
||||||
error: { text: '处理失败', percent: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 支持的文件格式
|
|
||||||
*/
|
|
||||||
const SUPPORTED_FORMATS = 'TXT, MARKDOWN, MDX, PDF, HTML, XLSX, XLS, DOCX, CSV, VTT, PROPERTIES, MD, HTM';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文档上传组件
|
* 文档上传组件
|
||||||
* 支持多文件上传,两步流程:选择文件 → 上传并配置分段
|
* 支持多文件上传,两步流程:选择文件 → 上传并配置分段
|
||||||
@@ -114,419 +40,55 @@ export default function DocumentUpload({
|
|||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: DocumentUploadProps) {
|
}: DocumentUploadProps) {
|
||||||
// 步骤控制
|
const {
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
// 状态
|
||||||
|
step,
|
||||||
|
fileList,
|
||||||
|
uploadedDocuments,
|
||||||
|
currentSettings,
|
||||||
|
previewLoading,
|
||||||
|
|
||||||
// 文件相关
|
// 方法
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
handleFileChange,
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
handleRemoveFile,
|
||||||
|
handleNextStep,
|
||||||
|
handleDocumentChange,
|
||||||
|
handleReprocess,
|
||||||
|
handlePrevStep,
|
||||||
|
handleGoToDocuments,
|
||||||
|
updateCurrentSettings,
|
||||||
|
|
||||||
// 多文档状态管理
|
// 计算属性方法
|
||||||
const [uploadedDocuments, setUploadedDocuments] = useState<UploadedDocument[]>([]);
|
getCurrentDocument,
|
||||||
// 当前选中查看的文档索引
|
getCurrentProgress,
|
||||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
getStatusText,
|
||||||
|
isCurrentDocProcessing,
|
||||||
|
getCompletionStats,
|
||||||
|
} = useDocumentUpload(datasetId, onClose, onSuccess);
|
||||||
|
|
||||||
// 当前显示的分段设置(来自当前选中的文档)
|
const selectedFiles = fileList.filter((f: UploadFile) => f.originFileObj).map((f: UploadFile) => f.originFileObj as File);
|
||||||
const [currentSettings, setCurrentSettings] = useState<SegmentationSettings>(DEFAULT_SETTINGS);
|
|
||||||
|
|
||||||
// 预览相关
|
// 平滑进度条逻辑
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [displayPercent, setDisplayPercent] = useState(0);
|
||||||
|
const targetPercent = getCurrentProgress();
|
||||||
|
|
||||||
// 轮询定时器(支持多个文档)
|
|
||||||
const pollingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
|
||||||
|
|
||||||
// 清理所有轮询定时器
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (targetPercent > displayPercent) {
|
||||||
pollingTimersRef.current.forEach(timer => clearInterval(timer));
|
// 如果目标进度大于当前显示进度,启动动画
|
||||||
pollingTimersRef.current.clear();
|
const diff = targetPercent - displayPercent;
|
||||||
};
|
// 动态步长:差距越大跑得越快,但最小步长为1
|
||||||
}, []);
|
const step = Math.max(1, Math.ceil(diff / 10));
|
||||||
|
|
||||||
/**
|
const timer = requestAnimationFrame(() => {
|
||||||
* 停止指定文档的轮询
|
setDisplayPercent(prev => Math.min(targetPercent, prev + step));
|
||||||
*/
|
});
|
||||||
const stopPolling = useCallback((documentId: string) => {
|
|
||||||
const timer = pollingTimersRef.current.get(documentId);
|
return () => cancelAnimationFrame(timer);
|
||||||
if (timer) {
|
} else if (targetPercent < displayPercent && targetPercent === 0) {
|
||||||
clearInterval(timer);
|
// 如果目标重置为0(例如重新开始),立即重置
|
||||||
pollingTimersRef.current.delete(documentId);
|
setDisplayPercent(0);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [targetPercent, displayPercent]);
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止所有轮询
|
|
||||||
*/
|
|
||||||
const stopAllPolling = useCallback(() => {
|
|
||||||
pollingTimersRef.current.forEach(timer => clearInterval(timer));
|
|
||||||
pollingTimersRef.current.clear();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载分段预览
|
|
||||||
*/
|
|
||||||
const loadSegmentsPreview = useCallback(async (documentId: string, docIndex: number) => {
|
|
||||||
setPreviewLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetchSegments(datasetId, documentId, 1, 50);
|
|
||||||
const segments = response.data || [];
|
|
||||||
// 更新对应文档的分段
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === docIndex ? { ...doc, segments } : doc
|
|
||||||
));
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('加载分段预览失败:', err);
|
|
||||||
message.error('加载分段预览失败');
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false);
|
|
||||||
}
|
|
||||||
}, [datasetId]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 轮询索引状态
|
|
||||||
*/
|
|
||||||
const pollIndexingStatus = useCallback(async (batch: string, documentId: string, docIndex: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetchIndexingStatus(datasetId, batch);
|
|
||||||
const documentStatus = response.data?.[0];
|
|
||||||
|
|
||||||
if (documentStatus) {
|
|
||||||
const status = documentStatus.indexing_status as IndexingStatus;
|
|
||||||
|
|
||||||
// 更新文档状态
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) => {
|
|
||||||
if (idx !== docIndex) return doc;
|
|
||||||
return { ...doc, indexingStatus: status };
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (status === 'completed') {
|
|
||||||
stopPolling(documentId);
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === docIndex ? { ...doc, stage: 'completed' } : doc
|
|
||||||
));
|
|
||||||
// message.success(`文档 "${uploadedDocuments[docIndex]?.file.name}" 处理完成!`);
|
|
||||||
// 自动加载分段预览
|
|
||||||
loadSegmentsPreview(documentId, docIndex);
|
|
||||||
} else if (status === 'error') {
|
|
||||||
stopPolling(documentId);
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === docIndex ? { ...doc, stage: 'error', error: documentStatus.error || '处理失败' } : doc
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取索引状态失败:', err);
|
|
||||||
}
|
|
||||||
}, [datasetId, stopPolling, loadSegmentsPreview, uploadedDocuments]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始轮询
|
|
||||||
*/
|
|
||||||
const startPolling = useCallback((batch: string, documentId: string, docIndex: number) => {
|
|
||||||
// 先停止之前的轮询
|
|
||||||
stopPolling(documentId);
|
|
||||||
|
|
||||||
// 开始新的轮询
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
pollIndexingStatus(batch, documentId, docIndex);
|
|
||||||
}, 2000);
|
|
||||||
pollingTimersRef.current.set(documentId, timer);
|
|
||||||
|
|
||||||
// 立即执行一次
|
|
||||||
pollIndexingStatus(batch, documentId, docIndex);
|
|
||||||
}, [stopPolling, pollIndexingStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建上传配置
|
|
||||||
*/
|
|
||||||
const buildConfig = (s: SegmentationSettings) => ({
|
|
||||||
indexing_technique: s.indexingTechnique,
|
|
||||||
process_rule: {
|
|
||||||
mode: 'custom' as const,
|
|
||||||
rules: {
|
|
||||||
pre_processing_rules: [
|
|
||||||
{ id: 'remove_extra_spaces' as const, enabled: s.removeExtraSpaces },
|
|
||||||
{ id: 'remove_urls_emails' as const, enabled: s.removeUrlsEmails },
|
|
||||||
],
|
|
||||||
segmentation: {
|
|
||||||
separator: s.separator.replace(/\\n/g, '\n'),
|
|
||||||
max_tokens: s.maxTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新当前文档的设置
|
|
||||||
*/
|
|
||||||
const updateCurrentSettings = (key: keyof SegmentationSettings, value: any) => {
|
|
||||||
const newSettings = { ...currentSettings, [key]: value };
|
|
||||||
setCurrentSettings(newSettings);
|
|
||||||
// 同步更新到文档列表
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === currentDocIndex ? { ...doc, settings: newSettings } : doc
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理文件选择变化
|
|
||||||
*/
|
|
||||||
const handleFileChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
|
||||||
setFileList(newFileList);
|
|
||||||
// 提取实际文件对象
|
|
||||||
const files = newFileList
|
|
||||||
.filter(f => f.originFileObj)
|
|
||||||
.map(f => f.originFileObj as File);
|
|
||||||
setSelectedFiles(files);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除文件
|
|
||||||
*/
|
|
||||||
const handleRemoveFile = (file: UploadFile) => {
|
|
||||||
const newFileList = fileList.filter(f => f.uid !== file.uid);
|
|
||||||
setFileList(newFileList);
|
|
||||||
const files = newFileList
|
|
||||||
.filter(f => f.originFileObj)
|
|
||||||
.map(f => f.originFileObj as File);
|
|
||||||
setSelectedFiles(files);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传单个文件
|
|
||||||
*/
|
|
||||||
const uploadSingleFile = async (file: File, index: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// 更新状态为上传中
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === index ? { ...doc, stage: 'uploading' as DocumentStage } : doc
|
|
||||||
));
|
|
||||||
|
|
||||||
const config = buildConfig(DEFAULT_SETTINGS);
|
|
||||||
const result = await uploadDocumentWithConfig(
|
|
||||||
datasetId,
|
|
||||||
file,
|
|
||||||
config,
|
|
||||||
(percent) => {
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === index ? { ...doc, uploadProgress: percent } : doc
|
|
||||||
));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新文档信息
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === index ? {
|
|
||||||
...doc,
|
|
||||||
documentId: result.document.id,
|
|
||||||
batch: result.batch,
|
|
||||||
stage: 'indexing' as DocumentStage,
|
|
||||||
indexingStatus: 'waiting' as IndexingStatus,
|
|
||||||
} : doc
|
|
||||||
));
|
|
||||||
|
|
||||||
// 开始轮询索引状态
|
|
||||||
startPolling(result.batch, result.document.id, index);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`上传文档 ${file.name} 失败:`, err);
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === index ? {
|
|
||||||
...doc,
|
|
||||||
stage: 'error' as DocumentStage,
|
|
||||||
error: err.message || '上传失败',
|
|
||||||
} : doc
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 点击"下一步":立即上传所有文件
|
|
||||||
*/
|
|
||||||
const handleNextStep = async () => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
message.warning('请先选择文件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化所有文档状态
|
|
||||||
const docs: UploadedDocument[] = selectedFiles.map(file => ({
|
|
||||||
file,
|
|
||||||
documentId: '',
|
|
||||||
batch: '',
|
|
||||||
stage: 'pending' as DocumentStage,
|
|
||||||
indexingStatus: 'waiting' as IndexingStatus,
|
|
||||||
uploadProgress: 0,
|
|
||||||
settings: { ...DEFAULT_SETTINGS },
|
|
||||||
segments: [],
|
|
||||||
}));
|
|
||||||
setUploadedDocuments(docs);
|
|
||||||
setCurrentDocIndex(0);
|
|
||||||
setCurrentSettings({ ...DEFAULT_SETTINGS });
|
|
||||||
setStep(2);
|
|
||||||
|
|
||||||
// 依次上传所有文件
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
|
||||||
await uploadSingleFile(selectedFiles[i], i);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换查看的文档
|
|
||||||
*/
|
|
||||||
const handleDocumentChange = (docId: string) => {
|
|
||||||
const index = uploadedDocuments.findIndex(doc => doc.documentId === docId || doc.file.name === docId);
|
|
||||||
if (index !== -1) {
|
|
||||||
setCurrentDocIndex(index);
|
|
||||||
const doc = uploadedDocuments[index];
|
|
||||||
setCurrentSettings(doc.settings);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改参数后重新处理当前文档
|
|
||||||
*/
|
|
||||||
const handleReprocess = async () => {
|
|
||||||
const currentDoc = uploadedDocuments[currentDocIndex];
|
|
||||||
if (!currentDoc || !currentDoc.documentId) return;
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === currentDocIndex ? {
|
|
||||||
...doc,
|
|
||||||
stage: 'uploading' as DocumentStage,
|
|
||||||
uploadProgress: 0,
|
|
||||||
segments: [],
|
|
||||||
} : doc
|
|
||||||
));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = buildConfig(currentSettings);
|
|
||||||
const result = await updateDocumentByFile(
|
|
||||||
datasetId,
|
|
||||||
currentDoc.documentId,
|
|
||||||
currentDoc.file,
|
|
||||||
config,
|
|
||||||
(percent) => {
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === currentDocIndex ? { ...doc, uploadProgress: percent } : doc
|
|
||||||
));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新 batch
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === currentDocIndex ? {
|
|
||||||
...doc,
|
|
||||||
batch: result.batch,
|
|
||||||
stage: 'indexing' as DocumentStage,
|
|
||||||
indexingStatus: 'waiting' as IndexingStatus,
|
|
||||||
} : doc
|
|
||||||
));
|
|
||||||
|
|
||||||
startPolling(result.batch, currentDoc.documentId, currentDocIndex);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('重新处理失败:', err);
|
|
||||||
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
|
||||||
idx === currentDocIndex ? {
|
|
||||||
...doc,
|
|
||||||
stage: 'error' as DocumentStage,
|
|
||||||
error: err.message || '重新处理失败',
|
|
||||||
} : doc
|
|
||||||
));
|
|
||||||
message.error(err.message || '重新处理失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回上一步
|
|
||||||
*/
|
|
||||||
const handlePrevStep = () => {
|
|
||||||
// 检查是否有文档正在处理
|
|
||||||
const hasProcessing = uploadedDocuments.some(doc =>
|
|
||||||
doc.stage === 'uploading' || doc.stage === 'indexing'
|
|
||||||
);
|
|
||||||
if (hasProcessing) {
|
|
||||||
message.warning('还有文档正在处理中,请等待完成');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopAllPolling();
|
|
||||||
setStep(1);
|
|
||||||
setUploadedDocuments([]);
|
|
||||||
setCurrentDocIndex(0);
|
|
||||||
setCurrentSettings(DEFAULT_SETTINGS);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回文档列表
|
|
||||||
*/
|
|
||||||
const handleGoToDocuments = () => {
|
|
||||||
stopAllPolling();
|
|
||||||
const hasCompleted = uploadedDocuments.some(doc => doc.stage === 'completed');
|
|
||||||
if (hasCompleted) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前文档
|
|
||||||
*/
|
|
||||||
const getCurrentDocument = (): UploadedDocument | null => {
|
|
||||||
return uploadedDocuments[currentDocIndex] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前文档的进度
|
|
||||||
*/
|
|
||||||
const getCurrentProgress = () => {
|
|
||||||
const doc = getCurrentDocument();
|
|
||||||
if (!doc) return 0;
|
|
||||||
if (doc.stage === 'uploading') {
|
|
||||||
return doc.uploadProgress;
|
|
||||||
}
|
|
||||||
if (doc.stage === 'indexing' || doc.stage === 'completed') {
|
|
||||||
return INDEXING_STATUS_CONFIG[doc.indexingStatus]?.percent || 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前文档的状态文本
|
|
||||||
*/
|
|
||||||
const getStatusText = () => {
|
|
||||||
const doc = getCurrentDocument();
|
|
||||||
if (!doc) return '';
|
|
||||||
if (doc.stage === 'uploading') {
|
|
||||||
return `正在上传... ${doc.uploadProgress}%`;
|
|
||||||
}
|
|
||||||
if (doc.stage === 'indexing') {
|
|
||||||
return INDEXING_STATUS_CONFIG[doc.indexingStatus]?.text || '处理中...';
|
|
||||||
}
|
|
||||||
if (doc.stage === 'completed') {
|
|
||||||
return '处理完成';
|
|
||||||
}
|
|
||||||
if (doc.stage === 'error') {
|
|
||||||
return doc.error || '处理失败';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断当前文档是否正在处理
|
|
||||||
*/
|
|
||||||
const isCurrentDocProcessing = () => {
|
|
||||||
const doc = getCurrentDocument();
|
|
||||||
return doc?.stage === 'uploading' || doc?.stage === 'indexing';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有文档的完成状态统计
|
|
||||||
*/
|
|
||||||
const getCompletionStats = () => {
|
|
||||||
const completed = uploadedDocuments.filter(doc => doc.stage === 'completed').length;
|
|
||||||
const total = uploadedDocuments.length;
|
|
||||||
return { completed, total };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染步骤指示器(两步流程)
|
* 渲染步骤指示器(两步流程)
|
||||||
@@ -567,7 +129,7 @@ export default function DocumentUpload({
|
|||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
<InboxOutlined />
|
<InboxOutlined />
|
||||||
</p>
|
</p>
|
||||||
<p className="ant-upload-text">拖拽文件或文件夹至此,或者 <span className="upload-link">选择文件</span></p>
|
<p className="ant-upload-text">拖拽文件或至此,或者 <span className="upload-link">选择文件</span></p>
|
||||||
<p className="ant-upload-hint">
|
<p className="ant-upload-hint">
|
||||||
已支持 {SUPPORTED_FORMATS},每个文件不超过 15MB。支持批量上传多个文件。
|
已支持 {SUPPORTED_FORMATS},每个文件不超过 15MB。支持批量上传多个文件。
|
||||||
</p>
|
</p>
|
||||||
@@ -579,7 +141,7 @@ export default function DocumentUpload({
|
|||||||
<div className="selected-files-section">
|
<div className="selected-files-section">
|
||||||
<h3 className="section-subtitle">嵌入已就绪 ({selectedFiles.length} 个文件)</h3>
|
<h3 className="section-subtitle">嵌入已就绪 ({selectedFiles.length} 个文件)</h3>
|
||||||
<div className="selected-files-list">
|
<div className="selected-files-list">
|
||||||
{fileList.map((file) => (
|
{fileList.map((file: UploadFile) => (
|
||||||
<div key={file.uid} className="selected-file-item">
|
<div key={file.uid} className="selected-file-item">
|
||||||
<FileTextOutlined className="file-icon" />
|
<FileTextOutlined className="file-icon" />
|
||||||
<div className="file-info">
|
<div className="file-info">
|
||||||
@@ -768,7 +330,7 @@ export default function DocumentUpload({
|
|||||||
value={currentDoc?.documentId || currentDoc?.file.name}
|
value={currentDoc?.documentId || currentDoc?.file.name}
|
||||||
style={{ width: 500 }}
|
style={{ width: 500 }}
|
||||||
onChange={handleDocumentChange}
|
onChange={handleDocumentChange}
|
||||||
options={uploadedDocuments.map((doc, idx) => ({
|
options={uploadedDocuments.map((doc: UploadedDocument) => ({
|
||||||
value: doc.documentId || doc.file.name,
|
value: doc.documentId || doc.file.name,
|
||||||
label: (
|
label: (
|
||||||
<span className="file-select-option">
|
<span className="file-select-option">
|
||||||
@@ -800,7 +362,7 @@ export default function DocumentUpload({
|
|||||||
<LoadingOutlined className="status-icon loading" />
|
<LoadingOutlined className="status-icon loading" />
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
percent={getCurrentProgress()}
|
percent={displayPercent}
|
||||||
status="active"
|
status="active"
|
||||||
strokeColor={{
|
strokeColor={{
|
||||||
'0%': '#00684a',
|
'0%': '#00684a',
|
||||||
@@ -808,36 +370,6 @@ export default function DocumentUpload({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="status-text">{getStatusText()}</div>
|
<div className="status-text">{getStatusText()}</div>
|
||||||
|
|
||||||
{/* 索引阶段详情 */}
|
|
||||||
{currentDoc?.stage === 'indexing' && (
|
|
||||||
<div className="indexing-stages">
|
|
||||||
<div className={`stage-item ${['waiting', 'parsing', 'cleaning', 'splitting', 'indexing', 'completed'].includes(currentDoc.indexingStatus) ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>等待处理</span>
|
|
||||||
</div>
|
|
||||||
<div className={`stage-item ${['parsing', 'cleaning', 'splitting', 'indexing', 'completed'].includes(currentDoc.indexingStatus) ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>解析文档</span>
|
|
||||||
</div>
|
|
||||||
<div className={`stage-item ${['cleaning', 'splitting', 'indexing', 'completed'].includes(currentDoc.indexingStatus) ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>清洗文本</span>
|
|
||||||
</div>
|
|
||||||
<div className={`stage-item ${['splitting', 'indexing', 'completed'].includes(currentDoc.indexingStatus) ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>分段处理</span>
|
|
||||||
</div>
|
|
||||||
<div className={`stage-item ${['indexing', 'completed'].includes(currentDoc.indexingStatus) ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>建立索引</span>
|
|
||||||
</div>
|
|
||||||
<div className={`stage-item ${currentDoc.indexingStatus === 'completed' ? 'active' : ''}`}>
|
|
||||||
<span className="stage-dot"></span>
|
|
||||||
<span>完成</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : currentDoc?.stage === 'error' ? (
|
) : currentDoc?.stage === 'error' ? (
|
||||||
<div className="preview-error">
|
<div className="preview-error">
|
||||||
@@ -855,7 +387,7 @@ export default function DocumentUpload({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="preview-segments">
|
<div className="preview-segments">
|
||||||
{currentDoc?.segments.map((segment, index) => (
|
{currentDoc?.segments.map((segment: Segment, index: number) => (
|
||||||
<div key={segment.id} className="segment-item">
|
<div key={segment.id} className="segment-item">
|
||||||
<div className="segment-header">
|
<div className="segment-header">
|
||||||
<span className="segment-index">#{index + 1}</span>
|
<span className="segment-index">#{index + 1}</span>
|
||||||
@@ -881,7 +413,7 @@ export default function DocumentUpload({
|
|||||||
{stats.completed}/{stats.total} 个文档处理完成
|
{stats.completed}/{stats.total} 个文档处理完成
|
||||||
</span>
|
</span>
|
||||||
<Button type="primary" onClick={handleGoToDocuments}>
|
<Button type="primary" onClick={handleGoToDocuments}>
|
||||||
前往文档
|
前往知识库
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { Spin } from 'antd';
|
||||||
import { message, Spin } from 'antd';
|
import DatasetLayout from './layout';
|
||||||
import DatasetLayout, { type MenuTab } from './layout';
|
|
||||||
import DocumentList from './document-list';
|
import DocumentList from './document-list';
|
||||||
import DocumentDetail from './document-detail';
|
import DocumentDetail from './document-detail';
|
||||||
import RetrieveTest from './retrieve-test';
|
import RetrieveTest from './retrieve-test';
|
||||||
import DatasetSettings from './dataset-settings';
|
import DatasetSettings from './dataset-settings';
|
||||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
import { useDatasetManager } from '~/hooks/dify-dataset-manager';
|
||||||
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 '../../styles/components/dify-dataset-manager/index.css';
|
import '../../styles/components/dify-dataset-manager/index.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,162 +12,30 @@ import '../../styles/components/dify-dataset-manager/index.css';
|
|||||||
* 带左侧菜单栏的完整布局
|
* 带左侧菜单栏的完整布局
|
||||||
*/
|
*/
|
||||||
export default function DatasetManager() {
|
export default function DatasetManager() {
|
||||||
// 知识库状态
|
const {
|
||||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
// 状态
|
||||||
const [loadingDataset, setLoadingDataset] = useState(true);
|
dataset,
|
||||||
|
loadingDataset,
|
||||||
// 文档状态
|
documents,
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
loadingDocuments,
|
||||||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
documentTotal,
|
||||||
const [documentTotal, setDocumentTotal] = useState(0);
|
documentPage,
|
||||||
const [documentPage, setDocumentPage] = useState(1);
|
documentPageSize,
|
||||||
const [documentPageSize] = useState(20);
|
inited,
|
||||||
|
error,
|
||||||
// 初始化状态
|
activeTab,
|
||||||
const [inited, setInited] = useState(false);
|
selectedDocument,
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
// 方法
|
||||||
// 菜单状态
|
handlePageChange,
|
||||||
const [activeTab, setActiveTab] = useState<MenuTab>('documents');
|
handleDocumentDeleted,
|
||||||
|
handleDocumentStatusChanged,
|
||||||
// 选中的文档(用于查看文档详情)
|
handleRefresh,
|
||||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
handleViewDocument,
|
||||||
|
handleBackToDocuments,
|
||||||
/**
|
handleTabChange,
|
||||||
* 加载知识库(获取第一个知识库)
|
handleDatasetUpdated,
|
||||||
*/
|
} = useDatasetManager();
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 加载中状态
|
// 加载中状态
|
||||||
if (!inited || loadingDataset) {
|
if (!inited || loadingDataset) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { Button, Tooltip } from 'antd';
|
import { Button, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
@@ -7,27 +6,7 @@ import {
|
|||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout';
|
||||||
|
|
||||||
/**
|
|
||||||
* 菜单项类型
|
|
||||||
*/
|
|
||||||
export type MenuTab = 'documents' | 'retrieve' | 'settings';
|
|
||||||
|
|
||||||
interface DatasetLayoutProps {
|
|
||||||
/** 知识库信息 */
|
|
||||||
dataset: Dataset | null;
|
|
||||||
/** 当前激活的菜单 */
|
|
||||||
activeTab: MenuTab;
|
|
||||||
/** 菜单切换回调 */
|
|
||||||
onTabChange: (tab: MenuTab) => void;
|
|
||||||
/** 是否显示返回按钮(在文档详情页时显示) */
|
|
||||||
showBackButton?: boolean;
|
|
||||||
/** 返回按钮点击回调 */
|
|
||||||
onBack?: () => void;
|
|
||||||
/** 子组件 */
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识库布局组件
|
* 知识库布局组件
|
||||||
@@ -41,7 +20,7 @@ export default function DatasetLayout({
|
|||||||
onBack,
|
onBack,
|
||||||
children,
|
children,
|
||||||
}: DatasetLayoutProps) {
|
}: DatasetLayoutProps) {
|
||||||
const menuItems: { key: MenuTab; icon: ReactNode; label: string }[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{ key: 'documents', icon: <FileTextOutlined />, label: '文档' },
|
{ key: 'documents', icon: <FileTextOutlined />, label: '文档' },
|
||||||
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' },
|
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' },
|
||||||
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
|
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
|
||||||
@@ -106,3 +85,6 @@ export default function DatasetLayout({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重新导出类型,保持向后兼容
|
||||||
|
export type { MenuTab } from '~/types/dify-dataset-manager/layout';
|
||||||
|
|||||||
@@ -1,202 +1,306 @@
|
|||||||
import { useState } from 'react';
|
import { SearchOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||||
import {
|
import { Button, Tag, Input, Slider, Spin, Select, Flex } from 'antd';
|
||||||
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 type { RetrieveRecord } from '~/api/dify-dataset/type';
|
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) {
|
export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const {
|
||||||
const [retrieveResults, setRetrieveResults] = useState<RetrieveRecord[]>([]);
|
searchQuery,
|
||||||
const [retrieving, setRetrieving] = useState(false);
|
setSearchQuery,
|
||||||
const [searchMethod, setSearchMethod] = useState<string>('hybrid_search');
|
retrieveResults,
|
||||||
const [topK, setTopK] = useState<number>(5);
|
retrieving,
|
||||||
|
searchMethod,
|
||||||
|
setSearchMethod,
|
||||||
|
topK,
|
||||||
|
setTopK,
|
||||||
|
handleRetrieve,
|
||||||
|
} = useRetrieveTest(datasetId);
|
||||||
|
|
||||||
/**
|
// 检索方式选项(只有3种)
|
||||||
* 执行检索
|
const searchMethodOptions = [
|
||||||
*/
|
{ label: '向量检索', value: 'semantic_search' },
|
||||||
const handleRetrieve = async () => {
|
{ label: '全文检索', value: 'full_text_search' },
|
||||||
if (!searchQuery.trim()) {
|
{ label: '混合检索', value: 'hybrid_search' },
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="retrieve-test-page">
|
<Flex
|
||||||
{/* 页面标题 */}
|
style={{
|
||||||
<div className="page-header">
|
height: '100%',
|
||||||
<h1>召回测试</h1>
|
minHeight: 'calc(100vh - 120px)',
|
||||||
<p className="page-description">
|
}}
|
||||||
输入查询内容,测试知识库的检索效果
|
>
|
||||||
</p>
|
{/* 左侧面板 - 输入区域 */}
|
||||||
</div>
|
<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">
|
<Flex vertical gap={8}>
|
||||||
<div className="search-row">
|
<Flex justify="space-between" align="center">
|
||||||
<Input
|
<span style={{
|
||||||
placeholder="输入检索关键词..."
|
fontSize: 13,
|
||||||
prefix={<FileSearchOutlined />}
|
color: colors.text,
|
||||||
value={searchQuery}
|
fontWeight: 500,
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
}}>
|
||||||
onPressEnter={handleRetrieve}
|
源文本
|
||||||
className="search-input"
|
</span>
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleRetrieve}
|
|
||||||
loading={retrieving}
|
|
||||||
>
|
|
||||||
检索
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="options-row">
|
|
||||||
<div className="option-item">
|
|
||||||
<span className="option-label">检索方式:</span>
|
|
||||||
<Select
|
<Select
|
||||||
value={searchMethod}
|
value={searchMethod}
|
||||||
onChange={setSearchMethod}
|
onChange={(value) => setSearchMethod(value as any)}
|
||||||
style={{ width: 140 }}
|
options={searchMethodOptions}
|
||||||
options={[
|
style={{ width: 130 }}
|
||||||
{ value: 'keyword_search', label: '关键词搜索' },
|
size="small"
|
||||||
{ value: 'semantic_search', label: '语义搜索' },
|
|
||||||
{ value: 'full_text_search', label: '全文搜索' },
|
|
||||||
{ value: 'hybrid_search', label: '混合搜索' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Flex>
|
||||||
<div className="option-item">
|
<Input.TextArea
|
||||||
<span className="option-label">返回数量 (Top K):</span>
|
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
|
<Slider
|
||||||
value={topK}
|
value={topK}
|
||||||
onChange={setTopK}
|
onChange={setTopK}
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
style={{ width: 120 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<span className="option-value">{topK}</span>
|
<span style={{
|
||||||
</div>
|
fontSize: 13,
|
||||||
</div>
|
color: colors.text,
|
||||||
</Card>
|
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 ? (
|
{retrieving ? (
|
||||||
<div className="loading-state">
|
<Flex
|
||||||
|
flex={1}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
vertical
|
||||||
|
gap={12}
|
||||||
|
>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
<div className="loading-text">检索中...</div>
|
<span style={{ color: colors.textSecondary }}>
|
||||||
</div>
|
检索中...
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
) : retrieveResults.length === 0 ? (
|
) : retrieveResults.length === 0 ? (
|
||||||
<Empty
|
<Flex
|
||||||
description="请输入关键词进行检索"
|
flex={1}
|
||||||
className="empty-state"
|
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">
|
<Flex justify="space-between" align="center">
|
||||||
<span>找到 {retrieveResults.length} 条结果</span>
|
<span style={{
|
||||||
</div>
|
fontSize: 14,
|
||||||
<Table
|
color: colors.text,
|
||||||
columns={columns}
|
fontWeight: 500,
|
||||||
dataSource={retrieveResults}
|
}}>
|
||||||
rowKey={(record) => record.segment.id}
|
检索结果
|
||||||
pagination={false}
|
</span>
|
||||||
size="small"
|
<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>
|
</Flex>
|
||||||
</div>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-18
@@ -28,6 +28,13 @@ interface ApiConfig {
|
|||||||
// 应用ID(用于登出)
|
// 应用ID(用于登出)
|
||||||
appId?: string;
|
appId?: string;
|
||||||
};
|
};
|
||||||
|
// Dify 知识库检索配置
|
||||||
|
dify: {
|
||||||
|
// Reranking 模型提供商
|
||||||
|
rerankingProviderName: string;
|
||||||
|
// Reranking 模型名称
|
||||||
|
rerankingModelName: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 端口特定配置映射
|
// 端口特定配置映射
|
||||||
@@ -37,17 +44,12 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
// 主要
|
// 主要
|
||||||
// 梅州
|
// 梅州
|
||||||
'51703': {
|
'51703': {
|
||||||
// baseUrl: 'http://172.16.0.55:8073',
|
baseUrl: 'http://172.16.0.78:8073',
|
||||||
// documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
documentUrl: 'http://172.16.0.78:8073/docauditai/',
|
||||||
// uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
|
||||||
// collaboraUrl: 'http://172.16.0.81:9980',
|
|
||||||
// appUrl: 'http://172.16.0.34:51703',
|
|
||||||
|
|
||||||
baseUrl: 'http://10.79.97.17:8000',
|
collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
documentUrl: 'http://10.79.97.17:8000/docauditai/',
|
appUrl: 'http://172.16.0.34:51703',
|
||||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
|
||||||
collaboraUrl: 'http://10.79.97.17:9980',
|
|
||||||
appUrl: 'http://10.79.97.17:51703',
|
|
||||||
|
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51703/callback'
|
redirectUri: 'http://10.79.97.17:51703/callback'
|
||||||
@@ -119,20 +121,23 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
const configs: Record<string, ApiConfig> = {
|
const configs: Record<string, ApiConfig> = {
|
||||||
// 开发环境
|
// 开发环境
|
||||||
development: {
|
development: {
|
||||||
baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
documentUrl: 'http://172.16.0.78:8073/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
|
||||||
|
|
||||||
collaboraUrl: 'http://172.16.0.81:9980',
|
collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
// appUrl: 'http://172.16.0.34:51709',
|
appUrl: 'http://172.16.0.78:51703',
|
||||||
appUrl: 'http://172.16.0.34:5173',
|
|
||||||
|
|
||||||
oauth: {
|
oauth: {
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||||
clientId: 'none',
|
clientId: 'none',
|
||||||
clientSecret: 'none', // 需要替换为实际的Client Secret
|
clientSecret: 'none', // 需要替换为实际的Client Secret
|
||||||
redirectUri: 'http://10.79.97.17/', // 回调地址
|
redirectUri: 'http://10.79.97.17/', // 回调地址
|
||||||
appId: 'idaasoauth2' // 应用ID,用于登出
|
appId: 'idaasoauth2' // 应用ID,用于登出
|
||||||
|
},
|
||||||
|
dify: {
|
||||||
|
rerankingProviderName: 'langgenius/tongyi/tongyi',
|
||||||
|
rerankingModelName: 'gte-rerank'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,6 +154,10 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
clientSecret: 'placeholder', // 需要替换为实际的Client Secret
|
clientSecret: 'placeholder', // 需要替换为实际的Client Secret
|
||||||
redirectUri: 'http://10.79.97.17/', // 回调地址
|
redirectUri: 'http://10.79.97.17/', // 回调地址
|
||||||
appId: 'idaasoauth2' // 应用ID,用于登出
|
appId: 'idaasoauth2' // 应用ID,用于登出
|
||||||
|
},
|
||||||
|
dify: {
|
||||||
|
rerankingProviderName: 'langgenius/tongyi/tongyi',
|
||||||
|
rerankingModelName: 'gte-rerank'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -170,6 +179,10 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
clientSecret: 'placeholder', // 占位符,实际值从环境变量获取
|
clientSecret: 'placeholder', // 占位符,实际值从环境变量获取
|
||||||
redirectUri: 'http://10.79.97.17/', // 回调地址
|
redirectUri: 'http://10.79.97.17/', // 回调地址
|
||||||
appId: 'idaasoauth2' // 应用ID,用于登出
|
appId: 'idaasoauth2' // 应用ID,用于登出
|
||||||
|
},
|
||||||
|
dify: {
|
||||||
|
rerankingProviderName: 'langgenius/tongyi/tongyi',
|
||||||
|
rerankingModelName: 'gte-rerank'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -186,6 +199,10 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret
|
clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret
|
||||||
redirectUri: 'http://172.16.0.119:3000/callback', // 回调地址
|
redirectUri: 'http://172.16.0.119:3000/callback', // 回调地址
|
||||||
appId: 'idaasoauth2' // 应用ID,用于登出
|
appId: 'idaasoauth2' // 应用ID,用于登出
|
||||||
|
},
|
||||||
|
dify: {
|
||||||
|
rerankingProviderName: 'langgenius/tongyi/tongyi',
|
||||||
|
rerankingModelName: 'gte-rerank'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,7 +252,8 @@ const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => {
|
|||||||
clientSecret: process.env.OAUTH_CLIENT_SECRET || defaultConfig.oauth.clientSecret,
|
clientSecret: process.env.OAUTH_CLIENT_SECRET || defaultConfig.oauth.clientSecret,
|
||||||
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI || defaultConfig.oauth.redirectUri,
|
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI || defaultConfig.oauth.redirectUri,
|
||||||
appId: process.env.NEXT_PUBLIC_OAUTH_APP_ID || defaultConfig.oauth.appId
|
appId: process.env.NEXT_PUBLIC_OAUTH_APP_ID || defaultConfig.oauth.appId
|
||||||
}
|
},
|
||||||
|
dify: defaultConfig.dify
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,7 +381,8 @@ export const {
|
|||||||
uploadUrl: UPLOAD_URL,
|
uploadUrl: UPLOAD_URL,
|
||||||
collaboraUrl: COLLABORA_URL,
|
collaboraUrl: COLLABORA_URL,
|
||||||
appUrl: APP_URL,
|
appUrl: APP_URL,
|
||||||
oauth: OAUTH_CONFIG
|
oauth: OAUTH_CONFIG,
|
||||||
|
dify: DIFY_CONFIG
|
||||||
} = apiConfig;
|
} = apiConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||||
|
import { updateDatasetName } from '~/api/dify-dataset/api/datasetApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库设置状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useDatasetSettings(
|
||||||
|
dataset: Dataset | null,
|
||||||
|
form: FormInstance,
|
||||||
|
onDatasetUpdated: (dataset: Dataset) => void
|
||||||
|
) {
|
||||||
|
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 = useCallback(() => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const changed =
|
||||||
|
values.name !== dataset?.name ||
|
||||||
|
values.description !== (dataset?.description || '');
|
||||||
|
setHasChanges(changed);
|
||||||
|
}, [form, dataset]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存设置
|
||||||
|
*/
|
||||||
|
const handleSave = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [dataset, form, onDatasetUpdated]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置表单
|
||||||
|
*/
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (dataset) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: dataset.name,
|
||||||
|
description: dataset.description || '',
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [dataset, form]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
saving,
|
||||||
|
hasChanges,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
handleValuesChange,
|
||||||
|
handleSave,
|
||||||
|
handleReset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDatasetSettingsReturn = ReturnType<typeof useDatasetSettings>;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { message } from 'antd';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { updateDocumentWithSettings } from '~/api/dify-dataset/api/documentApi';
|
||||||
|
import { fetchSegments } from '~/api/dify-dataset/api/segmentApi';
|
||||||
|
import type { Segment } from '~/api/dify-dataset/type';
|
||||||
|
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
import type { DocumentDetailSegmentationSettings } from '~/types/dify-dataset-manager/document-detail';
|
||||||
|
import { DEFAULT_DOCUMENT_DETAIL_SETTINGS } from '~/types/dify-dataset-manager/document-detail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档详情状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useDocumentDetail(datasetId: string, document: Document | null) {
|
||||||
|
// 分段设置状态
|
||||||
|
const [settings, setSettings] = useState<DocumentDetailSegmentationSettings>(DEFAULT_DOCUMENT_DETAIL_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_DOCUMENT_DETAIL_SETTINGS);
|
||||||
|
setPreviewSegments([]);
|
||||||
|
setShowPreview(false);
|
||||||
|
}
|
||||||
|
}, [document?.id]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新设置
|
||||||
|
*/
|
||||||
|
const updateSettings = useCallback((key: keyof DocumentDetailSegmentationSettings, value: any) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置设置
|
||||||
|
*/
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setSettings(DEFAULT_DOCUMENT_DETAIL_SETTINGS);
|
||||||
|
setPreviewSegments([]);
|
||||||
|
setShowPreview(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览分段
|
||||||
|
*/
|
||||||
|
const handlePreview = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [datasetId, document]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存并处理
|
||||||
|
*/
|
||||||
|
const handleSaveAndProcess = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [datasetId, document, settings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
settings,
|
||||||
|
previewSegments,
|
||||||
|
previewLoading,
|
||||||
|
showPreview,
|
||||||
|
saving,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
updateSettings,
|
||||||
|
handleReset,
|
||||||
|
handlePreview,
|
||||||
|
handleSaveAndProcess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDocumentDetailReturn = ReturnType<typeof useDocumentDetail>;
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
import { deleteDocument, toggleDocumentStatus } from '~/api/dify-dataset/api/documentApi';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { StatusConfig } from '~/types/dify-dataset-manager/document-list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档列表状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useDocumentList(
|
||||||
|
datasetId: string,
|
||||||
|
onDocumentDeleted: (documentId: string) => void,
|
||||||
|
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void,
|
||||||
|
onRefresh: () => void
|
||||||
|
) {
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [showUploadPage, setShowUploadPage] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取状态标签配置
|
||||||
|
*/
|
||||||
|
const getStatusConfig = useCallback((status: IndexingStatus): StatusConfig => {
|
||||||
|
const configs: Record<IndexingStatus, StatusConfig> = {
|
||||||
|
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 = useCallback((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 = useCallback((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 = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [datasetId, onDocumentDeleted]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理启用/禁用文档
|
||||||
|
*/
|
||||||
|
const handleToggleStatus = useCallback(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 || '操作失败');
|
||||||
|
}
|
||||||
|
}, [datasetId, onDocumentStatusChanged]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击上传按钮,显示上传页面
|
||||||
|
*/
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
if (!datasetId) {
|
||||||
|
message.error('请先选择知识库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowUploadPage(true);
|
||||||
|
}, [datasetId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭上传页面
|
||||||
|
*/
|
||||||
|
const handleUploadClose = useCallback(() => {
|
||||||
|
setShowUploadPage(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传成功回调
|
||||||
|
*/
|
||||||
|
const handleUploadSuccess = useCallback(() => {
|
||||||
|
setShowUploadPage(false);
|
||||||
|
onRefresh();
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤文档
|
||||||
|
*/
|
||||||
|
const filterDocuments = useCallback((documents: Document[]) => {
|
||||||
|
return documents.filter((doc) =>
|
||||||
|
doc.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
searchValue,
|
||||||
|
setSearchValue,
|
||||||
|
deletingId,
|
||||||
|
showUploadPage,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
getStatusConfig,
|
||||||
|
formatDate,
|
||||||
|
formatNumber,
|
||||||
|
handleDelete,
|
||||||
|
handleToggleStatus,
|
||||||
|
handleUploadClick,
|
||||||
|
handleUploadClose,
|
||||||
|
handleUploadSuccess,
|
||||||
|
filterDocuments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDocumentListReturn = ReturnType<typeof useDocumentList>;
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import type { UploadFile, UploadProps } from 'antd';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchIndexingStatus,
|
||||||
|
updateDocumentByFile,
|
||||||
|
uploadDocumentWithConfig,
|
||||||
|
} from '~/api/dify-dataset/api/documentApi';
|
||||||
|
import { fetchSegments } from '~/api/dify-dataset/api/segmentApi';
|
||||||
|
import type { IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
import type {
|
||||||
|
DocumentStage,
|
||||||
|
SegmentationSettings,
|
||||||
|
UploadedDocument,
|
||||||
|
} from '~/types/dify-dataset-manager/document-upload';
|
||||||
|
import {
|
||||||
|
DEFAULT_SEGMENTATION_SETTINGS,
|
||||||
|
INDEXING_STATUS_CONFIG,
|
||||||
|
} from '~/types/dify-dataset-manager/document-upload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档上传状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useDocumentUpload(datasetId: string, onClose: () => void, onSuccess: () => void) {
|
||||||
|
// 步骤控制
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
|
// 文件相关
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
|
|
||||||
|
// 多文档状态管理
|
||||||
|
const [uploadedDocuments, setUploadedDocuments] = useState<UploadedDocument[]>([]);
|
||||||
|
// 当前选中查看的文档索引
|
||||||
|
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||||
|
|
||||||
|
// 当前显示的分段设置(来自当前选中的文档)
|
||||||
|
const [currentSettings, setCurrentSettings] = useState<SegmentationSettings>(DEFAULT_SEGMENTATION_SETTINGS);
|
||||||
|
|
||||||
|
// 预览相关
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
|
// 轮询定时器(支持多个文档)
|
||||||
|
const pollingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||||
|
// 状态追赶定时器
|
||||||
|
const statusCatchUpTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||||
|
|
||||||
|
// 状态顺序
|
||||||
|
const STATUS_ORDER: IndexingStatus[] = ['waiting', 'parsing', 'cleaning', 'splitting', 'indexing', 'completed'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止指定文档的轮询
|
||||||
|
*/
|
||||||
|
const stopPolling = useCallback((documentId: string) => {
|
||||||
|
const timer = pollingTimersRef.current.get(documentId);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
pollingTimersRef.current.delete(documentId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止所有轮询
|
||||||
|
*/
|
||||||
|
const stopAllPolling = useCallback(() => {
|
||||||
|
pollingTimersRef.current.forEach(timer => clearInterval(timer));
|
||||||
|
pollingTimersRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载分段预览
|
||||||
|
*/
|
||||||
|
const loadSegmentsPreview = useCallback(async (documentId: string, docIndex: number) => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchSegments(datasetId, documentId, 1, 50);
|
||||||
|
const segments = response.data || [];
|
||||||
|
// 更新对应文档的分段
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === docIndex ? { ...doc, segments } : doc
|
||||||
|
));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('加载分段预览失败:', err);
|
||||||
|
message.error('加载分段预览失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}, [datasetId]);
|
||||||
|
|
||||||
|
// 清理所有轮询定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
pollingTimersRef.current.forEach(timer => clearInterval(timer));
|
||||||
|
pollingTimersRef.current.clear();
|
||||||
|
statusCatchUpTimersRef.current.forEach(timer => clearTimeout(timer));
|
||||||
|
statusCatchUpTimersRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态追赶逻辑
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
uploadedDocuments.forEach((doc, index) => {
|
||||||
|
// 如果没有真实状态,或者已经完成/错误,或者正在追赶中(有定时器),则跳过
|
||||||
|
if (!doc.realIndexingStatus || doc.stage === 'error' || statusCatchUpTimersRef.current.has(doc.documentId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = STATUS_ORDER.indexOf(doc.indexingStatus);
|
||||||
|
const targetIndex = STATUS_ORDER.indexOf(doc.realIndexingStatus);
|
||||||
|
|
||||||
|
// 如果当前显示状态落后于真实状态
|
||||||
|
if (currentIndex < targetIndex) {
|
||||||
|
// 设置定时器,1秒后更新到下一个状态
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setUploadedDocuments(prev => prev.map((d, idx) => {
|
||||||
|
if (idx !== index) return d;
|
||||||
|
|
||||||
|
const nextStatus = STATUS_ORDER[currentIndex + 1];
|
||||||
|
const isCompleted = nextStatus === 'completed';
|
||||||
|
|
||||||
|
// 如果到达完成状态,且真实状态也是完成,则触发完成逻辑
|
||||||
|
if (isCompleted && d.realIndexingStatus === 'completed') {
|
||||||
|
stopPolling(d.documentId);
|
||||||
|
// 自动加载分段预览
|
||||||
|
loadSegmentsPreview(d.documentId, index);
|
||||||
|
return { ...d, indexingStatus: nextStatus, stage: 'completed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...d, indexingStatus: nextStatus };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 清除定时器引用
|
||||||
|
statusCatchUpTimersRef.current.delete(doc.documentId);
|
||||||
|
}, 1000); // 至少停留1秒
|
||||||
|
|
||||||
|
statusCatchUpTimersRef.current.set(doc.documentId, timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [uploadedDocuments, stopPolling, loadSegmentsPreview]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询索引状态
|
||||||
|
*/
|
||||||
|
const pollIndexingStatus = useCallback(async (batch: string, documentId: string, docIndex: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetchIndexingStatus(datasetId, batch);
|
||||||
|
const documentStatus = response.data?.[0];
|
||||||
|
|
||||||
|
if (documentStatus) {
|
||||||
|
const realStatus = documentStatus.indexing_status as IndexingStatus;
|
||||||
|
|
||||||
|
// 更新文档状态(只更新真实状态和统计信息,显示状态由 useEffect 控制)
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) => {
|
||||||
|
if (idx !== docIndex) return doc;
|
||||||
|
|
||||||
|
// 如果已经是 error 状态,直接更新
|
||||||
|
if (realStatus === 'error') {
|
||||||
|
stopPolling(documentId);
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
stage: 'error',
|
||||||
|
error: documentStatus.error || '处理失败',
|
||||||
|
realIndexingStatus: realStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
realIndexingStatus: realStatus,
|
||||||
|
completedSegments: documentStatus.completed_segments,
|
||||||
|
totalSegments: documentStatus.total_segments
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取索引状态失败:', err);
|
||||||
|
}
|
||||||
|
}, [datasetId, stopPolling]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始轮询
|
||||||
|
*/
|
||||||
|
const startPolling = useCallback((batch: string, documentId: string, docIndex: number) => {
|
||||||
|
// 先停止之前的轮询
|
||||||
|
stopPolling(documentId);
|
||||||
|
|
||||||
|
// 开始新的轮询
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
pollIndexingStatus(batch, documentId, docIndex);
|
||||||
|
}, 2000);
|
||||||
|
pollingTimersRef.current.set(documentId, timer);
|
||||||
|
|
||||||
|
// 立即执行一次
|
||||||
|
pollIndexingStatus(batch, documentId, docIndex);
|
||||||
|
}, [stopPolling, pollIndexingStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建上传配置
|
||||||
|
*/
|
||||||
|
const buildConfig = useCallback((s: SegmentationSettings) => ({
|
||||||
|
indexing_technique: s.indexingTechnique,
|
||||||
|
process_rule: {
|
||||||
|
mode: 'custom' as const,
|
||||||
|
rules: {
|
||||||
|
pre_processing_rules: [
|
||||||
|
{ id: 'remove_extra_spaces' as const, enabled: s.removeExtraSpaces },
|
||||||
|
{ id: 'remove_urls_emails' as const, enabled: s.removeUrlsEmails },
|
||||||
|
],
|
||||||
|
segmentation: {
|
||||||
|
separator: s.separator.replace(/\\n/g, '\n'),
|
||||||
|
max_tokens: s.maxTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前文档的设置
|
||||||
|
*/
|
||||||
|
const updateCurrentSettings = useCallback((key: keyof SegmentationSettings, value: any) => {
|
||||||
|
setCurrentSettings(prev => {
|
||||||
|
const newSettings = { ...prev, [key]: value };
|
||||||
|
// 同步更新到文档列表
|
||||||
|
setUploadedDocuments(prevDocs => prevDocs.map((doc, idx) =>
|
||||||
|
idx === currentDocIndex ? { ...doc, settings: newSettings } : doc
|
||||||
|
));
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
}, [currentDocIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件选择变化
|
||||||
|
*/
|
||||||
|
const handleFileChange: UploadProps['onChange'] = useCallback(({ fileList: newFileList }: { fileList: UploadFile[] }) => {
|
||||||
|
setFileList(newFileList);
|
||||||
|
// 提取实际文件对象
|
||||||
|
const files = newFileList
|
||||||
|
.filter((f: UploadFile) => f.originFileObj)
|
||||||
|
.map((f: UploadFile) => f.originFileObj as File);
|
||||||
|
setSelectedFiles(files);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除文件
|
||||||
|
*/
|
||||||
|
const handleRemoveFile = useCallback((file: UploadFile) => {
|
||||||
|
setFileList(prev => {
|
||||||
|
const newFileList = prev.filter(f => f.uid !== file.uid);
|
||||||
|
const files = newFileList
|
||||||
|
.filter(f => f.originFileObj)
|
||||||
|
.map(f => f.originFileObj as File);
|
||||||
|
setSelectedFiles(files);
|
||||||
|
return newFileList;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个文件
|
||||||
|
*/
|
||||||
|
const uploadSingleFile = useCallback(async (file: File, index: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 更新状态为上传中
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === index ? { ...doc, stage: 'uploading' as DocumentStage } : doc
|
||||||
|
));
|
||||||
|
|
||||||
|
const config = buildConfig(DEFAULT_SEGMENTATION_SETTINGS);
|
||||||
|
const result = await uploadDocumentWithConfig(
|
||||||
|
datasetId,
|
||||||
|
file,
|
||||||
|
config,
|
||||||
|
(percent) => {
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === index ? { ...doc, uploadProgress: percent } : doc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新文档信息
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === index ? {
|
||||||
|
...doc,
|
||||||
|
documentId: result.document.id,
|
||||||
|
batch: result.batch,
|
||||||
|
stage: 'indexing' as DocumentStage,
|
||||||
|
indexingStatus: 'waiting' as IndexingStatus,
|
||||||
|
realIndexingStatus: 'waiting' as IndexingStatus, // 初始化真实状态
|
||||||
|
} : doc
|
||||||
|
));
|
||||||
|
|
||||||
|
// 开始轮询索引状态
|
||||||
|
startPolling(result.batch, result.document.id, index);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`上传文档 ${file.name} 失败:`, err);
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === index ? {
|
||||||
|
...doc,
|
||||||
|
stage: 'error' as DocumentStage,
|
||||||
|
error: err.message || '上传失败',
|
||||||
|
} : doc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, [datasetId, buildConfig, startPolling]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击"下一步":立即上传所有文件
|
||||||
|
*/
|
||||||
|
const handleNextStep = useCallback(async () => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
message.warning('请先选择文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化所有文档状态
|
||||||
|
const docs: UploadedDocument[] = selectedFiles.map(file => ({
|
||||||
|
file,
|
||||||
|
documentId: '',
|
||||||
|
batch: '',
|
||||||
|
stage: 'pending' as DocumentStage,
|
||||||
|
indexingStatus: 'waiting' as IndexingStatus,
|
||||||
|
realIndexingStatus: 'waiting' as IndexingStatus, // 初始化真实状态
|
||||||
|
uploadProgress: 0,
|
||||||
|
settings: { ...DEFAULT_SEGMENTATION_SETTINGS },
|
||||||
|
segments: [],
|
||||||
|
}));
|
||||||
|
setUploadedDocuments(docs);
|
||||||
|
setCurrentDocIndex(0);
|
||||||
|
setCurrentSettings({ ...DEFAULT_SEGMENTATION_SETTINGS });
|
||||||
|
setStep(2);
|
||||||
|
|
||||||
|
// 依次上传所有文件
|
||||||
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
|
await uploadSingleFile(selectedFiles[i], i);
|
||||||
|
}
|
||||||
|
}, [selectedFiles, uploadSingleFile]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换查看的文档
|
||||||
|
*/
|
||||||
|
const handleDocumentChange = useCallback((docId: string) => {
|
||||||
|
const index = uploadedDocuments.findIndex(doc => doc.documentId === docId || doc.file.name === docId);
|
||||||
|
if (index !== -1) {
|
||||||
|
setCurrentDocIndex(index);
|
||||||
|
const doc = uploadedDocuments[index];
|
||||||
|
setCurrentSettings(doc.settings);
|
||||||
|
}
|
||||||
|
}, [uploadedDocuments]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改参数后重新处理当前文档
|
||||||
|
*/
|
||||||
|
const handleReprocess = useCallback(async () => {
|
||||||
|
const currentDoc = uploadedDocuments[currentDocIndex];
|
||||||
|
if (!currentDoc || !currentDoc.documentId) return;
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === currentDocIndex ? {
|
||||||
|
...doc,
|
||||||
|
stage: 'uploading' as DocumentStage,
|
||||||
|
uploadProgress: 0,
|
||||||
|
segments: [],
|
||||||
|
} : doc
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = buildConfig(currentSettings);
|
||||||
|
const result = await updateDocumentByFile(
|
||||||
|
datasetId,
|
||||||
|
currentDoc.documentId,
|
||||||
|
currentDoc.file,
|
||||||
|
config,
|
||||||
|
(percent) => {
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === currentDocIndex ? { ...doc, uploadProgress: percent } : doc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新 batch
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === currentDocIndex ? {
|
||||||
|
...doc,
|
||||||
|
batch: result.batch,
|
||||||
|
stage: 'indexing' as DocumentStage,
|
||||||
|
indexingStatus: 'waiting' as IndexingStatus,
|
||||||
|
realIndexingStatus: 'waiting' as IndexingStatus, // 初始化真实状态
|
||||||
|
} : doc
|
||||||
|
));
|
||||||
|
|
||||||
|
startPolling(result.batch, currentDoc.documentId, currentDocIndex);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('重新处理失败:', err);
|
||||||
|
setUploadedDocuments(prev => prev.map((doc, idx) =>
|
||||||
|
idx === currentDocIndex ? {
|
||||||
|
...doc,
|
||||||
|
stage: 'error' as DocumentStage,
|
||||||
|
error: err.message || '重新处理失败',
|
||||||
|
} : doc
|
||||||
|
));
|
||||||
|
message.error(err.message || '重新处理失败');
|
||||||
|
}
|
||||||
|
}, [uploadedDocuments, currentDocIndex, currentSettings, datasetId, buildConfig, startPolling]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回上一步
|
||||||
|
*/
|
||||||
|
const handlePrevStep = useCallback(() => {
|
||||||
|
// 检查是否有文档正在处理
|
||||||
|
const hasProcessing = uploadedDocuments.some(doc =>
|
||||||
|
doc.stage === 'uploading' || doc.stage === 'indexing'
|
||||||
|
);
|
||||||
|
if (hasProcessing) {
|
||||||
|
message.warning('还有文档正在处理中,请等待完成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopAllPolling();
|
||||||
|
setStep(1);
|
||||||
|
setUploadedDocuments([]);
|
||||||
|
setCurrentDocIndex(0);
|
||||||
|
setCurrentSettings(DEFAULT_SEGMENTATION_SETTINGS);
|
||||||
|
}, [uploadedDocuments, stopAllPolling]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回文档列表
|
||||||
|
*/
|
||||||
|
const handleGoToDocuments = useCallback(() => {
|
||||||
|
stopAllPolling();
|
||||||
|
const hasCompleted = uploadedDocuments.some(doc => doc.stage === 'completed');
|
||||||
|
if (hasCompleted) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [uploadedDocuments, stopAllPolling, onSuccess, onClose]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前文档
|
||||||
|
*/
|
||||||
|
const getCurrentDocument = useCallback((): UploadedDocument | null => {
|
||||||
|
return uploadedDocuments[currentDocIndex] || null;
|
||||||
|
}, [uploadedDocuments, currentDocIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前文档的进度
|
||||||
|
*/
|
||||||
|
const getCurrentProgress = useCallback(() => {
|
||||||
|
const doc = getCurrentDocument();
|
||||||
|
if (!doc) return 0;
|
||||||
|
if (doc.stage === 'uploading') {
|
||||||
|
return doc.uploadProgress;
|
||||||
|
}
|
||||||
|
if (doc.stage === 'indexing' || doc.stage === 'completed') {
|
||||||
|
return INDEXING_STATUS_CONFIG[doc.indexingStatus]?.percent || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [getCurrentDocument]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前文档的状态文本
|
||||||
|
*/
|
||||||
|
const getStatusText = useCallback(() => {
|
||||||
|
const doc = getCurrentDocument();
|
||||||
|
if (!doc) return '';
|
||||||
|
if (doc.stage === 'uploading') {
|
||||||
|
return `正在上传... ${doc.uploadProgress}%`;
|
||||||
|
}
|
||||||
|
if (doc.stage === 'indexing') {
|
||||||
|
const baseText = INDEXING_STATUS_CONFIG[doc.indexingStatus]?.text || '处理中...';
|
||||||
|
// 如果有分段信息,且处于分段或索引阶段,显示进度
|
||||||
|
if ((doc.indexingStatus === 'splitting' || doc.indexingStatus === 'indexing') &&
|
||||||
|
doc.totalSegments && doc.totalSegments > 0) {
|
||||||
|
return `${baseText} (${doc.completedSegments || 0}/${doc.totalSegments})`;
|
||||||
|
}
|
||||||
|
return baseText;
|
||||||
|
}
|
||||||
|
if (doc.stage === 'completed') {
|
||||||
|
return `处理完成 (${doc.totalSegments || doc.segments?.length || 0} 段)`;
|
||||||
|
}
|
||||||
|
if (doc.stage === 'error') {
|
||||||
|
return doc.error || '处理失败';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [getCurrentDocument]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前文档是否正在处理
|
||||||
|
*/
|
||||||
|
const isCurrentDocProcessing = useCallback(() => {
|
||||||
|
const doc = getCurrentDocument();
|
||||||
|
return doc?.stage === 'uploading' || doc?.stage === 'indexing';
|
||||||
|
}, [getCurrentDocument]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有文档的完成状态统计
|
||||||
|
*/
|
||||||
|
const getCompletionStats = useCallback(() => {
|
||||||
|
const completed = uploadedDocuments.filter(doc => doc.stage === 'completed').length;
|
||||||
|
const total = uploadedDocuments.length;
|
||||||
|
return { completed, total };
|
||||||
|
}, [uploadedDocuments]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
step,
|
||||||
|
selectedFiles,
|
||||||
|
fileList,
|
||||||
|
uploadedDocuments,
|
||||||
|
currentDocIndex,
|
||||||
|
currentSettings,
|
||||||
|
previewLoading,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
handleFileChange,
|
||||||
|
handleRemoveFile,
|
||||||
|
handleNextStep,
|
||||||
|
handleDocumentChange,
|
||||||
|
handleReprocess,
|
||||||
|
handlePrevStep,
|
||||||
|
handleGoToDocuments,
|
||||||
|
updateCurrentSettings,
|
||||||
|
|
||||||
|
// 计算属性方法
|
||||||
|
getCurrentDocument,
|
||||||
|
getCurrentProgress,
|
||||||
|
getStatusText,
|
||||||
|
isCurrentDocProcessing,
|
||||||
|
getCompletionStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDocumentUploadReturn = ReturnType<typeof useDocumentUpload>;
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
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 type { MenuTab } from '~/types/dify-dataset-manager/layout';
|
||||||
|
import { DEFAULT_DOCUMENT_PAGE_SIZE } from '~/types/dify-dataset-manager/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库管理器状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useDatasetManager() {
|
||||||
|
// 知识库状态
|
||||||
|
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(DEFAULT_DOCUMENT_PAGE_SIZE);
|
||||||
|
|
||||||
|
// 初始化状态
|
||||||
|
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 loadDocuments = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [documentPageSize]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载知识库(获取第一个知识库)
|
||||||
|
*/
|
||||||
|
const loadDataset = useCallback(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);
|
||||||
|
}
|
||||||
|
}, [loadDocuments]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档页码变化
|
||||||
|
*/
|
||||||
|
const handlePageChange = useCallback((page: number) => {
|
||||||
|
if (dataset) {
|
||||||
|
loadDocuments(dataset.id, page);
|
||||||
|
}
|
||||||
|
}, [dataset, loadDocuments]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档删除
|
||||||
|
*/
|
||||||
|
const handleDocumentDeleted = useCallback((documentId: string) => {
|
||||||
|
setDocuments((prev) => prev.filter((doc) => doc.id !== documentId));
|
||||||
|
setDocumentTotal((prev) => prev - 1);
|
||||||
|
|
||||||
|
// 更新知识库的文档数量
|
||||||
|
setDataset((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
document_count: prev.document_count - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档状态变化
|
||||||
|
*/
|
||||||
|
const handleDocumentStatusChanged = useCallback((documentId: string, enabled: boolean) => {
|
||||||
|
setDocuments((prev) =>
|
||||||
|
prev.map((doc) =>
|
||||||
|
doc.id === documentId ? { ...doc, enabled } : doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新文档列表
|
||||||
|
*/
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (dataset) {
|
||||||
|
loadDocuments(dataset.id, documentPage);
|
||||||
|
}
|
||||||
|
}, [dataset, documentPage, loadDocuments]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看文档详情(分段管理)
|
||||||
|
*/
|
||||||
|
const handleViewDocument = useCallback((doc: Document) => {
|
||||||
|
console.log('[DatasetManager] 查看文档详情:', doc);
|
||||||
|
setSelectedDocument(doc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回文档列表
|
||||||
|
*/
|
||||||
|
const handleBackToDocuments = useCallback(() => {
|
||||||
|
setSelectedDocument(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理菜单切换
|
||||||
|
*/
|
||||||
|
const handleTabChange = useCallback((tab: MenuTab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
// 切换菜单时清除选中的文档
|
||||||
|
if (tab !== 'documents') {
|
||||||
|
setSelectedDocument(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理知识库更新
|
||||||
|
*/
|
||||||
|
const handleDatasetUpdated = useCallback((updatedDataset: Dataset) => {
|
||||||
|
setDataset(updatedDataset);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
loadDataset();
|
||||||
|
}, [loadDataset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
dataset,
|
||||||
|
loadingDataset,
|
||||||
|
documents,
|
||||||
|
loadingDocuments,
|
||||||
|
documentTotal,
|
||||||
|
documentPage,
|
||||||
|
documentPageSize,
|
||||||
|
inited,
|
||||||
|
error,
|
||||||
|
activeTab,
|
||||||
|
selectedDocument,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
loadDataset,
|
||||||
|
loadDocuments,
|
||||||
|
handlePageChange,
|
||||||
|
handleDocumentDeleted,
|
||||||
|
handleDocumentStatusChanged,
|
||||||
|
handleRefresh,
|
||||||
|
handleViewDocument,
|
||||||
|
handleBackToDocuments,
|
||||||
|
handleTabChange,
|
||||||
|
handleDatasetUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDatasetManagerReturn = ReturnType<typeof useDatasetManager>;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import type { RetrieveRecord, RetrievalModel } from '~/api/dify-dataset/type';
|
||||||
|
import { retrieveDataset } from '~/api/dify-dataset/api/segmentApi';
|
||||||
|
import { DIFY_CONFIG } from '~/config/api-config';
|
||||||
|
import type { SearchMethod } from '~/types/dify-dataset-manager/retrieve-test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建完整的 retrieval_model 参数(匹配 Dify API 规范)
|
||||||
|
* 根据检索方式启用 Reranking(语义搜索和混合搜索需要启用)
|
||||||
|
*/
|
||||||
|
function buildRetrievalModel(searchMethod: SearchMethod, topK: number): RetrievalModel {
|
||||||
|
// 语义搜索和混合搜索需要启用 Reranking
|
||||||
|
const needReranking = searchMethod === 'semantic_search' || searchMethod === 'hybrid_search';
|
||||||
|
|
||||||
|
return {
|
||||||
|
search_method: searchMethod,
|
||||||
|
reranking_enable: needReranking,
|
||||||
|
reranking_mode: needReranking ? null : null,
|
||||||
|
reranking_model: {
|
||||||
|
reranking_provider_name: DIFY_CONFIG.rerankingProviderName,
|
||||||
|
reranking_model_name: DIFY_CONFIG.rerankingModelName,
|
||||||
|
},
|
||||||
|
weights: null,
|
||||||
|
top_k: topK,
|
||||||
|
score_threshold_enabled: false,
|
||||||
|
score_threshold: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 召回测试状态管理 Hook
|
||||||
|
*/
|
||||||
|
export function useRetrieveTest(datasetId: string) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [retrieveResults, setRetrieveResults] = useState<RetrieveRecord[]>([]);
|
||||||
|
const [retrieving, setRetrieving] = useState(false);
|
||||||
|
// 默认使用语义搜索
|
||||||
|
const [searchMethod, setSearchMethod] = useState<SearchMethod>('semantic_search');
|
||||||
|
const [topK, setTopK] = useState<number>(5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行检索
|
||||||
|
*/
|
||||||
|
const handleRetrieve = useCallback(async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
message.warning('请输入检索关键词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!datasetId) {
|
||||||
|
message.warning('知识库ID不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRetrieving(true);
|
||||||
|
try {
|
||||||
|
const retrievalModel = buildRetrievalModel(searchMethod, topK);
|
||||||
|
console.log('[Hook] 检索参数:', { datasetId, query: searchQuery, retrievalModel });
|
||||||
|
|
||||||
|
const response = await retrieveDataset(datasetId, searchQuery, retrievalModel);
|
||||||
|
setRetrieveResults(response.records || []);
|
||||||
|
if (response.records?.length === 0) {
|
||||||
|
message.info('未找到匹配的结果');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('检索失败:', err);
|
||||||
|
message.error(err.message || '检索失败');
|
||||||
|
} finally {
|
||||||
|
setRetrieving(false);
|
||||||
|
}
|
||||||
|
}, [datasetId, searchQuery, searchMethod, topK]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
retrieveResults,
|
||||||
|
retrieving,
|
||||||
|
searchMethod,
|
||||||
|
setSearchMethod,
|
||||||
|
topK,
|
||||||
|
setTopK,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
handleRetrieve,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseRetrieveTestReturn = ReturnType<typeof useRetrieveTest>;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "@remix-run/react";
|
||||||
import {type MetaFunction} from "@remix-run/node";
|
import {type MetaFunction} from "@remix-run/node";
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
|
|||||||
@@ -1754,6 +1754,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 修复样式污染:移除其他页面可能定义的伪元素 */
|
||||||
|
.document-upload-page .step-item::after,
|
||||||
|
.document-upload-page .step-item::before {
|
||||||
|
content: none !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.step-item.active {
|
.step-item.active {
|
||||||
color: #00684a;
|
color: #00684a;
|
||||||
}
|
}
|
||||||
@@ -1791,10 +1798,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-divider {
|
.step-divider {
|
||||||
width: 40px;
|
width: 64px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #e5e5e5;
|
background: #d9d9d9;
|
||||||
margin: 0 12px;
|
margin: 0 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2341,3 +2348,117 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* 强制覆盖主题色 - 绿色 #00684a
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
/* 强制覆盖所有 Primary 按钮颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-btn-primary {
|
||||||
|
background-color: #00684a !important;
|
||||||
|
border-color: #00684a !important;
|
||||||
|
box-shadow: 0 2px 0 rgba(0, 104, 74, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-btn-primary:hover,
|
||||||
|
.dataset-manager-wrapper .ant-btn-primary:focus {
|
||||||
|
background-color: #005a3f !important;
|
||||||
|
border-color: #005a3f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-btn-primary:active {
|
||||||
|
background-color: #004d36 !important;
|
||||||
|
border-color: #004d36 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-btn-primary:disabled {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
border-color: #d9d9d9 !important;
|
||||||
|
color: rgba(0, 0, 0, 0.25) !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强制覆盖 Upload Dragger 样式 */
|
||||||
|
.dataset-manager-wrapper .ant-upload-wrapper .ant-upload-drag:hover {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-upload-wrapper .ant-upload-drag .ant-upload-drag-icon .anticon {
|
||||||
|
color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-upload-wrapper .ant-upload-drag p.ant-upload-text {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-upload-wrapper .ant-upload-drag p.ant-upload-hint {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Steps 组件的主题色 */
|
||||||
|
.dataset-manager-wrapper .ant-steps .ant-steps-item-process .ant-steps-item-icon {
|
||||||
|
background-color: #00684a !important;
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-steps .ant-steps-item-finish .ant-steps-item-icon {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-steps .ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon {
|
||||||
|
color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-steps .ant-steps-item-finish > .ant-steps-item-container > .ant-steps-item-content > .ant-steps-item-title::after {
|
||||||
|
background-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Checkbox 选中颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-checkbox-checked .ant-checkbox-inner {
|
||||||
|
background-color: #00684a !important;
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-checkbox-wrapper:hover .ant-checkbox-inner,
|
||||||
|
.dataset-manager-wrapper .ant-checkbox:hover .ant-checkbox-inner,
|
||||||
|
.dataset-manager-wrapper .ant-checkbox-input:focus + .ant-checkbox-inner {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Radio 选中颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-radio-checked .ant-radio-inner {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
background-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-radio-wrapper:hover .ant-radio-inner,
|
||||||
|
.dataset-manager-wrapper .ant-radio:hover .ant-radio-inner,
|
||||||
|
.dataset-manager-wrapper .ant-radio-input:focus + .ant-radio-inner {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Switch 选中颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-switch-checked {
|
||||||
|
background-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Select 选中项颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
|
||||||
|
background-color: rgba(0, 104, 74, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Input/Select focus 边框颜色 */
|
||||||
|
.dataset-manager-wrapper .ant-input:focus,
|
||||||
|
.dataset-manager-wrapper .ant-input-focused,
|
||||||
|
.dataset-manager-wrapper .ant-input-number:focus,
|
||||||
|
.dataset-manager-wrapper .ant-input-number-focused,
|
||||||
|
.dataset-manager-wrapper .ant-select-focused:not(.ant-select-disabled).ant-select:not(.ant-select-customize-input) .ant-select-selector {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-manager-wrapper .ant-input:hover,
|
||||||
|
.dataset-manager-wrapper .ant-input-number:hover,
|
||||||
|
.dataset-manager-wrapper .ant-select:not(.ant-select-disabled):hover .ant-select-selector {
|
||||||
|
border-color: #00684a !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库设置组件 Props
|
||||||
|
*/
|
||||||
|
export interface DatasetSettingsProps {
|
||||||
|
dataset: Dataset | null;
|
||||||
|
onDatasetUpdated: (dataset: Dataset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库设置表单值
|
||||||
|
*/
|
||||||
|
export interface DatasetSettingsFormValues {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库设置状态
|
||||||
|
*/
|
||||||
|
export interface DatasetSettingsState {
|
||||||
|
saving: boolean;
|
||||||
|
hasChanges: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Segment } from '~/api/dify-dataset/type';
|
||||||
|
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档详情组件 Props
|
||||||
|
*/
|
||||||
|
export interface DocumentDetailProps {
|
||||||
|
datasetId: string;
|
||||||
|
document: Document | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分段设置配置(文档详情专用)
|
||||||
|
* 注意:Dify API 支持的参数有限
|
||||||
|
* - separator: ✅ 支持
|
||||||
|
* - maxTokens: ✅ 支持
|
||||||
|
* - removeExtraSpaces: ✅ 支持
|
||||||
|
* - removeUrlsEmails: ✅ 支持
|
||||||
|
* - useQASegment: ⚠️ 需要 doc_form: "qa_model"
|
||||||
|
*/
|
||||||
|
export interface DocumentDetailSegmentationSettings {
|
||||||
|
separator: string;
|
||||||
|
maxTokens: number;
|
||||||
|
removeExtraSpaces: boolean;
|
||||||
|
removeUrlsEmails: boolean;
|
||||||
|
useQASegment: boolean;
|
||||||
|
qaLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认分段设置(文档详情)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DOCUMENT_DETAIL_SETTINGS: DocumentDetailSegmentationSettings = {
|
||||||
|
separator: '\\n\\n',
|
||||||
|
maxTokens: 500,
|
||||||
|
removeExtraSpaces: true,
|
||||||
|
removeUrlsEmails: false,
|
||||||
|
useQASegment: false,
|
||||||
|
qaLanguage: 'Chinese',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档详情状态
|
||||||
|
*/
|
||||||
|
export interface DocumentDetailState {
|
||||||
|
settings: DocumentDetailSegmentationSettings;
|
||||||
|
previewSegments: Segment[];
|
||||||
|
previewLoading: boolean;
|
||||||
|
showPreview: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档列表组件 Props
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态标签配置类型
|
||||||
|
*/
|
||||||
|
export interface StatusConfig {
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态配置映射类型
|
||||||
|
*/
|
||||||
|
export type StatusConfigMap = Record<IndexingStatus, StatusConfig>;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Segment } from '~/api/dify-dataset/type';
|
||||||
|
import type { IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分段设置配置
|
||||||
|
*/
|
||||||
|
export interface SegmentationSettings {
|
||||||
|
separator: string;
|
||||||
|
maxTokens: number;
|
||||||
|
chunkOverlap: number;
|
||||||
|
removeExtraSpaces: boolean;
|
||||||
|
removeUrlsEmails: boolean;
|
||||||
|
indexingTechnique: 'high_quality' | 'economy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认分段设置
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SEGMENTATION_SETTINGS: SegmentationSettings = {
|
||||||
|
separator: '\\n\\n',
|
||||||
|
maxTokens: 1024,
|
||||||
|
chunkOverlap: 50,
|
||||||
|
removeExtraSpaces: true,
|
||||||
|
removeUrlsEmails: false,
|
||||||
|
indexingTechnique: 'high_quality',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个文档的上传状态
|
||||||
|
*/
|
||||||
|
export type DocumentStage = 'pending' | 'uploading' | 'indexing' | 'completed' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传的文档信息(支持多文件)
|
||||||
|
*/
|
||||||
|
export interface UploadedDocument {
|
||||||
|
file: File;
|
||||||
|
documentId: string;
|
||||||
|
batch: string;
|
||||||
|
stage: DocumentStage;
|
||||||
|
indexingStatus: IndexingStatus; // 显示用的状态
|
||||||
|
realIndexingStatus?: IndexingStatus; // 真实的后端状态
|
||||||
|
uploadProgress: number;
|
||||||
|
error?: string;
|
||||||
|
settings: SegmentationSettings;
|
||||||
|
segments: Segment[];
|
||||||
|
completedSegments?: number;
|
||||||
|
totalSegments?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引状态配置
|
||||||
|
*/
|
||||||
|
export const INDEXING_STATUS_CONFIG: Record<IndexingStatus, { text: string; percent: number }> = {
|
||||||
|
waiting: { text: '等待处理...', percent: 10 },
|
||||||
|
parsing: { text: '解析文档...', percent: 30 },
|
||||||
|
cleaning: { text: '清洗文本...', percent: 50 },
|
||||||
|
splitting: { text: '分段处理...', percent: 70 },
|
||||||
|
indexing: { text: '建立索引...', percent: 85 },
|
||||||
|
completed: { text: '处理完成', percent: 100 },
|
||||||
|
paused: { text: '已暂停', percent: 0 },
|
||||||
|
error: { text: '处理失败', percent: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持的文件格式
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_FORMATS = 'TXT, MARKDOWN, MDX, PDF, HTML, XLSX, XLS, DOCX, CSV, VTT, PROPERTIES, MD, HTM';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档上传组件 Props
|
||||||
|
*/
|
||||||
|
export interface DocumentUploadProps {
|
||||||
|
datasetId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||||
|
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||||
|
import type { MenuTab } from './layout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库管理器状态
|
||||||
|
*/
|
||||||
|
export interface DatasetManagerState {
|
||||||
|
// 知识库状态
|
||||||
|
dataset: Dataset | null;
|
||||||
|
loadingDataset: boolean;
|
||||||
|
|
||||||
|
// 文档状态
|
||||||
|
documents: Document[];
|
||||||
|
loadingDocuments: boolean;
|
||||||
|
documentTotal: number;
|
||||||
|
documentPage: number;
|
||||||
|
documentPageSize: number;
|
||||||
|
|
||||||
|
// 初始化状态
|
||||||
|
inited: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// 菜单状态
|
||||||
|
activeTab: MenuTab;
|
||||||
|
|
||||||
|
// 选中的文档
|
||||||
|
selectedDocument: Document | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认文档分页大小
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DOCUMENT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
// 导出所有子模块类型
|
||||||
|
export * from './dataset-settings';
|
||||||
|
export * from './document-detail';
|
||||||
|
export * from './document-list';
|
||||||
|
export * from './document-upload';
|
||||||
|
export * from './layout';
|
||||||
|
export * from './retrieve-test';
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项类型
|
||||||
|
*/
|
||||||
|
export type MenuTab = 'documents' | 'retrieve' | 'settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项配置
|
||||||
|
*/
|
||||||
|
export interface MenuItem {
|
||||||
|
key: MenuTab;
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库布局组件 Props
|
||||||
|
*/
|
||||||
|
export interface DatasetLayoutProps {
|
||||||
|
/** 知识库信息 */
|
||||||
|
dataset: Dataset | null;
|
||||||
|
/** 当前激活的菜单 */
|
||||||
|
activeTab: MenuTab;
|
||||||
|
/** 菜单切换回调 */
|
||||||
|
onTabChange: (tab: MenuTab) => void;
|
||||||
|
/** 是否显示返回按钮(在文档详情页时显示) */
|
||||||
|
showBackButton?: boolean;
|
||||||
|
/** 返回按钮点击回调 */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** 子组件 */
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { RetrieveRecord } from '~/api/dify-dataset/type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 召回测试组件 Props
|
||||||
|
*/
|
||||||
|
export interface RetrieveTestProps {
|
||||||
|
datasetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索方法类型
|
||||||
|
* - semantic_search: 向量检索(语义搜索)
|
||||||
|
* - full_text_search: 全文检索
|
||||||
|
* - hybrid_search: 混合检索
|
||||||
|
*/
|
||||||
|
export type SearchMethod = 'semantic_search' | 'full_text_search' | 'hybrid_search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索选项
|
||||||
|
*/
|
||||||
|
export interface RetrieveOptions {
|
||||||
|
searchMethod: SearchMethod;
|
||||||
|
topK: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 召回测试状态
|
||||||
|
*/
|
||||||
|
export interface RetrieveTestState {
|
||||||
|
searchQuery: string;
|
||||||
|
retrieveResults: RetrieveRecord[];
|
||||||
|
retrieving: boolean;
|
||||||
|
searchMethod: SearchMethod;
|
||||||
|
topK: number;
|
||||||
|
}
|
||||||
+3
-1
@@ -18,6 +18,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@ant-design/x": "^2.0.0",
|
||||||
|
"@ant-design/x-markdown": "^2.0.0",
|
||||||
"@codemirror/lang-javascript": "^6.2.3",
|
"@codemirror/lang-javascript": "^6.2.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@uiw/react-codemirror": "^4.23.10",
|
"@uiw/react-codemirror": "^4.23.10",
|
||||||
"ahooks": "^3.8.5",
|
"ahooks": "^3.8.5",
|
||||||
"antd": "^5.25.4",
|
"antd": "^6.0.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
|
|||||||
Generated
+1894
-611
File diff suppressed because it is too large
Load Diff
+24
-2
@@ -53,7 +53,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
// port: 5173,
|
// port: 5173,
|
||||||
port: Number(process.env.PORT) || 5173,
|
port: Number(process.env.PORT) || 51703,
|
||||||
open: true,
|
open: true,
|
||||||
// open: false,
|
// open: false,
|
||||||
allowedHosts: ['nas.7bm.co', 'localhost', '127.0.0.1'], // 允许的主机名列表1
|
allowedHosts: ['nas.7bm.co', 'localhost', '127.0.0.1'], // 允许的主机名列表1
|
||||||
@@ -69,6 +69,28 @@ export default defineConfig({
|
|||||||
// 防止依赖预构建时触发页面刷新导致路由中断
|
// 防止依赖预构建时触发页面刷新导致路由中断
|
||||||
force: false,
|
force: false,
|
||||||
// 预构建这些依赖,避免首次加载时出现重新构建
|
// 预构建这些依赖,避免首次加载时出现重新构建
|
||||||
include: ['react-pdf', 'pdfjs-dist', 'dayjs', '@remix-run/node', 'react-dom', 'axios', 'dayjs/plugin/utc', '@remix-run/react', 'react-router-dom', 'jszip', 'ahooks', 'antd', 'immer', '@ant-design/icons', 'react-markdown', 'remark-math', 'remark-breaks', 'rehype-katex', 'remark-gfm'],
|
include: [
|
||||||
|
'react-pdf',
|
||||||
|
'pdfjs-dist',
|
||||||
|
'dayjs',
|
||||||
|
'@remix-run/node',
|
||||||
|
'react-dom',
|
||||||
|
'axios',
|
||||||
|
'dayjs/plugin/utc',
|
||||||
|
'@remix-run/react',
|
||||||
|
'react-router-dom',
|
||||||
|
'jszip',
|
||||||
|
'ahooks',
|
||||||
|
'antd',
|
||||||
|
'immer',
|
||||||
|
'@ant-design/icons',
|
||||||
|
'react-markdown',
|
||||||
|
'remark-math',
|
||||||
|
'remark-breaks',
|
||||||
|
'rehype-katex',
|
||||||
|
'remark-gfm',
|
||||||
|
// Ant Design X 相关依赖
|
||||||
|
'@ant-design/x',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user