Files
MemoryBear/web/src/views/KnowledgeBase/components/KnowledgeGraph.tsx
yujiangping d1f44ef650 fix(knowledge-graph): improve tooltip styling and text wrapping
- Add max-width constraint to node and edge tooltip containers
- Enable word breaking and preserve whitespace formatting for edge descriptions
- Prevent tooltip overflow and improve readability of long description text
2026-01-15 17:33:47 +08:00

451 lines
16 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, { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Col } from 'antd'
import RbCard from '@/components/RbCard/Card'
import ReactEcharts from 'echarts-for-react'
import zoom from '@/assets/images/userMemory/zoom.svg'
import drag from '@/assets/images/userMemory/drag.svg'
import pointer from '@/assets/images/userMemory/pointer.svg'
import empty from '@/assets/images/userMemory/empty.svg'
import Empty from '@/components/Empty'
// 知识图谱数据类型定义
export interface KnowledgeNode {
id: string
entity_name: string
entity_type: string
description: string
pagerank: number
source_id: string[]
// ECharts 需要的属性
name: string
category: number
symbolSize: number
itemStyle: {
color: string
}
}
export interface KnowledgeEdge {
src_id: string
tgt_id: string
description: string
keywords: string[]
weight: number
source_id: string[]
source: string
target: string
// ECharts 需要的属性
value: number
}
export interface KnowledgeGraphData {
directed: boolean
multigraph: boolean
graph: {
source_id: string[]
}
nodes: KnowledgeNode[]
edges: KnowledgeEdge[]
}
export interface KnowledgeGraphResponse {
graph: KnowledgeGraphData
mind_map: Record<string, unknown>
}
interface KnowledgeGraphProps {
data?: KnowledgeGraphResponse
loading?: boolean
}
const operations = [
{ name: 'click', icon: pointer },
{ name: 'drag', icon: drag },
{ name: 'zoom', icon: zoom },
]
// 预定义的颜色调色板
const colorPalette = [
'#155EEF', '#4DA8FF', '#9C6FFF', '#8BAEF7', '#369F21',
'#FF5D34', '#FF8A4C', '#FFB048', '#E74C3C', '#9B59B6',
'#3498DB', '#1ABC9C', '#F39C12', '#D35400', '#C0392B',
'#8E44AD', '#2980B9', '#16A085', '#F1C40F', '#E67E22'
]
// 动态生成实体类型颜色映射
const generateEntityTypeColors = (entityTypes: string[]): Record<string, string> => {
const colorMap: Record<string, string> = {}
entityTypes.forEach((type, index) => {
colorMap[type] = colorPalette[index % colorPalette.length]
})
return colorMap
}
const KnowledgeGraph: FC<KnowledgeGraphProps> = ({ data, loading = false }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null)
const resizeScheduledRef = useRef(false)
const modalRef = useRef<HTMLDivElement>(null)
const [nodes, setNodes] = useState<KnowledgeNode[]>([])
const [links, setLinks] = useState<KnowledgeEdge[]>([])
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<KnowledgeNode | null>(null)
const [entityTypeColors, setEntityTypeColors] = useState<Record<string, string>>({})
// 弹框拖动相关状态
const [modalPosition, setModalPosition] = useState({ x: 20, y: 20 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
// 拖动处理函数
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsDragging(true)
setDragStart({
x: e.clientX - modalPosition.x,
y: e.clientY - modalPosition.y
})
}, [modalPosition])
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return
const newX = e.clientX - dragStart.x
const newY = e.clientY - dragStart.y
// 限制拖动范围,确保弹框不会超出容器
const container = chartRef.current?.getEchartsInstance().getDom().parentElement
if (container && modalRef.current) {
const containerRect = container.getBoundingClientRect()
const modalRect = modalRef.current.getBoundingClientRect()
const maxX = containerRect.width - modalRect.width
const maxY = containerRect.height - modalRect.height
setModalPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
})
}
}, [isDragging, dragStart])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// 添加全局鼠标事件监听
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging, handleMouseMove, handleMouseUp])
// 关闭弹框
const handleCloseModal = useCallback(() => {
setSelectedNode(null)
}, [])
// 处理知识图谱数据
const processGraphData = useCallback(() => {
if (!data?.graph) {
setNodes([])
setLinks([])
setCategories([])
setSelectedNode(null)
return
}
const { nodes: rawNodes, edges: rawEdges } = data.graph
const processedNodes: KnowledgeNode[] = []
const processedEdges: KnowledgeEdge[] = []
// 获取所有实体类型
const entityTypes = [...new Set(rawNodes.map(node => node.entity_type))]
const categoryMap = entityTypes.reduce((acc, type, index) => {
acc[type] = index
return acc
}, {} as Record<string, number>)
// 动态生成实体类型颜色映射
const dynamicEntityTypeColors = generateEntityTypeColors(entityTypes)
setEntityTypeColors(dynamicEntityTypeColors)
// 计算每个节点的连接数
const connectionCount: Record<string, number> = {}
rawEdges.forEach(edge => {
// 使用 src_id 和 tgt_id 计算连接数
connectionCount[edge.src_id] = (connectionCount[edge.src_id] || 0) + 1
connectionCount[edge.tgt_id] = (connectionCount[edge.tgt_id] || 0) + 1
})
// 处理节点数据
rawNodes.forEach(node => {
const connections = connectionCount[node.id] || 0
const categoryIndex = categoryMap[node.entity_type] || 0
// 根据 pagerank 和连接数计算节点大小
let symbolSize = Math.max(10, Math.min(50, node.pagerank * 200 + connections * 2))
processedNodes.push({
...node,
name: node.entity_name,
category: categoryIndex,
symbolSize,
itemStyle: {
color: dynamicEntityTypeColors[node.entity_type] || colorPalette[0]
}
})
})
// 处理边数据
rawEdges.forEach(edge => {
// 注意根据数据结构source 和 target 字段可能与 src_id 和 tgt_id 相反
// 我们使用 src_id 和 tgt_id 作为正确的连接关系
processedEdges.push({
...edge, // 保留所有原始字段
source: edge.src_id, // 使用 src_id 作为源节点
target: edge.tgt_id, // 使用 tgt_id 作为目标节点
value: edge.weight || 1
})
})
// 验证节点ID和边的连接
const nodeIds = new Set(processedNodes.map(n => n.id))
const validEdges = processedEdges.filter(edge => {
const sourceExists = nodeIds.has(edge.source)
const targetExists = nodeIds.has(edge.target)
if (!sourceExists || !targetExists) {
console.warn('Invalid edge:', edge, 'Source exists:', sourceExists, 'Target exists:', targetExists)
}
return sourceExists && targetExists
})
// 调试信息
console.log('Total nodes:', processedNodes.length)
console.log('Total edges:', processedEdges.length)
console.log('Valid edges:', validEdges.length)
console.log('Node IDs:', Array.from(nodeIds).slice(0, 5))
console.log('Edge sample:', validEdges.slice(0, 3))
// 设置分类
const processedCategories = entityTypes.map(type => ({ name: type }))
setNodes(processedNodes)
setLinks(validEdges) // 只使用有效的边
setCategories(processedCategories)
}, [data])
useEffect(() => {
processGraphData()
}, [processGraphData])
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize()
resizeScheduledRef.current = false
})
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
return () => {
resizeObserver.disconnect()
}
}, [nodes])
return (
<Col span={24}>
<RbCard
title={t('knowledgeBase.knowledgeGraph')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
<div className="rb:h-124 rb:relative">
{loading ? (
<div className="rb:h-full rb:flex rb:items-center rb:justify-center">
<div className="rb:text-[#5B6167]">...</div>
</div>
) : nodes.length === 0 ? (
<Empty className="rb:h-full" />
) : (
<>
<ReactEcharts
ref={chartRef}
option={{
colors: Object.values(entityTypeColors),
tooltip: {
show: true,
formatter: (params: any) => {
if (params.dataType === 'node') {
const node = params.data as KnowledgeNode
return `
<div class="rb:max-w-[560px]">
<div><strong>${node.entity_name}</strong></div>
<div>类型: ${node.entity_type}</div>
<div>重要度: ${(node.pagerank * 100).toFixed(2)}%</div>
</div>
`
} else if (params.dataType === 'edge') {
const edge = params.data as KnowledgeEdge
return `
<div class="rb:max-w-[560px]">
<div><strong>关系</strong></div>
<div>权重: ${edge.weight}</div>
<div class="rb:break-words rb:whitespace-pre-wrap">${edge.description}</div>
</div>
`
}
return ''
}
},
legend: {
data: categories.map(cat => cat.name),
orient: 'vertical',
left: 'right',
top: 'center'
},
series: [
{
type: 'graph',
layout: 'force',
data: nodes,
links: links,
categories: categories,
roam: true,
label: {
show: true,
position: 'right',
formatter: '{b}',
fontSize: 12
},
lineStyle: {
color: '#5B6167',
curveness: 0.3,
width: 2, // 固定线宽,避免函数问题
opacity: 0.8
},
force: {
repulsion: 300,
edgeLength: 150,
gravity: 0.1,
layoutAnimation: true,
preventOverlap: true
},
selectedMode: 'single',
draggable: true,
animationDurationUpdate: 0,
select: {
itemStyle: {
borderWidth: 2,
borderColor: '#ffffff',
shadowBlur: 10,
}
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 3
}
}
}
]
}}
style={{ height: '496px', width: '100%' }}
notMerge={false}
lazyUpdate={true}
onEvents={{
click: (params: { dataType: string; data: KnowledgeNode }) => {
if (params.dataType === 'node') {
console.log('Knowledge node clicked:', params.data)
setSelectedNode(params.data)
}
}
}}
/>
{/* 实体详情弹框 */}
{selectedNode && (
<div
ref={modalRef}
className="rb:absolute rb:bg-white rb:border rb:border-[#EBEBEB] rb:rounded-[12px] rb:shadow-lg rb:p-4 rb:w-80 rb:z-10"
style={{
left: modalPosition.x,
top: modalPosition.y,
cursor: isDragging ? 'grabbing' : 'grab'
}}
>
{/* 弹框头部 - 可拖动区域 */}
<div
className="rb:flex rb:items-center rb:justify-between rb:mb-3 rb:pb-2 rb:border-b rb:border-[#EBEBEB] rb:cursor-grab"
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<div className="rb:text-[16px] rb:font-medium rb:text-[#1A1A1A]">
{t('knowledgeBase.entityDetails')}
</div>
<button
onClick={handleCloseModal}
className="rb:w-6 rb:h-6 rb:flex rb:items-center rb:justify-center rb:text-[#5B6167] hover:rb:text-[#1A1A1A] hover:rb:bg-[#F0F3F8] rb:rounded rb:transition-colors"
>
×
</button>
</div>
{/* 弹框内容 */}
<div>
<div className="rb:font-medium rb:mb-4">
<div className="rb:text-[16px] rb:mb-2">{selectedNode.entity_name}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-2">
<span className="rb:inline-block rb:px-2 rb:py-1 rb:bg-[#F0F3F8] rb:rounded rb:mr-2">
{selectedNode.entity_type}
</span>
<span>: {(selectedNode.pagerank * 100).toFixed(2)}%</span>
</div>
</div>
<div className="rb:font-medium rb:mb-4">
{t('knowledgeBase.entityDescription')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2 rb:leading-5">
{selectedNode.description}
</div>
</div>
<div className="rb:font-medium rb:mb-2">
{t('knowledgeBase.sourceDocuments')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2">
{selectedNode.source_id.length}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
<div className="rb:bg-[#F0F3F8] rb:flex rb:items-center rb:gap-6 rb:rounded-[0px_0px_12px_12px] rb:p-[14px_40px] rb:m-[0_-20px_-16px_-16px]">
{operations.map((item) => (
<div key={item.name} className="rb:flex rb:items-center rb:text-[#5B6167] rb:leading-5">
<img src={item.icon} className="rb:w-5 rb:h-5 rb:mr-1" />
{t(`userMemory.${item.name}`)}
</div>
))}
</div>
</RbCard>
</Col>
)
}
export default React.memo(KnowledgeGraph)