From fd7e77eff8c6000c0d44193269dda547067ed9d2 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 13 Mar 2026 15:17:06 +0800 Subject: [PATCH] feat(web): add community network --- web/package.json | 2 + web/src/api/memory.ts | 11 +- web/src/components/D3Graph/CommunityGraph.tsx | 67 +++ web/src/components/D3Graph/hooks.ts | 24 + web/src/components/D3Graph/types.ts | 102 ++++ web/src/components/D3Graph/utils.ts | 547 ++++++++++++++++++ web/src/i18n/en.ts | 27 + web/src/i18n/zh.ts | 27 + .../components/CommunityNetwork.tsx | 72 +++ .../components/RelationshipNetwork.tsx | 222 +++---- web/src/views/UserMemoryDetail/types.ts | 56 +- 11 files changed, 1054 insertions(+), 103 deletions(-) create mode 100644 web/src/components/D3Graph/CommunityGraph.tsx create mode 100644 web/src/components/D3Graph/hooks.ts create mode 100644 web/src/components/D3Graph/types.ts create mode 100644 web/src/components/D3Graph/utils.ts create mode 100644 web/src/views/UserMemoryDetail/components/CommunityNetwork.tsx diff --git a/web/package.json b/web/package.json index e2d5c898..2799a631 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 491f78ea..b8bfac32 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -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) => { diff --git a/web/src/components/D3Graph/CommunityGraph.tsx b/web/src/components/D3Graph/CommunityGraph.tsx new file mode 100644 index 00000000..549d69f3 --- /dev/null +++ b/web/src/components/D3Graph/CommunityGraph.tsx @@ -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 = ({ + 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 + return ( +
+
+ {tooltipNode ? ( +
+ {tooltipNode} +
+ ) : undefined} +
+ ) +} + +export default React.memo(CommunityGraph) diff --git a/web/src/components/D3Graph/hooks.ts b/web/src/components/D3Graph/hooks.ts new file mode 100644 index 00000000..93355718 --- /dev/null +++ b/web/src/components/D3Graph/hooks.ts @@ -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( + initFn: (container: HTMLDivElement) => (() => void) | void, + deps: T[] +) { + const containerRef = useRef(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 +} diff --git a/web/src/components/D3Graph/types.ts b/web/src/components/D3Graph/types.ts new file mode 100644 index 00000000..88bbbfeb --- /dev/null +++ b/web/src/components/D3Graph/types.ts @@ -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 { + 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 + communityCaption: Map + communityNodeMap: Map +} + +// 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> +} diff --git a/web/src/components/D3Graph/utils.ts b/web/src/components/D3Graph/utils.ts new file mode 100644 index 00000000..87d888ac --- /dev/null +++ b/web/src/components/D3Graph/utils.ts @@ -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, + 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, + g: d3.Selection +) { + svg.call( + d3.zoom().scaleExtent([0.2, 4]) + .on('zoom', e => g.attr('transform', e.transform)) + ) +} + +// ─── Node drag ──────────────────────────────────────────────────────────────── + +export function makeNodeDrag( + simulation: d3.Simulation> +) { + return d3.drag() + .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( + nodes: N[], + getGroup: (d: N) => string | number, + centers: Record, + 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() + 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 = {} + 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 { + const centers: Record = {} + 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() + const communityMap = new Map() + + communityNodes.forEach(n => { + communityCaption.set(n.id, n.properties.name) + communityMap.set(n.id, n.properties.member_entity_ids) + }) + + const entityToCommunity = new Map() + 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 = {} + 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( + 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, + communityCaption: Map, + colors: string[] +): HullDatum[] { + const getColor = (i: number) => colors[i % colors.length] + const byComm = new Map() + 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, + hulls: HullDatum[], + hiddenCommunities: Set, + nodes: CommunityD3Node[], + simulation: d3.Simulation, + onCommunityClick?: (node: RawCommunityNode) => void, + communityNodeMap?: Map +) { + let dragNodes: CommunityD3Node[] = [] + let dragStart = { x: 0, y: 0 } + const communityDrag = d3.drag() + .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('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('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, + communityCaption: Map, + communityNodeMap: Map, + 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() + .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(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(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() + + const linkSel = g.append('g').selectAll('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('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(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('path.hull').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null) + hullG.selectAll('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, + 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) + }) + }) +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 62f404aa..baeb5848 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 387c67c3..e8aa7e5a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: '创建空间', diff --git a/web/src/views/UserMemoryDetail/components/CommunityNetwork.tsx b/web/src/views/UserMemoryDetail/components/CommunityNetwork.tsx new file mode 100644 index 00000000..2757498d --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/CommunityNetwork.tsx @@ -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 ( +
+
+ {node.properties?.name ?? node.name} +
+ {node.properties?.description && ( +
+ {node.properties.description} +
+ )} +
+ {t('userMemory.type')}: + {t(`userMemory.${node.properties?.entity_type}`)} +
+
+ {t('userMemory.community')}: + {node.properties?.community_name} +
+
+ ) +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const CommunityNetwork: FC<{ onSelectCommunity?: (node: RawCommunityNode) => void }> = ({ onSelectCommunity }) => { + const { id } = useParams() + const [graphData, setGraphData] = useState(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 ( + } + /> + ) +} + +export default React.memo(CommunityNetwork) diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index aa8b9d7c..66e37a45 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -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([]) const [links, setLinks] = useState([]) const [categories, setCategories] = useState<{ name: string }[]>([]) - const [selectedNode, setSelectedNode] = useState(null) + 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).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 ( {/* Relationship Network */} - - //
- // {t('userMemory.fullScreen')} - //
- // } - > + + ({ key, label: t(`userMemory.${key}`) }))} + activeKey={activeTab} + onChange={handleChangeTab} + />
- {nodes.length === 0 ? ( - - ) : ( - setSelectedNode(community)} /> + : nodes.length === 0 + ? + : { } }} /> - )} + }
{/* Memory Details */} - -
- {t('userMemory.completeMemory')} - } + bodyClassName="rb:p-0!" + extra={selectedNode && !(selectedNode as RawCommunityNode).properties.community_id && ( + + )} >
{!selectedNode - ? - : <> - {selectedNode.name &&
{selectedNode.name}
} -
- <> + ? + : (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?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')} + ? selectedNode.properties.description + : selectedNode.label === 'Statement' && 'statement' in selectedNode.properties + ? selectedNode.properties.statement + : ''}
- {selectedNode?.properties.associative_memory > 0 &&
-
{t('userMemory.associative_memory')}
+
+
{t('userMemory.created_at')}
- {selectedNode?.properties.associative_memory} {t('userMemory.unix')}{t('userMemory.associative_memory')} + {dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
-
} - {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 ( -
- {t(`userMemory.Statement_${key}`)} -
- {key === 'emotion_keywords' - ? {statementProps.emotion_keywords.map((vo, index) => {vo})} - : statementProps[key] - } + {(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 entityProps = selectedNode.properties as ExtractedEntityNodeProperties; - if (entityProps[key]) { - return ( -
- {t(`userMemory.ExtractedEntity_${key}`)} -
- {Array.isArray(entityProps[key]) && entityProps[key].length > 0 - ? entityProps[key].map((vo, index) =>
- {vo}
) - : 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 ( +
+ {t(`userMemory.ExtractedEntity_${key}`)} +
+ {Array.isArray(p[key]) && p[key].length > 0 + ? p[key].map((v, i) =>
- {v}
) + : p[key]} +
-
- ) - } - return null - })} - } + ) + } + return null + }) + )} +
-
- + }
diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index 8333cb2c..72e896ad 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -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 -} \ No newline at end of file +} +// 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; + edge_types: Record; +} +export interface CommunityGraphData { + nodes: (CommunityTypeNode | ExtractedEntityTypeNode)[]; + edges: CommunityEdge[]; + statistics: CommunityStatistics; +}