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

578 lines
18 KiB
TypeScript
Raw Normal View History

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,
} from '@mui/material';
import { type GridRowSelectionModel } from '@mui/x-data-grid';
import knowledgeService from '@/services/knowledge_service';
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 { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks';
import { RUNNING_STATUS_KEYS } from '@/constants/knowledge';
function KnowledgeBaseDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 状态管理
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
const [loading, setLoading] = useState(true);
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 [testingDialogOpen, setTestingDialogOpen] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [processDetailsDialogOpen, setProcessDetailsDialogOpen] = useState(false);
const [selectedFileDetails, setSelectedFileDetails] = useState<IKnowledgeFile | null>(null);
// 轮询相关状态
const pollingIntervalRef = useRef<any>(null);
const [isPolling, setIsPolling] = useState(false);
// 使用新的document hooks
const {
documents: files,
total,
loading: filesLoading,
error: filesError,
currentPage,
pageSize,
refresh: refreshFiles,
setKeywords,
setCurrentPage,
setPageSize,
} = useDocumentList(id || '');
const {
uploadDocuments,
deleteDocuments,
runDocuments,
renameDocument,
changeDocumentStatus,
cancelRunDocuments,
loading: operationLoading,
error: operationError,
} = useDocumentOperations();
// 获取知识库详情
const fetchKnowledgeDetail = async () => {
if (!id) return;
try {
setLoading(true);
const response = await knowledgeService.getKnowledgeDetail({ kb_id: id });
if (response.data.code === 0) {
setKnowledgeBase(response.data.data);
} else {
setError(response.data.message || '获取知识库详情失败');
}
} catch (err: any) {
setError(err.response?.data?.message || err.message || '获取知识库详情失败');
} finally {
setLoading(false);
}
};
// 删除文件
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(() => {
fetchKnowledgeDetail();
}, [id]);
// 搜索文件
useEffect(() => {
const timer = setTimeout(() => {
setKeywords(searchKeyword);
}, 500);
return () => clearTimeout(timer);
}, [searchKeyword, setKeywords]);
// 合并错误状态
const combinedError = error || filesError || operationError;
if (loading) {
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>
);
}
if (!knowledgeBase) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning"></Alert>
</Box>
);
}
function fetchDocumentsFilter(): Promise<IDocumentInfoFilter | undefined> {
throw new Error('Function not implemented.');
}
return (
<Box sx={{ p: 3 }}>
{/* 面包屑导航 */}
<KnowledgeBreadcrumbs
kbItems={[
{
label: '知识库',
path: '/knowledge'
},
{
label: knowledgeBase?.name || '知识库详情',
path: `/knowledge/${id}`
}
]}
/>
{/* 知识库信息卡片 */}
<KnowledgeInfoCard knowledgeBase={knowledgeBase} />
{/* 文件列表组件 */}
<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}
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={testingDialogOpen} onClose={() => setTestingDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
<TextField
fullWidth
label="测试查询"
placeholder="输入要测试的查询内容..."
multiline
rows={3}
/>
<Stack direction="row" spacing={2}>
<TextField
label="返回结果数量"
type="number"
defaultValue={5}
sx={{ width: 150 }}
/>
<TextField
label="相似度阈值"
type="number"
defaultValue={0.7}
inputProps={{ min: 0, max: 1, step: 0.1 }}
sx={{ width: 150 }}
/>
</Stack>
<Box sx={{ minHeight: 200, border: '1px solid #e0e0e0', borderRadius: 1, p: 2 }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setTestingDialogOpen(false)}></Button>
<Button variant="contained"></Button>
</DialogActions>
</Dialog>
{/* 配置设置对话框 */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
<TextField
fullWidth
label="知识库名称"
defaultValue={knowledgeBase?.name}
/>
<TextField
fullWidth
label="描述"
multiline
rows={3}
defaultValue={knowledgeBase?.description}
/>
<TextField
fullWidth
label="语言"
defaultValue={knowledgeBase?.language}
/>
<TextField
fullWidth
label="嵌入模型"
defaultValue={knowledgeBase?.embd_id}
disabled
/>
<TextField
fullWidth
label="解析器"
defaultValue={knowledgeBase?.parser_id}
disabled
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}></Button>
<Button variant="contained"></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)}%`}
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;