/* * @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(null) const resizeScheduledRef = useRef(false) const [nodes, setNodes] = useState([]) const [links, setLinks] = useState([]) const [categories, setCategories] = useState<{ name: string }[]>([]) const [selectedNode, setSelectedNode] = useState(null) // const [fullScreen, setFullScreen] = useState(false) const navigate = useNavigate() const [activeTab, setActiveTab] = useState('relationshipNetwork') console.log('categories', categories) const edgeAbortRef = useRef(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 = {} 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 = {} 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 ( {/* Relationship Network */} ({ key, label: t(`userMemory.${key}`) }))} activeKey={activeTab} onChange={handleChangeTab} />
{activeTab === 'communityNetwork' ? setSelectedNode(community)} /> : nodes.length === 0 ? : ({ 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) } } }} /> }
{/* Memory Details */}
{t('userMemory.completeMemory')} )} >
{!selectedNode ? : (selectedNode as RawCommunityNode).properties.community_id ?
{(selectedNode as RawCommunityNode).properties.name}
{t('userMemory.summary')}
{(selectedNode as RawCommunityNode).properties.summary}
{t('userMemory.member_count')} {(selectedNode as RawCommunityNode).properties.member_count}{t('userMemory.member_count_desc')}
{t('userMemory.core_entities')}
    {(selectedNode as RawCommunityNode).properties.core_entities.map((entity, index) =>
  • {entity}
  • )}
: <> {(selectedNode as Node).name && (
{(selectedNode as Node).name}
)}
{t('userMemory.memoryContent')}
{['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 : ''}
{t('userMemory.created_at')}
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
{(selectedNode as Node).properties.associative_memory > 0 && (
{t('userMemory.associative_memory')}
{(selectedNode as Node).properties.associative_memory} {' '}{t('userMemory.unix')}{t('userMemory.associative_memory')}
)} {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 (
{t(`userMemory.Statement_${key}`)}
{key === 'emotion_keywords' ? {p.emotion_keywords.map((v, i) => {v})} : p[key]}
) } 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 (
{t(`userMemory.ExtractedEntity_${key}`)}
{Array.isArray(p[key]) && p[key].length > 0 ? p[key].map((v, i) =>
- {v}
) : p[key]}
) } return null }) )}
}
) } /** Use React.memo to avoid unnecessary renders */ export default React.memo(RelationshipNetwork)