Files
TERES_web_frontend/src/pages/knowledge/detail.tsx

567 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Typography,
LinearProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Breadcrumbs,
Link,
Stack,
Card,
CardContent,
Divider,
Chip,
Tabs,
Tab,
} from '@mui/material';
import { type GridRowSelectionModel } from '@mui/x-data-grid';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IDocumentInfoFilter } from '@/interfaces/database/document';
import FileUploadDialog from '@/components/FileUploadDialog';
import KnowledgeInfoCard from './components/KnowledgeInfoCard';
import DocumentListComponent from './components/DocumentListComponent';
import FloatingActionButtons from './components/FloatingActionButtons';
import KnowledgeBreadcrumbs from './components/KnowledgeBreadcrumbs';
import KnowledgeGraphView from './components/KnowledgeGraphView';
import { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks';
import { RUNNING_STATUS_KEYS } from '@/constants/knowledge';
import { useKnowledgeDetail } from '@/hooks/knowledge-hooks';
import logger from '@/utils/logger';
function KnowledgeBaseDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 状态管理
const [error, setError] = useState<string | null>(null);
const [searchKeyword, setSearchKeyword] = useState('');
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>({
type: 'include',
ids: new Set()
});
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [processDetailsDialogOpen, setProcessDetailsDialogOpen] = useState(false);
const [selectedFileDetails, setSelectedFileDetails] = useState<IKnowledgeFile | null>(null);
// 标签页状态
const [currentTab, setCurrentTab] = useState(0);
// 轮询相关状态
const pollingIntervalRef = useRef<any>(null);
const [isPolling, setIsPolling] = useState(false);
// documents filter
const [documentsFilter, setDocumentsFilter] = useState<IDocumentInfoFilter | undefined>(undefined);
const {
knowledge: knowledgeBase,
refresh: fetchKnowledgeDetail,
loading, showKnowledgeGraph,
knowledgeGraph
} = useKnowledgeDetail(id || '');
// 使用新的document hooks
const {
documents: files,
total,
loading: filesLoading,
error: filesError,
currentPage,
pageSize,
refresh: refreshFiles,
setKeywords,
setCurrentPage,
setPageSize,
fetchDocuments,
fetchDocumentsFilter,
} = useDocumentList(id || '');
const {
uploadDocuments,
deleteDocuments,
runDocuments,
renameDocument,
changeDocumentStatus,
cancelRunDocuments,
error: operationError,
} = useDocumentOperations();
console.log('showKnowledgeGraph:', showKnowledgeGraph, knowledgeGraph);
const refreshDetailData = async () => {
await fetchKnowledgeDetail();
await refreshFiles();
};
// 删除文件
const handleDeleteFiles = async () => {
try {
await deleteDocuments(Array.from(rowSelectionModel.ids) as string[]);
setDeleteDialogOpen(false);
setRowSelectionModel({
type: 'include',
ids: new Set()
});
refreshFiles();
fetchKnowledgeDetail();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '删除文件失败');
}
};
// 上传文件处理
const handleUploadFiles = async (uploadFiles: File[]) => {
console.log('上传文件:', uploadFiles);
const kb_id = knowledgeBase?.id || '';
try {
await uploadDocuments(kb_id, uploadFiles);
refreshFiles();
fetchKnowledgeDetail();
} catch (err: any) {
throw new Error(err.response?.data?.message || err.message || '上传文件失败');
}
};
// 重新解析文件
const handleReparse = async (docIds: string[]) => {
try {
await runDocuments(docIds);
refreshFiles(); // 刷新列表
startPolling(); // 开始轮询
} catch (err: any) {
setError(err.response?.data?.message || err.message || '重新解析失败');
}
};
// 重命名文件
const handleRename = async (fileId: string, newName: string) => {
try {
await renameDocument(fileId, newName);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '重命名失败');
}
};
// 更改文件状态
const handleChangeStatus = async (fileIds: string[], status: string) => {
try {
await changeDocumentStatus(fileIds, status);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '更改状态失败');
}
};
// 取消运行
const handleCancelRun = async (fileIds: string[]) => {
try {
await cancelRunDocuments(fileIds);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '取消运行失败');
}
};
// 查看详情
const handleViewDetails = (file: IKnowledgeFile) => {
console.log("查看详情:", file);
navigate(`/chunk/parsed-result?kb_id=${id}&doc_id=${file.id}`);
};
// 查看解析详情
const handleViewProcessDetails = (file: IKnowledgeFile) => {
console.log("查看解析详情:", file);
setSelectedFileDetails(file);
setProcessDetailsDialogOpen(true);
};
// 开始轮询
const startPolling = () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
setIsPolling(true);
pollingIntervalRef.current = setInterval(() => {
refreshFiles();
}, 3000); // 每3秒轮询一次
};
// 停止轮询
const stopPolling = () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setIsPolling(false);
};
// 检查是否有运行中的文档
const hasRunningDocuments = files.some(file => file.run === RUNNING_STATUS_KEYS.RUNNING);
// 根据运行状态自动管理轮询
useEffect(() => {
if (hasRunningDocuments && !isPolling) {
startPolling();
} else if (!hasRunningDocuments && isPolling) {
stopPolling();
}
}, [hasRunningDocuments, isPolling]);
// 组件卸载时清理轮询
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// 初始化数据
useEffect(() => {
fetchDocumentsFilter().then(filterData => {
if (filterData?.filter) {
// setKeywords(filter.keywords || '');
logger.debug('filter:', filterData.filter);
const filter = filterData.filter || {};
const showFilter = Object.keys(filter.run_status || {}).length > 0 || Object.keys(filter.suffix || {}).length > 0;
if (showFilter) {
setDocumentsFilter({
run_status: filterData.filter?.run_status || {},
suffix: filterData.filter?.suffix || {},
});
}
}
});
}, [id]);
// 搜索文件
useEffect(() => {
const timer = setTimeout(() => {
setKeywords(searchKeyword);
}, 500);
return () => clearTimeout(timer);
}, [searchKeyword, setKeywords]);
// 合并错误状态
const combinedError = error || filesError || operationError;
if (loading || !knowledgeBase) {
return (
<Box sx={{ p: 3 }}>
<LinearProgress />
<Typography sx={{ mt: 2 }}>...</Typography>
</Box>
);
}
if (combinedError) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{combinedError}</Alert>
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{/* 面包屑导航 */}
<KnowledgeBreadcrumbs
kbItems={[
{
label: '知识库',
path: '/knowledge'
},
{
label: knowledgeBase?.name || '知识库详情',
path: `/knowledge/${id}`
}
]}
/>
{/* 知识库信息卡片 */}
<KnowledgeInfoCard knowledgeBase={knowledgeBase} />
{/* 标签页组件 - 仅在showKnowledgeGraph为true时显示 */}
{showKnowledgeGraph ? (
<Box sx={{ mt: 3 }}>
<Tabs
value={currentTab}
onChange={(event, newValue) => setCurrentTab(newValue)}
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Documents" />
<Tab label="Graph" />
</Tabs>
{/* Document List 标签页内容 */}
{currentTab === 0 && (
<Box sx={{ mt: 2 }}>
<DocumentListComponent
files={files}
loading={filesLoading}
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
onReparse={(fileIds) => handleReparse(fileIds)}
onDelete={(fileIds) => {
setRowSelectionModel({
type: 'include',
ids: new Set(fileIds)
});
setDeleteDialogOpen(true);
}}
onUpload={() => setUploadDialogOpen(true)}
onRefresh={() => {
refreshFiles();
fetchKnowledgeDetail();
}}
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newModel) => {
setRowSelectionModel(newModel);
}}
total={total}
page={currentPage}
pageSize={pageSize}
documentFilter={documentsFilter}
onSelectedFilterChange={(filterBody) => {
fetchDocuments({}, filterBody);
}}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
onRename={handleRename}
onChangeStatus={handleChangeStatus}
onCancelRun={handleCancelRun}
onViewDetails={handleViewDetails}
onViewProcessDetails={handleViewProcessDetails}
/>
</Box>
)}
{/* Graph 标签页内容 */}
{currentTab === 1 && (
<Box sx={{ mt: 2 }}>
<KnowledgeGraphView knowledgeGraph={knowledgeGraph} />
</Box>
)}
</Box>
) : (
/* 原有的文件列表组件 - 当showKnowledgeGraph为false时显示 */
<DocumentListComponent
files={files}
loading={filesLoading}
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
onReparse={(fileIds) => handleReparse(fileIds)}
onDelete={(fileIds) => {
console.log('删除文件:', fileIds);
setRowSelectionModel({
type: 'include',
ids: new Set(fileIds)
});
setDeleteDialogOpen(true);
}}
onUpload={() => setUploadDialogOpen(true)}
onRefresh={() => {
refreshFiles();
fetchKnowledgeDetail();
}}
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newModel) => {
console.log('新的选择模型:', newModel);
setRowSelectionModel(newModel);
}}
total={total}
page={currentPage}
pageSize={pageSize}
documentFilter={documentsFilter}
onSelectedFilterChange={(filterBody) => {
fetchDocuments({}, filterBody);
}}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
onRename={handleRename}
onChangeStatus={handleChangeStatus}
onCancelRun={handleCancelRun}
onViewDetails={handleViewDetails}
onViewProcessDetails={handleViewProcessDetails}
/>
)}
{/* 浮动操作按钮 */}
<FloatingActionButtons
onTestClick={() => navigate(`/knowledge/${id}/testing`)}
onConfigClick={() => navigate(`/knowledge/${id}/setting`)}
/>
{/* 上传文件对话框 */}
<FileUploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
onUpload={handleUploadFiles}
title="上传文件到知识库"
acceptedFileTypes={['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav']}
maxFileSize={100}
maxFiles={10}
/>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography>
{rowSelectionModel.ids.size}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}></Button>
<Button color="error" onClick={handleDeleteFiles}></Button>
</DialogActions>
</Dialog>
{/* 文档详情对话框 */}
<Dialog
open={processDetailsDialogOpen}
onClose={() => setProcessDetailsDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent sx={{ p: 3 }}>
{selectedFileDetails && (
<Stack spacing={3}>
{/* 基本信息卡片 */}
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{selectedFileDetails.name}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
ID
</Typography>
<Typography variant="body1">
{selectedFileDetails.parser_id || '未指定'}
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
{/* 处理状态卡片 */}
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1">
{selectedFileDetails.process_begin_at || '未开始'}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1">
{selectedFileDetails.process_duration ? `${selectedFileDetails.process_duration}` : '未完成'}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
label={`${(100 * (selectedFileDetails.progress || 0)).toFixed(2)}%`}
color="primary"
size="small"
/>
<LinearProgress
variant="determinate"
value={100 * (selectedFileDetails.progress || 0)}
sx={{ flexGrow: 1, height: 8, borderRadius: 4 }}
/>
</Box>
</Box>
</Stack>
</CardContent>
</Card>
{/* 处理详情卡片 */}
{selectedFileDetails.progress_msg && (
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Box
sx={{
maxHeight: 300,
overflow: 'auto',
backgroundColor: 'grey.50',
borderRadius: 1,
p: 2,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Typography
variant="body2"
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
color: 'text.primary',
}}
>
{selectedFileDetails.progress_msg}
</Typography>
</Box>
</CardContent>
</Card>
)}
</Stack>
)}
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button onClick={() => setProcessDetailsDialogOpen(false)} variant="contained">
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default KnowledgeBaseDetail;