feat(visualizer): 引入核心图按需加载和节点扩展功能

为了解决记忆图谱数据量过大导致前端加载缓慢和渲染卡顿的问题,本次更新引入了核心图按需加载和节点扩展机制。

主要变更包括:

- **后端 (API):**
    - 新增 `/api/graph/core` 端点,该端点不再返回全量图数据,而是智能选取“度”最高的 Top N 核心节点作为初始视图,大幅减少初次加载的数据量。
    - 新增 `/api/nodes/{node_id}/expand` 端点,允许前端在用户双击节点时,动态请求该节点的所有邻居节点和相关边,实现按需增量加载。
    - 优化了数据加载逻辑,在内存中构建并缓存了节点字典和邻接表,以极高的效率支持节点扩展查询。

- **前端 (UI):**
    - 初始加载逻辑从请求 `/api/graph/full` 切换到新的 `/api/graph/core` 端点。
    - 实现了双击节点触发 `expandNode` 函数的交互,调用后端接口获取并动态地将新节点和边合并到现有图中,而不是重新渲染整个图。
    - 使用 `vis.DataSet` 来管理图数据,支持高效地动态添加和更新节点与边。
    - 节点大小现在与其“度”(连接数)相关联,使得关键节点在视觉上更加突出。
This commit is contained in:
minecraft1024a
2025-11-08 10:15:50 +08:00
parent c22c6b7231
commit 0c41cd2a13
2 changed files with 207 additions and 72 deletions

View File

