Merge pull request #554 from SuanmoSuanyangTechnology/feature/memory_zy
Feature/memory zy
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -67,6 +68,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^18.2.0",
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:00:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:58:41
|
||||
* @Last Modified time: 2026-03-13 10:48:41
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type {
|
||||
MemoryFormData,
|
||||
} from '@/views/MemoryManagement/types'
|
||||
@@ -94,8 +95,12 @@ export const updatedEndUserProfile = (values: EndUser) => {
|
||||
return request.post(`/memory-storage/updated_end_user/profile`, values)
|
||||
}
|
||||
// User Memory - Relationship network
|
||||
export const getMemorySearchEdges = (end_user_id: string) => {
|
||||
return request.get(`/memory-storage/analytics/graph_data`, { end_user_id })
|
||||
export const getMemorySearchEdges = (end_user_id: string, config?: AxiosRequestConfig) => {
|
||||
return request.get(`/memory-storage/analytics/graph_data`, { end_user_id }, config)
|
||||
}
|
||||
// User Memory - Community graph
|
||||
export const getMemoryCommunityGraph = (end_user_id: string, config?: AxiosRequestConfig) => {
|
||||
return request.get(`/memory-storage/analytics/community_graph`, { end_user_id }, config)
|
||||
}
|
||||
// User Memory - User interest distribution
|
||||
export const getInterestDistributionByUser = (end_user_id: string) => {
|
||||
|
||||
67
web/src/components/D3Graph/CommunityGraph.tsx
Normal file
67
web/src/components/D3Graph/CommunityGraph.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, type FC } from 'react'
|
||||
import Empty from '@/components/Empty'
|
||||
import { GRAPH_COLORS, initCommunityGraph } from './utils'
|
||||
import { useD3Graph } from './hooks'
|
||||
import type { CommunityD3Node, D3Link, CommunityGraphProps } from './types'
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
// Renders a D3-powered community graph with optional tooltip and legend.
|
||||
|
||||
const CommunityGraph: FC<CommunityGraphProps> = ({
|
||||
data,
|
||||
empty: emptyProp,
|
||||
colors = GRAPH_COLORS,
|
||||
renderTooltip,
|
||||
showLegend = true,
|
||||
onCommunityClick,
|
||||
onNodeClick,
|
||||
defaultZoom = 1,
|
||||
}) => {
|
||||
// Tooltip position and hovered node state
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; node: CommunityD3Node } | null>(null)
|
||||
|
||||
// Keep callback refs stable to avoid re-initializing the graph on every render
|
||||
const onCommunityClickRef = useRef(onCommunityClick)
|
||||
const onNodeClickRef = useRef(onNodeClick)
|
||||
const renderTooltipRef = useRef(renderTooltip)
|
||||
useEffect(() => { onCommunityClickRef.current = onCommunityClick }, [onCommunityClick])
|
||||
useEffect(() => { onNodeClickRef.current = onNodeClick }, [onNodeClick])
|
||||
useEffect(() => { renderTooltipRef.current = renderTooltip }, [renderTooltip])
|
||||
|
||||
const graphState = useMemo(() => data, [data])
|
||||
// Show empty state when explicitly flagged or when there are no nodes
|
||||
const isEmpty = emptyProp ?? !data?.nodes.length
|
||||
|
||||
// Initialize (or re-initialize) the D3 graph whenever relevant state changes
|
||||
const containerRef = useD3Graph((container) => {
|
||||
if (!graphState) return
|
||||
return initCommunityGraph(
|
||||
container,
|
||||
graphState.nodes,
|
||||
graphState.links as D3Link[],
|
||||
graphState.communityMap,
|
||||
graphState.communityCaption,
|
||||
graphState.communityNodeMap,
|
||||
{ colors, showLegend, defaultZoom, setTooltip: renderTooltip ? setTooltip : () => {}, onCommunityClickRef, onNodeClickRef }
|
||||
)
|
||||
}, [graphState, showLegend, defaultZoom])
|
||||
|
||||
// Resolve tooltip content: use custom renderer if provided, otherwise fall back to DefaultTooltip
|
||||
const tooltipNode = tooltip && renderTooltipRef.current
|
||||
? renderTooltipRef.current(tooltip.node)
|
||||
: null
|
||||
|
||||
if (isEmpty) return <Empty className="rb:h-full" />
|
||||
return (
|
||||
<div className="rb:w-full rb:h-full rb:relative">
|
||||
<div ref={containerRef} className="rb:w-full rb:h-full" />
|
||||
{tooltipNode ? (
|
||||
<div style={{ position: 'absolute', left: tooltip!.x + 14, top: tooltip!.y - 10, pointerEvents: 'none', zIndex: 20 }}>
|
||||
{tooltipNode}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityGraph)
|
||||
24
web/src/components/D3Graph/hooks.ts
Normal file
24
web/src/components/D3Graph/hooks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
/**
|
||||
* Generic hook that mounts a D3 graph inside a div container.
|
||||
* Clears any existing SVG before calling initFn, and runs cleanup on unmount or dep change.
|
||||
*/
|
||||
export function useD3Graph<T>(
|
||||
initFn: (container: HTMLDivElement) => (() => void) | void,
|
||||
deps: T[]
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
const cleanup = initFn(container)
|
||||
return () => {
|
||||
cleanup?.()
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
}
|
||||
}, deps)
|
||||
return containerRef
|
||||
}
|
||||
102
web/src/components/D3Graph/types.ts
Normal file
102
web/src/components/D3Graph/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
|
||||
// ─── Raw input types (mirror of API response, no external dependency) ─────────
|
||||
// These interfaces map 1-to-1 with the graph API response shape.
|
||||
|
||||
export interface RawCommunityNode {
|
||||
id: string
|
||||
label: 'Community'
|
||||
properties: {
|
||||
name: string
|
||||
summary: string
|
||||
member_entity_ids: string[]
|
||||
member_count: number
|
||||
core_entities: string[]
|
||||
community_id: string
|
||||
end_user_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEntityNode {
|
||||
id: string
|
||||
label: 'ExtractedEntity'
|
||||
properties: {
|
||||
name: string
|
||||
description: string
|
||||
entity_type: string
|
||||
community_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface RawCommunityGraphData {
|
||||
nodes: (RawCommunityNode | RawEntityNode)[]
|
||||
edges: RawEdge[]
|
||||
}
|
||||
|
||||
// ─── D3 graph types ───────────────────────────────────────────────────────────
|
||||
// Runtime node shape used by D3 simulations; extends SimulationNodeDatum for x/y/vx/vy.
|
||||
|
||||
export interface CommunityD3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
name: string
|
||||
community: string
|
||||
label: string
|
||||
symbolSize: number
|
||||
color: string
|
||||
properties?: RawEntityNode['properties']
|
||||
}
|
||||
|
||||
export interface D3Link extends d3.SimulationLinkDatum<CommunityD3Node> {
|
||||
isCross: boolean
|
||||
}
|
||||
|
||||
// Convex-hull shape rendered behind each community cluster.
|
||||
export interface HullDatum {
|
||||
id: string
|
||||
path: string
|
||||
color: string
|
||||
labelX: number
|
||||
labelY: number
|
||||
dashed: boolean
|
||||
caption: string
|
||||
}
|
||||
|
||||
// Fully transformed graph data ready to be passed into initCommunityGraph.
|
||||
export interface CommunityGraphData {
|
||||
nodes: CommunityD3Node[]
|
||||
links: Array<{ source: string; target: string; isCross: boolean }>
|
||||
communityMap: Map<string, string[]>
|
||||
communityCaption: Map<string, string>
|
||||
communityNodeMap: Map<string, RawCommunityNode>
|
||||
}
|
||||
|
||||
// Props accepted by the CommunityGraph React component.
|
||||
export interface CommunityGraphProps {
|
||||
data: CommunityGraphData | null
|
||||
empty?: boolean
|
||||
colors?: string[]
|
||||
renderTooltip?: (node: CommunityD3Node) => ReactNode
|
||||
showLegend?: boolean
|
||||
onCommunityClick?: (node: RawCommunityNode) => void
|
||||
onNodeClick?: (node: CommunityD3Node) => void
|
||||
defaultZoom?: number
|
||||
}
|
||||
|
||||
// Options forwarded from the React component into the D3 initializer.
|
||||
export interface InitOptions {
|
||||
colors: string[]
|
||||
showLegend: boolean
|
||||
defaultZoom: number
|
||||
setTooltip: (s: { x: number; y: number; node: CommunityD3Node } | null) => void
|
||||
onCommunityClickRef: RefObject<((node: RawCommunityNode) => void) | undefined>
|
||||
onNodeClickRef: RefObject<((node: CommunityD3Node) => void) | undefined>
|
||||
}
|
||||
547
web/src/components/D3Graph/utils.ts
Normal file
547
web/src/components/D3Graph/utils.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import * as d3 from 'd3'
|
||||
import type { CommunityD3Node, D3Link, HullDatum, CommunityGraphData, RawCommunityGraphData, RawCommunityNode, RawEntityNode, InitOptions } from './types'
|
||||
|
||||
// ─── Colors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GRAPH_COLORS = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
|
||||
export const colorAt = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length]
|
||||
|
||||
export function connectionToRadius(connections: number): number {
|
||||
if (connections <= 1) return 5
|
||||
if (connections <= 10) return 8
|
||||
if (connections <= 15) return 11
|
||||
if (connections <= 20) return 16
|
||||
return 22
|
||||
}
|
||||
|
||||
// ─── Arrow markers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function addArrowMarkers(
|
||||
defs: d3.Selection<SVGDefsElement, unknown, null, undefined>,
|
||||
markers: { id: string; color: string }[]
|
||||
) {
|
||||
markers.forEach(({ id, color }) => {
|
||||
defs.append('marker')
|
||||
.attr('id', id)
|
||||
.attr('viewBox', '0 -4 8 8')
|
||||
.attr('refX', 8).attr('refY', 0)
|
||||
.attr('markerWidth', 6).attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', color)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Zoom ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function addZoom(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
g: d3.Selection<SVGGElement, unknown, null, undefined>
|
||||
) {
|
||||
svg.call(
|
||||
d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node drag ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function makeNodeDrag<N extends d3.SimulationNodeDatum>(
|
||||
simulation: d3.Simulation<N, d3.SimulationLinkDatum<N>>
|
||||
) {
|
||||
return d3.drag<SVGGElement, N>()
|
||||
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = e.x; d.fy = e.y })
|
||||
}
|
||||
|
||||
// ─── Cluster force ────────────────────────────────────────────────────────────
|
||||
// Works for both string and number group keys.
|
||||
|
||||
export function makeClusterForce<N extends d3.SimulationNodeDatum & { x?: number; y?: number; vx?: number; vy?: number }>(
|
||||
nodes: N[],
|
||||
getGroup: (d: N) => string | number,
|
||||
centers: Record<string | number, { x: number; y: number }>,
|
||||
width: number,
|
||||
height: number,
|
||||
opts: { pullStrength?: number; minSepRatio?: number; pushStrength?: number } = {}
|
||||
) {
|
||||
const { pullStrength = 0.45, minSepRatio = 0.68, pushStrength = 1.0 } = opts
|
||||
return (alpha: number) => {
|
||||
// pre-group nodes by key to avoid repeated filter() in hot path
|
||||
const groups = new Map<string, N[]>()
|
||||
nodes.forEach(d => {
|
||||
const k = String(getGroup(d))
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k)!.push(d)
|
||||
})
|
||||
// pull toward group center
|
||||
nodes.forEach(d => {
|
||||
const c = centers[getGroup(d)]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * pullStrength * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * pullStrength * alpha
|
||||
})
|
||||
// live centroids
|
||||
const centroids: Record<string, { x: number; y: number; n: number }> = {}
|
||||
nodes.forEach(d => {
|
||||
const g = String(getGroup(d))
|
||||
if (!centroids[g]) centroids[g] = { x: 0, y: 0, n: 0 }
|
||||
centroids[g].x += d.x ?? 0
|
||||
centroids[g].y += d.y ?? 0
|
||||
centroids[g].n++
|
||||
})
|
||||
Object.values(centroids).forEach(c => { c.x /= c.n; c.y /= c.n })
|
||||
// push groups apart
|
||||
const keys = Object.keys(centroids)
|
||||
const minSep = Math.min(width, height) * minSepRatio
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
for (let j = i + 1; j < keys.length; j++) {
|
||||
const ci = centroids[keys[i]], cj = centroids[keys[j]]
|
||||
const dx = cj.x - ci.x, dy = cj.y - ci.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
if (dist >= minSep) continue
|
||||
const push = ((minSep - dist) / dist) * pushStrength * alpha
|
||||
const fx = dx * push, fy = dy * push
|
||||
groups.get(keys[i])?.forEach(d => { d.vx = (d.vx ?? 0) - fx; d.vy = (d.vy ?? 0) - fy })
|
||||
groups.get(keys[j])?.forEach(d => { d.vx = (d.vx ?? 0) + fx; d.vy = (d.vy ?? 0) + fy })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Group centers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGroupCenters(
|
||||
keys: (string | number)[],
|
||||
width: number,
|
||||
height: number,
|
||||
radiusRatio = 0.4
|
||||
): Record<string | number, { x: number; y: number }> {
|
||||
const centers: Record<string | number, { x: number; y: number }> = {}
|
||||
const r = Math.min(width, height) * radiusRatio
|
||||
keys.forEach((key, i) => {
|
||||
const angle = (i / keys.length) * 2 * Math.PI - Math.PI / 2
|
||||
centers[key] = { x: width / 2 + r * Math.cos(angle), y: height / 2 + r * Math.sin(angle) }
|
||||
})
|
||||
return centers
|
||||
}
|
||||
|
||||
// ─── Community graph data transform ─────────────────────────────────────────
|
||||
|
||||
export function buildCommunityGraphData(raw: RawCommunityGraphData, colors: string[] = GRAPH_COLORS): CommunityGraphData | null {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const communityNodes = raw.nodes.filter(n => n.label === 'Community') as RawCommunityNode[]
|
||||
const communityCaption = new Map<string, string>()
|
||||
const communityMap = new Map<string, string[]>()
|
||||
|
||||
communityNodes.forEach(n => {
|
||||
communityCaption.set(n.id, n.properties.name)
|
||||
communityMap.set(n.id, n.properties.member_entity_ids)
|
||||
})
|
||||
|
||||
const entityToCommunity = new Map<string, string>()
|
||||
communityMap.forEach((members, commId) => members.forEach(eid => entityToCommunity.set(eid, commId)))
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const commIndex = new Map(commKeys.map((k, i) => [k, i]))
|
||||
|
||||
const entityNodes = raw.nodes.filter(n => n.label === 'ExtractedEntity') as RawEntityNode[]
|
||||
const entityNodeSet = new Set(entityNodes.map(n => n.id))
|
||||
|
||||
const connectionCount: Record<string, number> = {}
|
||||
raw.edges.forEach(e => {
|
||||
if (entityNodeSet.has(e.source)) connectionCount[e.source] = (connectionCount[e.source] || 0) + 1
|
||||
if (entityNodeSet.has(e.target)) connectionCount[e.target] = (connectionCount[e.target] || 0) + 1
|
||||
})
|
||||
|
||||
const nodes: CommunityD3Node[] = entityNodes.map(n => {
|
||||
const commId = entityToCommunity.get(n.id) ?? commKeys[0]
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.properties.name,
|
||||
community: commId,
|
||||
label: n.label,
|
||||
symbolSize: connectionToRadius(connectionCount[n.id] || 0),
|
||||
color: getColor(commIndex.get(commId) ?? 0),
|
||||
properties: n.properties,
|
||||
}
|
||||
})
|
||||
|
||||
if (!nodes.length) return null
|
||||
|
||||
const links = raw.edges
|
||||
.filter(e => entityNodeSet.has(e.source) && entityNodeSet.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
isCross: entityToCommunity.get(e.source) !== entityToCommunity.get(e.target),
|
||||
}))
|
||||
|
||||
const communityNodeMap = new Map<string, RawCommunityNode>(
|
||||
communityNodes.map(n => [n.id, n])
|
||||
)
|
||||
return { nodes, links, communityMap, communityCaption, communityNodeMap }
|
||||
}
|
||||
|
||||
// ─── Hull helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const smoothLine = d3.line<[number, number]>()
|
||||
.x(d => d[0]).y(d => d[1])
|
||||
.curve(d3.curveCatmullRomClosed.alpha(0.5))
|
||||
|
||||
function expandPoints(pts: [number, number][], pad: number): [number, number][] {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
return pts.map(([x, y]) => {
|
||||
const dx = x - cx, dy = y - cy
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return [x + (dx / len) * pad, y + (dy / len) * pad]
|
||||
})
|
||||
}
|
||||
|
||||
function toHullPoints(pts: [number, number][]): [number, number][] {
|
||||
if (pts.length === 1) {
|
||||
const [x, y] = pts[0]
|
||||
return [[x - 1, y - 1], [x + 1, y - 1], [x, y + 1]]
|
||||
}
|
||||
if (pts.length === 2) {
|
||||
const [[x1, y1], [x2, y2]] = pts
|
||||
return [[x1, y1], [x2, y2], [(x1 + x2) / 2, (y1 + y2) / 2 - 1]]
|
||||
}
|
||||
return d3.polygonHull(pts) ?? pts
|
||||
}
|
||||
|
||||
const CIRCLE_THRESHOLD = 4 // 节点数 < 此值时使用圆形
|
||||
const CIRCLE_SEGMENTS = 32
|
||||
|
||||
function circlePoints(cx: number, cy: number, r: number): [number, number][] {
|
||||
return Array.from({ length: CIRCLE_SEGMENTS }, (_, i) => {
|
||||
const a = (i / CIRCLE_SEGMENTS) * 2 * Math.PI
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHullData(
|
||||
nodes: CommunityD3Node[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
colors: string[]
|
||||
): HullDatum[] {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
const byComm = new Map<string, [number, number][]>()
|
||||
communityMap.forEach((_, id) => byComm.set(id, []))
|
||||
nodes.forEach(d => {
|
||||
if (d.x != null && d.y != null) byComm.get(d.community)?.push([d.x, d.y])
|
||||
})
|
||||
|
||||
const hulls: HullDatum[] = []
|
||||
let ci = 0
|
||||
byComm.forEach((pts, id) => {
|
||||
const color = getColor(ci++)
|
||||
if (!pts.length) return
|
||||
let pathPoints: [number, number][]
|
||||
if (pts.length < CIRCLE_THRESHOLD) {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
pathPoints = circlePoints(cx, cy, 60)
|
||||
} else {
|
||||
pathPoints = expandPoints(toHullPoints(pts), 60) as [number, number][]
|
||||
}
|
||||
const path = smoothLine(pathPoints)
|
||||
if (!path) return
|
||||
hulls.push({
|
||||
id, path, color,
|
||||
labelX: pathPoints.reduce((s, p) => s + p[0], 0) / pathPoints.length,
|
||||
labelY: Math.min(...pathPoints.map(p => p[1])) - 10,
|
||||
dashed: pts.length <= 2,
|
||||
caption: communityCaption.get(id) ?? id,
|
||||
})
|
||||
})
|
||||
return hulls
|
||||
}
|
||||
|
||||
// ─── Hull render ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderHulls(
|
||||
hullG: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
hulls: HullDatum[],
|
||||
hiddenCommunities: Set<string>,
|
||||
nodes: CommunityD3Node[],
|
||||
simulation: d3.Simulation<CommunityD3Node, D3Link>,
|
||||
onCommunityClick?: (node: RawCommunityNode) => void,
|
||||
communityNodeMap?: Map<string, RawCommunityNode>
|
||||
) {
|
||||
let dragNodes: CommunityD3Node[] = []
|
||||
let dragStart = { x: 0, y: 0 }
|
||||
const communityDrag = d3.drag<SVGPathElement, HullDatum>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
dragNodes = nodes.filter(n => n.community === d.id)
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = n.x; n.fy = n.y })
|
||||
})
|
||||
.on('drag', (event) => {
|
||||
const dx = event.x - dragStart.x, dy = event.y - dragStart.y
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = (n.fx ?? n.x ?? 0) + dx; n.fy = (n.fy ?? n.y ?? 0) + dy })
|
||||
})
|
||||
.on('end', (event) => { if (!event.active) simulation.alphaTarget(0) })
|
||||
|
||||
const pathSel = hullG.selectAll<SVGPathElement, HullDatum>('path.hull').data(hulls, d => d.id)
|
||||
pathSel.enter().append('path').attr('class', 'hull').style('cursor', 'grab')
|
||||
.merge(pathSel)
|
||||
.call(communityDrag)
|
||||
.attr('d', d => d.path)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.08)
|
||||
.attr('stroke', d => d.color).attr('stroke-opacity', 0.5).attr('stroke-width', 1.5)
|
||||
.attr('stroke-dasharray', 'none')
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.on('click', (event, d) => {
|
||||
if ((event as MouseEvent).defaultPrevented) return
|
||||
const node = communityNodeMap?.get(d.id)
|
||||
if (node) onCommunityClick?.(node)
|
||||
})
|
||||
pathSel.exit().remove()
|
||||
|
||||
const labelSel = hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').data(hulls, d => d.id)
|
||||
labelSel.enter().append('text').attr('class', 'hull-label')
|
||||
.attr('text-anchor', 'middle').attr('font-size', '12px').attr('font-weight', '500')
|
||||
.style('pointer-events', 'none')
|
||||
.merge(labelSel)
|
||||
.attr('x', d => d.labelX).attr('y', d => d.labelY)
|
||||
.attr('fill', d => d.color)
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.text(d => d.caption)
|
||||
labelSel.exit().remove()
|
||||
}
|
||||
|
||||
// ─── Community graph init ─────────────────────────────────────────────────────
|
||||
|
||||
export function initCommunityGraph(
|
||||
container: HTMLDivElement,
|
||||
nodes: CommunityD3Node[],
|
||||
links: D3Link[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
communityNodeMap: Map<string, RawCommunityNode>,
|
||||
opts: InitOptions
|
||||
) {
|
||||
const { colors, showLegend, defaultZoom, setTooltip, onCommunityClickRef, onNodeClickRef } = opts
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const width = container.clientWidth || 600
|
||||
const height = container.clientHeight || 518
|
||||
|
||||
const svg = d3.select(container).append('svg')
|
||||
.attr('width', width).attr('height', height)
|
||||
.style('width', '100%').style('height', '100%')
|
||||
.style('background', '#F6F8FC')
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
svg.call(zoom)
|
||||
if (defaultZoom !== 1) {
|
||||
svg.call(zoom.transform, d3.zoomIdentity
|
||||
.translate(width / 2 * (1 - defaultZoom), height / 2 * (1 - defaultZoom))
|
||||
.scale(defaultZoom)
|
||||
)
|
||||
}
|
||||
|
||||
const defs = svg.append('defs')
|
||||
addArrowMarkers(defs, [{ id: 'arrow', color: 'rgba(91, 97, 103, 0.7)' }])
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const centers = buildGroupCenters(commKeys, width, height, 0.45)
|
||||
const linkedIds = new Set(links.flatMap(l => [l.source as string, l.target as string]))
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink<CommunityD3Node, D3Link>(links).id(d => d.id).distance(60))
|
||||
.force('charge', d3.forceManyBody().strength(-120))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide<CommunityD3Node>(d => d.symbolSize + 16))
|
||||
.force('cluster', makeClusterForce(nodes, d => d.community, centers, width, height, {
|
||||
pullStrength: 0.45, minSepRatio: 0.68, pushStrength: 1.0,
|
||||
}))
|
||||
.force('isolatedPull', (alpha: number) => {
|
||||
nodes.forEach(d => {
|
||||
if (linkedIds.has(d.id)) return
|
||||
const c = centers[d.community]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * 0.4 * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * 0.4 * alpha
|
||||
})
|
||||
})
|
||||
|
||||
const hullG = g.append('g').attr('class', 'hulls')
|
||||
const hiddenCommunities = new Set<string>()
|
||||
|
||||
const linkSel = g.append('g').selectAll<SVGLineElement, D3Link>('line')
|
||||
.data(links).enter().append('line')
|
||||
.attr('stroke', '#5B6167')
|
||||
.attr('stroke-opacity', d => d.isCross ? 0.3 : 0.5)
|
||||
.attr('stroke-width', d => d.isCross ? 1 : 1.2)
|
||||
.attr('marker-end', 'url(#arrow)')
|
||||
|
||||
const nodeSel = g.append('g').selectAll<SVGGElement, CommunityD3Node>('g')
|
||||
.data(nodes).enter().append('g')
|
||||
.call(makeNodeDrag(simulation))
|
||||
|
||||
nodeSel.append('circle')
|
||||
.attr('r', d => d.symbolSize)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.85)
|
||||
.attr('stroke', '#fff').attr('stroke-width', 1.5)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', (event: MouseEvent, d: CommunityD3Node) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: d })
|
||||
})
|
||||
.on('mousemove', (event: MouseEvent) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
const nd = d3.select<SVGCircleElement, CommunityD3Node>(event.target as SVGCircleElement).datum()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: nd })
|
||||
})
|
||||
.on('mouseleave', () => setTooltip(null))
|
||||
.on('click', (_event: MouseEvent, d: CommunityD3Node) => onNodeClickRef.current?.(d))
|
||||
|
||||
nodeSel.append('text')
|
||||
.text(d => d.name)
|
||||
.attr('x', 0).attr('dy', d => -(d.symbolSize + 5))
|
||||
.attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', '#444')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
if (showLegend) {
|
||||
renderLegend(
|
||||
svg,
|
||||
commKeys.map((cid, i) => ({ key: cid, label: communityCaption.get(cid) ?? cid, color: getColor(i) })),
|
||||
width, height,
|
||||
(key, hidden) => {
|
||||
const cid = key as string
|
||||
if (hidden) hiddenCommunities.add(cid)
|
||||
else hiddenCommunities.delete(cid)
|
||||
nodeSel.style('display', d => hiddenCommunities.has(d.community) ? 'none' : null)
|
||||
linkSel.style('display', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
return hiddenCommunities.has(s.community) || hiddenCommunities.has(t.community) ? 'none' : null
|
||||
})
|
||||
hullG.selectAll<SVGPathElement, HullDatum>('path.hull').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
simulation.on('tick', () => {
|
||||
linkSel
|
||||
.attr('x1', d => (d.source as CommunityD3Node).x ?? 0)
|
||||
.attr('y1', d => (d.source as CommunityD3Node).y ?? 0)
|
||||
.attr('x2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.x ?? 0) - (dx / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.y ?? 0) - (dy / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||||
renderHulls(hullG, buildHullData(nodes, communityMap, communityCaption, colors), hiddenCommunities, nodes, simulation, (n) => onCommunityClickRef.current?.(n), communityNodeMap)
|
||||
})
|
||||
|
||||
return () => { simulation.stop(); d3.select(container).selectAll('svg').remove() }
|
||||
}
|
||||
|
||||
// ─── Legend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LegendItem {
|
||||
key: string | number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const LEGEND_GAP = 12
|
||||
const LEGEND_RECT_W = 20
|
||||
const LEGEND_RECT_H = 10
|
||||
const LEGEND_TEXT_OFFSET = 24
|
||||
const LEGEND_FONT_SIZE = 11
|
||||
const LEGEND_ROW_H = 24
|
||||
const LEGEND_BOTTOM_PAD = 8
|
||||
|
||||
// Approximate text width using canvas measureText if available, else char-based estimate
|
||||
function measureText(text: string, fontSize: number): number {
|
||||
try {
|
||||
const ctx = document.createElement('canvas').getContext('2d')
|
||||
if (ctx) { ctx.font = `${fontSize}px sans-serif`; return ctx.measureText(text).width }
|
||||
} catch { /* noop */ }
|
||||
return text.length * fontSize * 0.6
|
||||
}
|
||||
|
||||
export function renderLegend(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
items: LegendItem[],
|
||||
width: number,
|
||||
height: number,
|
||||
onToggle: (key: string | number, hidden: boolean) => void
|
||||
) {
|
||||
// Compute per-item width: rect + text-offset + textW
|
||||
const itemWidths = items.map(item =>
|
||||
LEGEND_RECT_W + LEGEND_TEXT_OFFSET + measureText(item.label, LEGEND_FONT_SIZE)
|
||||
)
|
||||
|
||||
// Layout items into rows
|
||||
const rows: { item: LegendItem; w: number; x: number; row: number }[] = []
|
||||
let rowIdx = 0, curX = 0
|
||||
itemWidths.forEach((w, i) => {
|
||||
const slotW = w + LEGEND_GAP
|
||||
if (curX > 0 && curX + w > width - LEGEND_GAP * 2) { rowIdx++; curX = 0 }
|
||||
rows.push({ item: items[i], w, x: curX, row: rowIdx })
|
||||
curX += slotW
|
||||
})
|
||||
|
||||
const totalRows = rowIdx + 1
|
||||
const totalH = totalRows * LEGEND_ROW_H
|
||||
const baseY = height - totalH - LEGEND_BOTTOM_PAD
|
||||
|
||||
// Center each row
|
||||
const rowWidths: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(({ w, row }, i) => {
|
||||
rowWidths[row] += w + (i > 0 && rows[i - 1].row === row ? LEGEND_GAP : 0)
|
||||
})
|
||||
// Recalculate row widths properly
|
||||
const rowTotals: number[] = Array(totalRows).fill(0)
|
||||
const rowCounts: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(r => { rowCounts[r.row]++; rowTotals[r.row] += r.w })
|
||||
rowTotals.forEach((_, ri) => { rowTotals[ri] += Math.max(0, rowCounts[ri] - 1) * LEGEND_GAP })
|
||||
|
||||
const legendG = svg.append('g')
|
||||
|
||||
rows.forEach(({ item, x, row }) => {
|
||||
const rowOffsetX = (width - rowTotals[row]) / 2
|
||||
const g = legendG.append('g')
|
||||
.attr('transform', `translate(${rowOffsetX + x},${baseY + row * LEGEND_ROW_H + LEGEND_ROW_H / 2})`)
|
||||
.style('cursor', 'pointer')
|
||||
|
||||
const rect = g.append('rect')
|
||||
.attr('x', 0).attr('y', -LEGEND_RECT_H / 2)
|
||||
.attr('width', LEGEND_RECT_W).attr('height', LEGEND_RECT_H).attr('rx', 2)
|
||||
.attr('fill', item.color)
|
||||
|
||||
const text = g.append('text')
|
||||
.text(item.label)
|
||||
.attr('x', LEGEND_TEXT_OFFSET).attr('dy', '0.35em')
|
||||
.attr('font-size', `${LEGEND_FONT_SIZE}px`).attr('fill', '#5B6167')
|
||||
|
||||
let hidden = false
|
||||
g.on('click', () => {
|
||||
hidden = !hidden
|
||||
rect.attr('fill', hidden ? '#ccc' : item.color)
|
||||
text.attr('fill', hidden ? '#bbb' : '#5B6167')
|
||||
onToggle(item.key, hidden)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1482,6 +1482,33 @@ export const en = {
|
||||
memoryNum: 'memories',
|
||||
memory_config_name: 'Memory Engine',
|
||||
searchPlaceholder: 'Search memory store name',
|
||||
|
||||
communityNetwork: 'Community Graph',
|
||||
community: 'Community',
|
||||
"Person": "Person Entity Node",
|
||||
"Organization": "Organization Entity Node",
|
||||
"ORG": "Organization Entity Node",
|
||||
"Location": "Location Entity Node",
|
||||
"LOC": "Location Entity Node",
|
||||
"Event": "Event Entity Node",
|
||||
"Concept": "Concept Entity Node",
|
||||
"Time": "Time Entity Node",
|
||||
"Position": "Position Entity Node",
|
||||
"WorkRole": "Work Role Entity Node",
|
||||
"System": "System Entity Node",
|
||||
"Policy": "Policy Entity Node",
|
||||
"HistoricalPeriod": "Historical Period Entity Node",
|
||||
"HistoricalState": "Historical State Entity Node",
|
||||
"HistoricalEvent": "Historical Event Entity Node",
|
||||
"EconomicFactor": "Economic Factor Entity Node",
|
||||
"Condition": "Condition Entity Node",
|
||||
"Numeric": "Numeric Entity Node",
|
||||
"Work": "Work / Output",
|
||||
member_count: 'Member Count',
|
||||
member_count_desc: 'entities',
|
||||
summary: 'Summary',
|
||||
core_entities: 'Core Entities',
|
||||
communityDetailEmptyDesc: 'Click on a community in the chart on the left to view details',
|
||||
},
|
||||
space: {
|
||||
createSpace: 'Create Space',
|
||||
|
||||
@@ -1480,6 +1480,33 @@ export const zh = {
|
||||
memoryNum: '条记忆',
|
||||
memory_config_name: '记忆引擎',
|
||||
searchPlaceholder: '搜索记忆库名称',
|
||||
|
||||
communityNetwork: '社区图谱',
|
||||
community: '社区',
|
||||
"Person": "人物实体节点",
|
||||
"Organization": "组织实体节点",
|
||||
"ORG": "组织实体节点",
|
||||
"Location": "地点实体节点",
|
||||
"LOC": "地点实体节点",
|
||||
"Event": "事件实体节点",
|
||||
"Concept": "概念实体节点",
|
||||
"Time": "时间实体节点",
|
||||
"Position": "职位实体节点",
|
||||
"WorkRole": "职业实体节点",
|
||||
"System": "系统实体节点",
|
||||
"Policy": "政策实体节点",
|
||||
"HistoricalPeriod": "历史时期实体节点",
|
||||
"HistoricalState": "历史国家实体节点",
|
||||
"HistoricalEvent": "历史事件实体节点",
|
||||
"EconomicFactor": "经济因素实体节点",
|
||||
"Condition": "条件实体节点",
|
||||
"Numeric": "数值实体节点",
|
||||
"Work": "作品/工作成果",
|
||||
member_count: '成员数',
|
||||
member_count_desc: '个实体',
|
||||
summary: '摘要',
|
||||
core_entities: '核心实体',
|
||||
communityDetailEmptyDesc: '点击左侧图表中的社区查看详情',
|
||||
},
|
||||
space: {
|
||||
createSpace: '创建空间',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState, type FC, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CommunityD3Node, CommunityGraphData, RawCommunityGraphData, RawCommunityNode } from '@/components/D3Graph/types'
|
||||
import { buildCommunityGraphData } from '@/components/D3Graph/utils'
|
||||
import CommunityGraph from '@/components/D3Graph/CommunityGraph'
|
||||
import { getMemoryCommunityGraph } from '@/api/memory'
|
||||
|
||||
// ─── Tooltip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NodeTooltip: FC<{ node: CommunityD3Node }> = ({ node }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #DFE4ED', borderRadius: 8,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: '10px 14px',
|
||||
minWidth: 180, maxWidth: 260, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: '#1a1a1a', fontSize: 14 }}>
|
||||
{node.properties?.name ?? node.name}
|
||||
</div>
|
||||
{node.properties?.description && (
|
||||
<div style={{ color: '#5B6167', lineHeight: '20px', marginBottom: 4 }}>
|
||||
{node.properties.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.type')}:
|
||||
<span style={{ color: '#1a1a1a' }}>{t(`userMemory.${node.properties?.entity_type}`)}</span>
|
||||
</div>
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.community')}:
|
||||
<span style={{ color: node.color, fontWeight: 500 }}>{node.properties?.community_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CommunityNetwork: FC<{ onSelectCommunity?: (node: RawCommunityNode) => void }> = ({ onSelectCommunity }) => {
|
||||
const { id } = useParams()
|
||||
const [graphData, setGraphData] = useState<CommunityGraphData | null>(null)
|
||||
const [empty, setEmpty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const controller = new AbortController()
|
||||
setEmpty(false)
|
||||
setGraphData(null)
|
||||
getMemoryCommunityGraph(id, { signal: controller.signal }).then(res => {
|
||||
const raw = res as RawCommunityGraphData
|
||||
if (!raw.nodes?.length) { setEmpty(true); return }
|
||||
const built = buildCommunityGraphData(raw)
|
||||
if (!built) { setEmpty(true); return }
|
||||
setGraphData(built)
|
||||
}).catch((e) => { if (e?.code !== 'ERR_CANCELED') setEmpty(true) })
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<CommunityGraph
|
||||
data={graphData}
|
||||
empty={empty}
|
||||
showLegend={false}
|
||||
onCommunityClick={onSelectCommunity}
|
||||
renderTooltip={node => <NodeTooltip node={node} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityNetwork)
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:32:00
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 18:32:00
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 14:51:17
|
||||
*/
|
||||
/**
|
||||
* Relationship Network Component
|
||||
@@ -13,18 +13,20 @@
|
||||
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 } from 'antd'
|
||||
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']
|
||||
@@ -36,16 +38,21 @@ const RelationshipNetwork:FC = () => {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [links, setLinks] = useState<Edge[]>([])
|
||||
const [categories, setCategories] = useState<{ name: string }[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
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).then((res) => {
|
||||
getMemorySearchEdges(id, { signal: edgeAbortRef.current.signal }).then((res) => {
|
||||
const { nodes, edges, statistics } = res as GraphData
|
||||
const curNodes: Node[] = []
|
||||
const curEdges: Edge[] = []
|
||||
@@ -123,6 +130,7 @@ const RelationshipNetwork:FC = () => {
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getEdgeData()
|
||||
return () => { edgeAbortRef.current?.abort() }
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,34 +161,36 @@ const RelationshipNetwork:FC = () => {
|
||||
const params = new URLSearchParams({
|
||||
nodeId: selectedNode.id,
|
||||
nodeLabel: selectedNode.label,
|
||||
nodeName: selectedNode.name || ''
|
||||
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
|
||||
title={t('userMemory.relationshipNetwork')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:min-h-[46px]!"
|
||||
// extra={
|
||||
// <div
|
||||
// onClick={handleFullScreen}
|
||||
// className="rb:group rb:cursor-pointer rb:hover:text-[#212332] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:flex rb:items-center rb:gap-1"
|
||||
// >
|
||||
// <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/fullScreen.svg')] rb:hover:bg-[url('@/assets/images/fullScreen_hover.svg')]"></div>
|
||||
// {t('userMemory.fullScreen')}
|
||||
// </div>
|
||||
// }
|
||||
>
|
||||
<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">
|
||||
{nodes.length === 0 ? (
|
||||
<Empty className="rb:h-full" />
|
||||
) : (
|
||||
<ReactEcharts
|
||||
{activeTab === 'communityNetwork'
|
||||
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
|
||||
: nodes.length === 0
|
||||
? <Empty className="rb:h-full" />
|
||||
: <ReactEcharts
|
||||
option={{
|
||||
colors: colors,
|
||||
tooltip: {
|
||||
@@ -253,103 +263,121 @@ const RelationshipNetwork:FC = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</RbCard>
|
||||
</Col>
|
||||
{/* Memory Details */}
|
||||
<Col span={8}>
|
||||
<RbCard
|
||||
<RbCard
|
||||
title={t('userMemory.memoryDetails')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:min-h-[46px]!"
|
||||
bodyClassName='rb:p-0!'
|
||||
extra={selectedNode && <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')]"
|
||||
></div>
|
||||
{t('userMemory.completeMemory')}
|
||||
</Button>}
|
||||
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={t('userMemory.memoryDetailEmptyDesc')}
|
||||
className="rb:h-full rb:mx-10 rb:text-center"
|
||||
size={[197.81, 150]}
|
||||
/>
|
||||
: <>
|
||||
{selectedNode.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.name}</div>}
|
||||
<div className="rb:p-4">
|
||||
<>
|
||||
? <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?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
? selectedNode.properties.description
|
||||
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
||||
? selectedNode.properties.statement
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
{selectedNode?.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: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]">
|
||||
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
|
||||
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{selectedNode.label === 'Statement' && <>
|
||||
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
||||
const statementProps = selectedNode.properties as StatementNodeProperties;
|
||||
if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || typeof statementProps[key] === 'string') {
|
||||
console.log('statementProps[key]', statementProps[key])
|
||||
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>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
|
||||
: statementProps[key]
|
||||
}
|
||||
{(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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
{selectedNode.label === 'ExtractedEntity' && <>
|
||||
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
||||
const entityProps = selectedNode.properties as ExtractedEntityNodeProperties;
|
||||
if (entityProps[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(entityProps[key]) && entityProps[key].length > 0
|
||||
? entityProps[key].map((vo, index) => <div key={index}>- {vo}</div>)
|
||||
: entityProps[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 (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</RbCard>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 11:49:52
|
||||
*/
|
||||
/**
|
||||
* User Memory Detail Types
|
||||
@@ -90,6 +90,7 @@ export interface ExtractedEntityNodeProperties {
|
||||
connect_strngth: string;
|
||||
importance_score: number;
|
||||
associative_memory: number;
|
||||
community_name?: string;
|
||||
}
|
||||
/**
|
||||
* Memory summary node
|
||||
@@ -246,4 +247,53 @@ export interface ForgetData {
|
||||
*/
|
||||
export interface GraphDetailRef {
|
||||
handleOpen: (vo: Node) => void
|
||||
}
|
||||
}
|
||||
// Community
|
||||
export type CommunityNodeType = 'Community' | 'ExtractedEntity';
|
||||
export type CommunityEdgeType = 'BELONGS_TO_COMMUNITY' | 'EXTRACTED_RELATIONSHIP';
|
||||
export type CommunityEntityType = "Person" | "Organization" | "ORG" | "Location" | "LOC" | "Event" | "Concept" | "Time" | "Position" | "WorkRole" | "System" | "Policy" | "HistoricalPeriod" | "HistoricalState" | "HistoricalEvent" | "EconomicFactor" | "Condition" | "Numeric" | "Work";
|
||||
// 社区节点
|
||||
export interface CommunityTypeNode {
|
||||
id: string;
|
||||
label: 'Community';
|
||||
properties: {
|
||||
community_id: string;
|
||||
end_user_id: string;
|
||||
member_count: number;
|
||||
updated_at: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
core_entities: string[];
|
||||
member_entity_ids: string[];
|
||||
};
|
||||
}
|
||||
// 核心实体
|
||||
export interface ExtractedEntityTypeNode {
|
||||
id: string;
|
||||
label: 'ExtractedEntity';
|
||||
properties: {
|
||||
name: string;
|
||||
end_user_id: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
entity_type: CommunityEntityType;
|
||||
community_name: string;
|
||||
};
|
||||
}
|
||||
// 社区图谱连线
|
||||
export interface CommunityEdge {
|
||||
id: string;
|
||||
target: string;
|
||||
source: string;
|
||||
}
|
||||
export interface CommunityStatistics {
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
node_types: Record<CommunityNodeType, number>;
|
||||
edge_types: Record<CommunityEdgeType, number>;
|
||||
}
|
||||
export interface CommunityGraphData {
|
||||
nodes: (CommunityTypeNode | ExtractedEntityTypeNode)[];
|
||||
edges: CommunityEdge[];
|
||||
statistics: CommunityStatistics;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user