389 lines
17 KiB
TypeScript
389 lines
17 KiB
TypeScript
/*
|
|
* @Author: ZhaoYing
|
|
* @Date: 2026-02-03 18:32:00
|
|
* @Last Modified by: ZhaoYing
|
|
* @Last Modified time: 2026-03-13 14:51:17
|
|
*/
|
|
/**
|
|
* Relationship Network Component
|
|
* Displays memory relationship graph with node details
|
|
* Interactive force-directed graph visualization
|
|
*/
|
|
|
|
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { Col, Row, Space, Button, Tabs, Flex, Divider } from 'antd'
|
|
import dayjs from 'dayjs'
|
|
import ReactEcharts from 'echarts-for-react'
|
|
|
|
import RbCard from '@/components/RbCard/Card'
|
|
import detailEmpty from '@/assets/images/userMemory/detail_empty.png'
|
|
import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
|
|
import type { RawCommunityNode } from '@/components/D3Graph/types'
|
|
import {
|
|
getMemorySearchEdges,
|
|
} from '@/api/memory'
|
|
import Empty from '@/components/Empty'
|
|
import Tag from '@/components/Tag'
|
|
import CommunityNetwork from './CommunityNetwork'
|
|
|
|
/** Node color palette */
|
|
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
|
|
const RelationshipNetwork:FC = () => {
|
|
const { t } = useTranslation()
|
|
const { id } = useParams()
|
|
const chartRef = useRef<ReactEcharts>(null)
|
|
const resizeScheduledRef = useRef(false)
|
|
const [nodes, setNodes] = useState<Node[]>([])
|
|
const [links, setLinks] = useState<Edge[]>([])
|
|
const [categories, setCategories] = useState<{ name: string }[]>([])
|
|
const [selectedNode, setSelectedNode] = useState<Node | RawCommunityNode | null>(null)
|
|
// const [fullScreen, setFullScreen] = useState<boolean>(false)
|
|
const navigate = useNavigate()
|
|
const [activeTab, setActiveTab] = useState('relationshipNetwork')
|
|
|
|
console.log('categories', categories)
|
|
const edgeAbortRef = useRef<AbortController | null>(null)
|
|
|
|
/** Fetch relationship network data */
|
|
const getEdgeData = useCallback(() => {
|
|
if (!id) return
|
|
edgeAbortRef.current?.abort()
|
|
edgeAbortRef.current = new AbortController()
|
|
setSelectedNode(null)
|
|
getMemorySearchEdges(id, { signal: edgeAbortRef.current.signal }).then((res) => {
|
|
const { nodes, edges, statistics } = res as GraphData
|
|
const curNodes: Node[] = []
|
|
const curEdges: Edge[] = []
|
|
const curNodeTypes = Object.keys(statistics.node_types).filter(vo => vo !== 'Dialogue')
|
|
|
|
// Calculate connection count for each node
|
|
const connectionCount: Record<string, number> = {}
|
|
edges.forEach(edge => {
|
|
connectionCount[edge.source] = (connectionCount[edge.source] || 0) + 1
|
|
connectionCount[edge.target] = (connectionCount[edge.target] || 0) + 1
|
|
})
|
|
|
|
// Process node data
|
|
nodes.filter(vo => vo.label !== 'Dialogue').forEach(node => {
|
|
const connections = connectionCount[node.id] || 0
|
|
const categoryIndex = curNodeTypes.indexOf(node.label)
|
|
|
|
// Get display name based on node type
|
|
let displayName = ''
|
|
switch (node.label) {
|
|
// case 'Statement':
|
|
// displayName = 'statement' in node.properties ? node.properties.statement?.slice(0, 5) || '' : ''
|
|
// break
|
|
case 'ExtractedEntity':
|
|
displayName = 'name' in node.properties ? node.properties.name || '' : ''
|
|
break
|
|
// default:
|
|
// displayName = 'content' in node.properties ? node.properties.content?.slice(0, 5) || '' : ''
|
|
// break
|
|
}
|
|
let symbolSize = 0
|
|
if (connections <= 1) {
|
|
symbolSize = 5
|
|
} else if (connections <= 10) {
|
|
symbolSize = 10
|
|
} else if (connections <= 15) {
|
|
symbolSize = 15
|
|
} else if (connections <= 20) {
|
|
symbolSize = 25
|
|
} else {
|
|
symbolSize = 35
|
|
}
|
|
|
|
curNodes.push({
|
|
...node,
|
|
name: displayName,
|
|
category: categoryIndex >= 0 ? categoryIndex : 0,
|
|
symbolSize: symbolSize, // Adjust node size based on connection count
|
|
})
|
|
})
|
|
|
|
// Create mapping from node ID to label
|
|
const nodeIdToLabel: Record<string, string> = {}
|
|
nodes.forEach(node => {
|
|
nodeIdToLabel[node.id] = node.label
|
|
})
|
|
// Process edge data
|
|
edges.forEach(edge => {
|
|
curEdges.push({
|
|
...edge,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
value: edge.weight || 1
|
|
})
|
|
})
|
|
|
|
// Set categories
|
|
const curCategories = curNodeTypes.map(type => ({ name: type }))
|
|
|
|
setNodes(curNodes)
|
|
setLinks(curEdges)
|
|
setCategories(curCategories)
|
|
})
|
|
}, [id])
|
|
useEffect(() => {
|
|
if (!id) return
|
|
getEdgeData()
|
|
return () => { edgeAbortRef.current?.abort() }
|
|
}, [id])
|
|
|
|
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])
|
|
|
|
/** Navigate to full graph view */
|
|
const handleViewAll = () => {
|
|
if (!selectedNode) return
|
|
const params = new URLSearchParams({
|
|
nodeId: selectedNode.id,
|
|
nodeLabel: selectedNode.label,
|
|
nodeName: (selectedNode as Node).name || ''
|
|
})
|
|
navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`)
|
|
}
|
|
const handleChangeTab = (tab: string) => {
|
|
if (tab === 'communityNetwork') {
|
|
edgeAbortRef.current?.abort()
|
|
} else {
|
|
getEdgeData()
|
|
}
|
|
setActiveTab(tab)
|
|
setSelectedNode(null)
|
|
}
|
|
|
|
return (
|
|
<Row gutter={16}>
|
|
{/* Relationship Network */}
|
|
<Col span={16}>
|
|
<RbCard bodyClassName="rb:pt-0!">
|
|
<Tabs
|
|
items={['relationshipNetwork', 'communityNetwork'].map(key => ({ key, label: t(`userMemory.${key}`) }))}
|
|
activeKey={activeTab}
|
|
onChange={handleChangeTab}
|
|
/>
|
|
<div className="rb:h-129.5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-sm">
|
|
{activeTab === 'communityNetwork'
|
|
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
|
|
: nodes.length === 0
|
|
? <Empty className="rb:h-full" />
|
|
: <ReactEcharts
|
|
option={{
|
|
colors: colors,
|
|
tooltip: {
|
|
show: false
|
|
},
|
|
legend: {
|
|
show: true,
|
|
bottom: 12,
|
|
},
|
|
series: [
|
|
{
|
|
type: 'graph',
|
|
layout: 'force',
|
|
data: nodes || [],
|
|
links: links || [],
|
|
categories: categories.map(vo => ({
|
|
name: t(`userMemory.${vo.name}`)
|
|
})) || [],
|
|
roam: true,
|
|
label: {
|
|
show: true,
|
|
position: 'right',
|
|
formatter: '{b}',
|
|
},
|
|
lineStyle: {
|
|
color: '#5B6167',
|
|
curveness: 0.3
|
|
},
|
|
force: {
|
|
repulsion: 100,
|
|
// Enable category aggregation
|
|
edgeLength: 80,
|
|
gravity: 0.3,
|
|
// Nodes of the same category attract each other
|
|
layoutAnimation: true,
|
|
// Prevent layout recalculation on click
|
|
preventOverlap: true,
|
|
// Keep layout stable after node click
|
|
edgeSymbol: ['none', 'arrow'],
|
|
edgeSymbolSize: [4, 10],
|
|
// Disable force-directed after initial layout
|
|
initLayout: 'force'
|
|
},
|
|
selectedMode: 'single',
|
|
draggable: true,
|
|
// Prevent layout recalculation on data update
|
|
animationDurationUpdate: 0,
|
|
select: {
|
|
itemStyle: {
|
|
borderWidth: 2,
|
|
borderColor: '#ffffff',
|
|
shadowBlur: 10,
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}}
|
|
style={{ height: '518px', width: '100%' }}
|
|
notMerge={false}
|
|
lazyUpdate={true}
|
|
onEvents={{
|
|
// Node click event handler
|
|
click: (params: { dataType: string; data: Node; name: string }) => {
|
|
if (params.dataType === 'node') {
|
|
// Handle node click event
|
|
console.log('Node clicked:', params.data);
|
|
// Use functional update to avoid state dependency issues
|
|
setSelectedNode(params.data)
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
}
|
|
</div>
|
|
</RbCard>
|
|
</Col>
|
|
{/* Memory Details */}
|
|
<Col span={8}>
|
|
<RbCard
|
|
title={t('userMemory.memoryDetails')}
|
|
headerType="borderless"
|
|
headerClassName="rb:min-h-[46px]!"
|
|
bodyClassName="rb:p-0!"
|
|
extra={selectedNode && !(selectedNode as RawCommunityNode).properties.community_id && (
|
|
<Button type="text" onClick={handleViewAll}>
|
|
<div className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/view.svg')] rb:hover:bg-[url('@/assets/images/userMemory/view_hover.svg')]" />
|
|
{t('userMemory.completeMemory')}
|
|
</Button>
|
|
)}
|
|
>
|
|
<div className="rb:h-133.5 rb:overflow-y-auto">
|
|
{!selectedNode
|
|
? <Empty url={detailEmpty} subTitle={activeTab === 'relationshipNetwork' ? t('userMemory.memoryDetailEmptyDesc') : t('userMemory.communityDetailEmptyDesc')} className="rb:h-full rb:mx-10 rb:text-center" size={[197.81, 150]} />
|
|
: (selectedNode as RawCommunityNode).properties.community_id
|
|
? <div className="rb:p-3 rb:pt-0">
|
|
<div className="rb:font-medium rb:text-[#212332] rb:text-[16px] rb:leading-5.5 rb:pl-1">
|
|
{(selectedNode as RawCommunityNode).properties.name}
|
|
</div>
|
|
<div className="rb:mt-3 rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.summary')}</div>
|
|
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:px-3 rb:py-2.5 rb:mt-2">
|
|
{(selectedNode as RawCommunityNode).properties.summary}
|
|
</div>
|
|
<Flex align="center" justify="space-between" className="rb:mt-5!">
|
|
<span className="rb:text-[#5B6167] rb:font-regular rb:pl-1">{t('userMemory.member_count')}</span>
|
|
<span className="rb:font-medium">{(selectedNode as RawCommunityNode).properties.member_count}{t('userMemory.member_count_desc')}</span>
|
|
</Flex>
|
|
|
|
<Divider className='rb:my-2.5!' />
|
|
<div className="rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.core_entities')}</div>
|
|
<ul className="rb:list-disc rb:pl-4 rb:text-[#5B6167] rb:mt-2">
|
|
{(selectedNode as RawCommunityNode).properties.core_entities.map((entity, index) => <li key={index}>{entity}</li>)}
|
|
</ul>
|
|
</div>
|
|
: <>
|
|
{(selectedNode as Node).name && (
|
|
<div className="rb:bg-[#F6F8FC] rb:border-t rb:border-b rb:border-[#DFE4ED] rb:font-medium rb:py-2 rb:px-4 rb:h-10">
|
|
{(selectedNode as Node).name}
|
|
</div>
|
|
)}
|
|
<div className="rb:p-4">
|
|
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
|
|
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
|
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
|
|
? selectedNode.properties.content
|
|
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
|
|
? selectedNode.properties.description
|
|
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
|
? selectedNode.properties.statement
|
|
: ''}
|
|
</div>
|
|
|
|
<div className="rb:font-medium rb:mb-2 rb:mt-4">
|
|
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
|
|
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
|
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
|
</div>
|
|
|
|
{(selectedNode as Node).properties.associative_memory > 0 && (
|
|
<div className="rb:mt-4">
|
|
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
|
|
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
|
<span className="rb:text-[#155EEF] rb:font-medium">{(selectedNode as Node).properties.associative_memory}</span>
|
|
{' '}{t('userMemory.unix')}{t('userMemory.associative_memory')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedNode.label === 'Statement' && (
|
|
(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
|
const p = selectedNode.properties as StatementNodeProperties
|
|
if ((key === 'emotion_keywords' && p[key]?.length > 0) || typeof p[key] === 'string') {
|
|
return (
|
|
<div className="rb:mt-4" key={key}>
|
|
{t(`userMemory.Statement_${key}`)}
|
|
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
|
{key === 'emotion_keywords'
|
|
? <Space>{p.emotion_keywords.map((v, i) => <Tag key={i}>{v}</Tag>)}</Space>
|
|
: p[key]}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
})
|
|
)}
|
|
|
|
{selectedNode.label === 'ExtractedEntity' && (
|
|
(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
|
const p = selectedNode.properties as ExtractedEntityNodeProperties
|
|
if (p[key]) {
|
|
return (
|
|
<div className="rb:mt-4" key={key}>
|
|
{t(`userMemory.ExtractedEntity_${key}`)}
|
|
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
|
{Array.isArray(p[key]) && p[key].length > 0
|
|
? p[key].map((v, i) => <div key={i}>- {v}</div>)
|
|
: p[key]}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
</RbCard>
|
|
</Col>
|
|
</Row>
|
|
)
|
|
}
|
|
/** Use React.memo to avoid unnecessary renders */
|
|
export default React.memo(RelationshipNetwork) |