feat(knowledge): add knowledge graph visualization component

- Add @xyflow/react dependency for graph visualization
- Create KnowledgeGraphView component with custom nodes and edges
- Extend knowledge detail hook to fetch and display graph data
- Add tabs in knowledge detail page to switch between documents and graph views
This commit is contained in:
2025-10-17 16:43:03 +08:00
parent de3d196e11
commit 3f85b0ff78
5 changed files with 727 additions and 150 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeResult, IParserConfig } from '@/interfaces/database/knowledge';
import type { IKnowledge, IKnowledgeGraph, IKnowledgeResult } from '@/interfaces/database/knowledge';
import type { IFetchKnowledgeListRequestParams } from '@/interfaces/request/knowledge';
/**
@@ -200,6 +200,12 @@ export const useKnowledgeDetail = (kbId: string) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [knowledgeGraph, setKnowledgeGraph] = useState<IKnowledgeGraph | null>(null);
const showKnowledgeGraph = useMemo(() => {
return knowledgeGraph !== null && Object.keys(knowledgeGraph?.graph || {}).length > 0;
}, [knowledgeGraph]);
const fetchKnowledgeDetail = useCallback(async () => {
if (!kbId) return;
@@ -223,15 +229,41 @@ export const useKnowledgeDetail = (kbId: string) => {
}
}, [kbId]);
const fetchKnowledgeGraph = useCallback(async () => {
if (!kbId) return;
try {
setLoading(true);
setError(null);
const response = await knowledgeService.getKnowledgeGraph(kbId);
if (response.data.code === 0) {
setKnowledgeGraph(response.data.data);
} else {
throw new Error(response.data.message || '获取知识库图失败');
}
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '获取知识库图失败';
setError(errorMessage);
console.error('Failed to fetch knowledge graph:', err);
} finally {
setLoading(false);
}
}, [kbId]);
useEffect(() => {
fetchKnowledgeDetail();
}, [fetchKnowledgeDetail]);
fetchKnowledgeGraph();
}, [fetchKnowledgeDetail, fetchKnowledgeGraph]);
return {
knowledge,
knowledgeGraph,
loading,
error,
refresh: fetchKnowledgeDetail,
showKnowledgeGraph,
};
};

View File

@@ -0,0 +1,351 @@
import React, { useMemo, useCallback } from 'react';
import {
ReactFlow,
type Node,
type Edge,
Controls,
Background,
useNodesState,
useEdgesState,
ConnectionMode,
Panel,
Handle,
Position,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Box, Typography, Alert, Chip, Card, CardContent, Tooltip, Avatar } from '@mui/material';
import type { IKnowledgeGraph } from '@/interfaces/database/knowledge';
import { v4 as uuidv4 } from 'uuid';
interface KnowledgeGraphProps {
knowledgeGraph?: IKnowledgeGraph | null;
}
// 根据实体类型获取节点颜色
const getNodeColor = (entityType?: string): string => {
const colorMap: Record<string, string> = {
PERSON: '#FF6B6B', // 红色 - 人物
ORGANIZATION: '#4ECDC4', // 青色 - 组织
LOCATION: '#45B7D1', // 蓝色 - 地点
EVENT: '#96CEB4', // 绿色 - 事件
CONCEPT: '#FFEAA7', // 黄色 - 概念
CATEGORY: '#DDA0DD', // 紫色 - 分类
TECHNOLOGY: '#98D8C8', // 薄荷绿 - 技术
PRODUCT: '#F7DC6F', // 金黄色 - 产品
SERVICE: '#BB8FCE', // 淡紫色 - 服务
};
return colorMap[entityType || 'CONCEPT'] || '#74B9FF';
};
// 自定义节点组件
const CustomNode = ({ data }: { data: any }) => {
const nodeColor = getNodeColor(data.entity_type);
const nodeSize = Math.max(80, Math.min(140, (data.pagerank || 0.1) * 500));
// 获取节点首字母作为Avatar显示
const getInitials = (name: string) => {
if (!name) return '?';
const words = name.trim().split(/\s+/);
if (words.length === 1) {
return words[0].charAt(0).toUpperCase();
}
return words.slice(0, 2).map(word => word.charAt(0).toUpperCase()).join('');
};
const tooltipContent = (
<Box sx={{ p: 1, maxWidth: 300 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1 }}>
{data.label || data.name || 'Unknown'}
</Typography>
<Typography variant="body2" sx={{ mb: 0.5 }}>
<strong>:</strong> {data.entity_type || 'Unknown'}
</Typography>
{data.description && (
<Typography variant="body2">
<strong>:</strong> {data.description}
</Typography>
)}
{data.pagerank && (
<Typography variant="caption" sx={{ display: 'block', mt: 1 }}>
PageRank: {data.pagerank.toFixed(4)}
</Typography>
)}
</Box>
);
return (
<Tooltip
title={tooltipContent}
placement="top"
arrow
enterDelay={500}
leaveDelay={200}
>
<Card
sx={{
width: nodeSize,
height: nodeSize,
borderRadius: '50%',
border: '3px solid #fff',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
cursor: 'pointer',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: nodeColor,
transition: 'all 0.2s ease-in-out',
'&:hover': {
transform: 'scale(1.1)',
boxShadow: '0 6px 20px rgba(0,0,0,0.25)',
zIndex: 10,
},
}}
>
{/* React Flow Handle 组件 */}
<Handle
type="target"
position={Position.Top}
style={{ background: '#555', opacity: 0 }}
/>
<Handle
type="source"
position={Position.Bottom}
style={{ background: '#555', opacity: 0 }}
/>
<CardContent
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
'&:last-child': { pb: 1 },
height: '100%',
width: '100%',
}}
>
<Typography
variant="caption"
sx={{
color: '#fff',
fontWeight: 'bold',
fontSize: Math.max(10, nodeSize * 0.08),
textAlign: 'center',
lineHeight: 1.1,
wordBreak: 'break-word',
textShadow: '1px 1px 2px rgba(0,0,0,0.7)',
maxWidth: '90%',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{data.label || data.name || 'Unknown'}
</Typography>
</CardContent>
</Card>
</Tooltip>
);
};
const nodeTypes = {
custom: CustomNode,
};
const KnowledgeGraphView: React.FC<KnowledgeGraphProps> = ({ knowledgeGraph }) => {
// 转换数据格式为 React Flow 所需的格式
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
const graphData = knowledgeGraph?.graph || {};
if (!graphData?.nodes || !graphData?.edges) {
return { nodes: [], edges: [] };
}
// 转换节点数据
// @ts-ignore
const nodes: Node[] = graphData.nodes.map((node, index) => {
console.log(`节点 ${index}:`, node);
console.log(`节点ID: ${node.id}`);
const encodeId = encodeURIComponent(String(node.id));
const n: Node = {
id: encodeId,
type: 'custom',
position: {
x: Math.random() * 800,
y: Math.random() * 600,
},
data: {
label: node.entity_name || node.id,
entity_type: node.entity_type,
pagerank: node.pagerank,
description: node.description,
...node,
},
};
return n
});
// 转换边数据
// @ts-ignore
const edges: Edge[] = graphData.edges.map((edge, index) => {
console.log(`${index}:`, edge);
console.log(`src_id: ${edge.src_id}, tgt_id: ${edge.tgt_id}`);
// 检查source和target是否存在
if (!edge.src_id || !edge.tgt_id) {
console.warn(`${index} 缺少src_id或tgt_id:`, edge);
return null;
}
// 检查对应的节点是否存在
const sourceExists = nodes.some(node => node.id === encodeURIComponent(edge.src_id));
const targetExists = nodes.some(node => node.id === encodeURIComponent(edge.tgt_id));
if (!sourceExists || !targetExists) {
console.warn(`${index} 的节点不存在: source=${sourceExists}, target=${targetExists}`, edge);
return null;
}
const weight = Number(edge.weight) || 1;
const strokeWidth = Math.max(1, Math.min(3, weight));
const sourceId = encodeURIComponent(String(edge.src_id));
const targetId = encodeURIComponent(String(edge.tgt_id));
const edgeObj: Edge = {
id: `${sourceId}-${targetId}`,
source: sourceId,
target: targetId,
type: 'simplebezier',
// animated: weight > 5,
style: {
strokeWidth,
stroke: '#99ADD1',
},
// label: edge.description ? edge.description.substring(0, 50) + '...' : '',
labelStyle: {
fontSize: '10px',
fill: '#666',
},
data: {
weight: edge.weight,
description: edge.description,
keywords: edge.keywords,
},
};
return edgeObj;
})
// @ts-ignore
.filter(edge => edge != null); // 过滤掉null值
return { nodes, edges };
}, [knowledgeGraph]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
console.log('转换后的节点:', nodes);
console.log('转换后的边:', edges);
// 节点点击事件
const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
console.log('节点点击:', node.data);
}, []);
// 边点击事件
const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => {
console.log('边点击:', edge.data);
}, []);
if (!knowledgeGraph || initialNodes.length === 0) {
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<Alert severity="info"></Alert>
</Box>
);
}
return (
<Box sx={{ width: '100%', height: '600px', border: '1px solid #e0e0e0', borderRadius: 1 }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
>
<Background />
<Controls />
{/* 图例面板 */}
<Panel position="top-right">
<Box sx={{
backgroundColor: 'rgba(255,255,255,0.9)',
p: 2,
borderRadius: 1,
minWidth: 200,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{['PERSON', 'ORGANIZATION', 'CATEGORY', 'TECHNOLOGY'].map((type) => (
<Box key={type} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
backgroundColor: getNodeColor(type),
border: '1px solid #fff',
}}
/>
<Typography variant="caption">{type}</Typography>
</Box>
))}
</Box>
</Box>
</Panel>
{/* 统计信息面板 */}
<Panel position="top-left">
<Box sx={{
backgroundColor: 'rgba(255,255,255,0.9)',
p: 2,
borderRadius: 1,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`节点: ${nodes.length}`}
size="small"
color="primary"
variant="outlined"
/>
<Chip
label={`边: ${edges.length}`}
size="small"
color="secondary"
variant="outlined"
/>
</Box>
</Box>
</Panel>
</ReactFlow>
</Box>
);
};
export default KnowledgeGraphView;

View File

@@ -18,9 +18,10 @@ import {
CardContent,
Divider,
Chip,
Tabs,
Tab,
} 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';
@@ -28,16 +29,16 @@ 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';
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>({
@@ -46,11 +47,13 @@ function KnowledgeBaseDetail() {
});
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 [currentTab, setCurrentTab] = useState(0);
// 轮询相关状态
const pollingIntervalRef = useRef<any>(null);
const [isPolling, setIsPolling] = useState(false);
@@ -80,25 +83,9 @@ function KnowledgeBaseDetail() {
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 { knowledge: knowledgeBase, refresh: fetchKnowledgeDetail, loading, showKnowledgeGraph, knowledgeGraph } = useKnowledgeDetail(id || '');
console.log('showKnowledgeGraph:', showKnowledgeGraph, knowledgeGraph);
// 删除文件
const handleDeleteFiles = async () => {
@@ -291,42 +278,104 @@ function KnowledgeBaseDetail() {
{/* 知识库信息卡片 */}
<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}
/>
{/* 标签页组件 - 仅在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) => {
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}
/>
</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}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
onRename={handleRename}
onChangeStatus={handleChangeStatus}
onCancelRun={handleCancelRun}
onViewDetails={handleViewDetails}
onViewProcessDetails={handleViewProcessDetails}
/>
)}
{/* 浮动操作按钮 */}
<FloatingActionButtons
@@ -359,88 +408,6 @@ function KnowledgeBaseDetail() {
</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}