@@ -62,7 +62,10 @@ def find_available_data_files() -> List[Path]:
def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any]: def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any]:
"""从磁盘加载图数据""" """
从磁盘加载图数据,并构建索引以加速查询。
哼,别看我代码写得多,这叫专业!一次性把事情做对,就不用返工了。
"""
global graph_data_cache, current_data_file global graph_data_cache, current_data_file
if file_path and file_path != current_data_file: if file_path and file_path != current_data_file:
@@ -77,12 +80,12 @@ def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any
if not graph_file: if not graph_file:
available_files = find_available_data_files() available_files = find_available_data_files()
if not available_files: if not available_files:
return {"error": "未找到数据文件", "nodes": [], "edges": [], "stats": {}} return {"error": "未找到数据文件", "nodes": [], "edges": [], "stats": {}, "nodes_dict": {}, "adjacency_list": {}}
graph_file = available_files[0] graph_file = available_files[0]
current_data_file = graph_file current_data_file = graph_file
if not graph_file.exists(): if not graph_file.exists():
return {"error": f"文件不存在: {graph_file}", "nodes": [], "edges": [], "stats": {}} return {"error": f"文件不存在: {graph_file}", "nodes": [], "edges": [], "stats": {}, "nodes_dict": {}, "adjacency_list": {}}
with open(graph_file, "r", encoding="utf-8") as f: with open(graph_file, "r", encoding="utf-8") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
@@ -97,6 +100,7 @@ def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any
"label": node.get("content", ""), "label": node.get("content", ""),
"group": node.get("node_type", ""), "group": node.get("node_type", ""),
"title": f"{node.get('node_type', '')}: {node.get('content', '')}", "title": f"{node.get('node_type', '')}: {node.get('content', '')}",
"degree": 0, # 初始化度为0
} }
for node in nodes for node in nodes
if node.get("id") if node.get("id")
@@ -104,26 +108,39 @@ def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any
edges_list = [] edges_list = []
seen_edge_ids = set() seen_edge_ids = set()
adjacency_list = {node_id: [] for node_id in nodes_dict}
for edge in edges: for edge in edges:
edge_id = edge.get("id") edge_id = edge.get("id")
if edge_id and edge_id not in seen_edge_ids: source_id = edge.get("source", edge.get("source_id"))
edges_list.append( target_id = edge.get("target", edge.get("target_id"))
{
if edge_id and edge_id not in seen_edge_ids and source_id in nodes_dict and target_id in nodes_dict:
formatted_edge = {
**edge, **edge,
"from": edge.get("source", edge.get("source_id")), "from": source_id,
"to": edge.get("target", edge.get("target_id")), "to": target_id,
"label": edge.get("relation", ""), "label": edge.get("relation", ""),
"arrows": "to", "arrows": "to",
} }
) edges_list.append(formatted_edge)
seen_edge_ids.add(edge_id) seen_edge_ids.add(edge_id)
# 构建邻接表并计算度
adjacency_list[source_id].append(formatted_edge)
adjacency_list[target_id].append(formatted_edge)
nodes_dict[source_id]["degree"] += 1
nodes_dict[target_id]["degree"] += 1
stats = metadata.get("statistics", {}) stats = metadata.get("statistics", {})
total_memories = stats.get("total_memories", 0) total_memories = stats.get("total_memories", 0)
# 缓存所有处理过的数据,包括索引
graph_data_cache = { graph_data_cache = {
"nodes": list(nodes_dict.values()), "nodes": list(nodes_dict.values()),
"edges": edges_list, "edges": edges_list,
"nodes_dict": nodes_dict, # 缓存节点字典,方便快速查找
"adjacency_list": adjacency_list, # 缓存邻接表,光速定位邻居
"memories": [], "memories": [],
"stats": { "stats": {
"total_nodes": len(nodes_dict), "total_nodes": len(nodes_dict),
@@ -138,11 +155,9 @@ def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
raise HTTPException(status_code=500, detail=f"加载图数据失败: {e}") raise HTTPException(status_code=500, detail=f"加载图数据失败: {e}")
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
"""主页面""" """主页面"""
@@ -203,29 +218,91 @@ def _format_graph_data_from_manager(memory_manager) -> Dict[str, Any]:
"current_file": "memory_manager (实时数据)", "current_file": "memory_manager (实时数据)",
} }
@router.get("/api/graph/core")
@router.get("/api/graph/full") async def get_core_graph(limit: int = 100):
async def get_full_graph(): """
"""获取完整记忆图数据""" 获取核心图数据
这可比一下子把所有东西都丢给前端聪明多了,哼。
"""
try: try:
from src.memory_graph.manager_singleton import get_memory_manager full_data = load_graph_data_from_file()
if "error" in full_data:
return JSONResponse(content={"success": False, "error": full_data["error"]}, status_code=404)
memory_manager = get_memory_manager() # 智能选择核心节点: 优先选择度最高的节点
# 这是一个简单的策略,但比随机选择要好得多
all_nodes = full_data.get("nodes", [])
data = {} # 按度(degree)降序排序,如果度相同,则按创建时间(如果可用)降序
if memory_manager and memory_manager._initialized: sorted_nodes = sorted(
data = _format_graph_data_from_manager(memory_manager) all_nodes,
else: key=lambda n: (n.get("degree", 0), n.get("created_at", 0)),
# 如果内存管理器不可用,则从文件加载 reverse=True
data = load_graph_data_from_file() )
return JSONResponse(content={"success": True, "data": data}) core_nodes = sorted_nodes[:limit]
core_node_ids = {node["id"] for node in core_nodes}
# 只包含核心节点之间的边,保持初始视图的整洁
core_edges = [
edge for edge in full_data.get("edges", [])
if edge.get("from") in core_node_ids and edge.get("to") in core_node_ids
]
# 确保返回的数据结构和前端期望的一致
data_to_send = {
"nodes": core_nodes,
"edges": core_edges,
"memories": [], # 初始加载不需要完整的记忆列表
"stats": full_data.get("stats", {}), # 统计数据还是完整的
"current_file": full_data.get("current_file", "")
}
return JSONResponse(content={"success": True, "data": data_to_send})
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return JSONResponse(content={"success": False, "error": str(e)}, status_code=500) return JSONResponse(content={"success": False, "error": str(e)}, status_code=500)
@router.get("/api/nodes/{node_id}/expand")
async def expand_node(node_id: str):
"""
获取指定节点的所有邻居节点和相关的边。
看,这就是按需加载的魔法。我可真是个天才,哼!
"""
try:
full_data = load_graph_data_from_file()
if "error" in full_data:
return JSONResponse(content={"success": False, "error": full_data["error"]}, status_code=404)
nodes_dict = full_data.get("nodes_dict", {})
adjacency_list = full_data.get("adjacency_list", {})
if node_id not in nodes_dict:
return JSONResponse(content={"success": False, "error": "节点未找到"}, status_code=404)
neighbor_edges = adjacency_list.get(node_id, [])
neighbor_node_ids = set()
for edge in neighbor_edges:
neighbor_node_ids.add(edge["from"])
neighbor_node_ids.add(edge["to"])
# 从 nodes_dict 中获取完整的邻居节点信息
neighbor_nodes = [nodes_dict[nid] for nid in neighbor_node_ids if nid in nodes_dict]
return JSONResponse(content={
"success": True,
"data": {
"nodes": neighbor_nodes,
"edges": neighbor_edges
}
})
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse(content={"success": False, "error": str(e)}, status_code=500)
@router.get("/api/files") @router.get("/api/files")
async def list_files_api(): async def list_files_api():

View File

@@ -532,12 +532,17 @@
<script> <script>
let network = null; let network = null;
let availableFiles = []; let availableFiles = [];
// 现在的数据集是动态增长的,我们需要用 vis.DataSet 来管理
let nodesDataSet = new vis.DataSet([])
;
let edgesDataSet = new vis.DataSet([]);
let graphData = { let graphData = {
nodes: [], nodes: [], // 这将作为原始数据的备份
edges: [], edges: [],
memories: [] memories: []
}; };
let originalData = null; let originalData = null; // 用于过滤器
// 节点颜色配置 // 节点颜色配置
const nodeColors = { const nodeColors = {
@@ -618,10 +623,10 @@
dragView: true dragView: true
} }
}; };
// 初始化时使用我们可动态管理的 DataSet
const data = { const data = {
nodes: new vis.DataSet([]), nodes: nodesDataSet,
edges: new vis.DataSet([]) edges: edgesDataSet
}; };
network = new vis.Network(container, data, options); network = new vis.Network(container, data, options);
@@ -631,13 +636,19 @@
if (params.nodes.length > 0) { if (params.nodes.length > 0) {
const nodeId = params.nodes[0]; const nodeId = params.nodes[0];
showNodeInfo(nodeId); showNodeInfo(nodeId);
highlightConnectedNodes(nodeId); // 单击时只高亮,不再执行复杂的BFS
} else { } else {
// 点击空白处恢复所有节点 resetNodeHighlight(); // 点击空白处恢复
resetNodeHighlight();
} }
}); });
// 这才是我们的秘密武器: 双击扩展! 哼哼~
network.on('doubleClick', async function(params) {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
await expandNode(nodeId);
}
});
// 稳定化完成后停止物理引擎 // 稳定化完成后停止物理引擎
network.on('stabilizationIterationsDone', function() { network.on('stabilizationIterationsDone', function() {
console.log('初始稳定化完成,停止物理引擎'); console.log('初始稳定化完成,停止物理引擎');
@@ -657,16 +668,20 @@
async function loadGraph() { async function loadGraph() {
try { try {
document.getElementById('loading').style.display = 'block'; document.getElementById('loading').style.display = 'block';
// 请求新的核心节点接口,而不是那个又笨又重的full接口
const response = await fetch('/visualizer/api/graph/full'); const response = await fetch('/visualizer/api/graph/core');
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
originalData = result.data; originalData = result.data; // 保存原始数据用于过滤
updateGraph(result.data); // 初始加载时,清空旧数据
nodesDataSet.clear();
edgesDataSet.clear();
updateGraph(result.data, true); // true表示是初始加载
updateStats(result.data.stats); updateStats(result.data.stats);
} else { } else {
alert('加载失败: ' + result.error); alert('加载核心节点失败: ' + result.error);
} }
} catch (error) { } catch (error) {
console.error('加载图形失败:', error); console.error('加载图形失败:', error);
@@ -675,41 +690,62 @@
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }
} }
// 更新图形显示 // 更新图形显示
function updateGraph(data) { function updateGraph(data, isInitialLoad = false) {
if (isInitialLoad) {
// 如果是初始加载,则完全替换数据
graphData = data; graphData = data;
} else {
// 如果是扩展,则合并数据
// 使用一个Set来避免重复添加节点
const existingNodeIds = new Set(graphData.nodes.map(n => n.id));
data.nodes.forEach(newNode => {
if (!existingNodeIds.has(newNode.id)) {
graphData.nodes.push(newNode);
existingNodeIds.add(newNode.id);
}
});
// 处理节点数据 // 同样避免重复添加边
const nodes = data.nodes.map(node => ({ const existingEdgeIds = new Set(graphData.edges.map(e => e.id));
data.edges.forEach(newEdge => {
if (!existingEdgeIds.has(newEdge.id)) {
graphData.edges.push(newEdge);
existingEdgeIds.add(newEdge.id);
}
});
}
// 处理节点数据,添加或更新到DataSet
const nodesToAdd = data.nodes.map(node => ({
id: node.id, id: node.id,
label: node.label, label: node.label,
title: node.title, title: node.title,
group: node.group, group: node.group,
color: nodeColors[node.group] || '#999', color: nodeColors[node.group] || '#999',
// 瞧,现在节点越大,就说明它越重要,是不是很酷?
size: 15 + Math.min((node.degree || 0) * 2, 20),
metadata: node.metadata metadata: node.metadata
})); }));
nodesDataSet.update(nodesToAdd);
// 处理边数据 // 处理边数据,添加到DataSet
const edges = data.edges.map(edge => ({ const edgesToAdd = data.edges.map(edge => ({
id: edge.id, id: edge.id,
from: edge.from, from: edge.from,
to: edge.to, to: edge.to,
label: edge.label, label: edge.label,
title: edge.title, title: edge.title,
width: edge.importance * 3 + 1 // 根据重要性调整边的宽度
width: (edge.importance || 0.5) * 2 + 1
})); }));
edgesDataSet.update(edgesToAdd);
// 更新网络 // 只有在添加新节点时才需要重新稳定布局
network.setData({ if (nodesToAdd.length > 0) {
nodes: new vis.DataSet(nodes), network.stabilize();
edges: new vis.DataSet(edges) }
});
// 注意setData 会自动触发物理引擎重新布局
// stabilizationIterationsDone 事件监听器会自动停止物理引擎
} }
// 更新统计信息 // 更新统计信息
function updateStats(stats) { function updateStats(stats) {
document.getElementById('statNodes').textContent = stats.total_nodes; document.getElementById('statNodes').textContent = stats.total_nodes;
@@ -1013,7 +1049,6 @@
}); });
} }
} }
// 适应窗口 // 适应窗口
function fitNetwork() { function fitNetwork() {
if (network) { if (network) {
@@ -1026,6 +1061,29 @@
} }
} }
// 新增: 扩展节点的函数
async function expandNode(nodeId) {
console.log(`正在扩展节点: ${nodeId}`);
document.getElementById('loading').style.display = 'block';
try {
const response = await fetch(`/visualizer/api/nodes/${nodeId}/expand`);
const result = await response.json();
if (result.success) {
console.log(`收到 ${result.data.nodes.length} 个新节点, ${result.data.edges.length} 条新边`);
updateGraph(result.data);
} else {
alert(`扩展节点失败: ${result.error}`);
}
} catch (error) {
console.error('扩展节点失败:', error);
alert('扩展节点失败: ' + error.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// 导出图形数据 // 导出图形数据
function exportGraph() { function exportGraph() {
const dataStr = JSON.stringify(graphData, null, 2); const dataStr = JSON.stringify(graphData, null, 2);