feat: 完善Dify知识库管理召回测试模块,优化知识库上传文件时的分段配置设置

This commit is contained in:
PingChuan
2025-12-03 12:03:09 +08:00
parent 0f49426a2e
commit a13f3b3635
29 changed files with 4016 additions and 1880 deletions
+2 -2
View File
@@ -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,
+1
View File
@@ -42,6 +42,7 @@ export type {
MetadataFilterCondition, MetadataFilterCondition,
MetadataFilteringConditions, MetadataFilteringConditions,
RetrieveRequest, RetrieveRequest,
RetrieveSegment,
RetrieveRecord, RetrieveRecord,
RetrieveResponse, RetrieveResponse,
} from './segmentTypes'; } from './segmentTypes';
+19 -12
View File
@@ -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>
)} )}
+27 -163
View File
@@ -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) {
+5 -23
View File
@@ -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
View File
@@ -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>;
+202
View File
@@ -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 -1
View File
@@ -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;
}
+43
View File
@@ -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';
+34
View File
@@ -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
View File
@@ -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",
+1894 -611
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -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',
],
}, },
}); });