From 0c41cd2a13d2a2884001600f57e806cfb322e7e0 Mon Sep 17 00:00:00 2001 From: minecraft1024a Date: Sat, 8 Nov 2025 10:15:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(visualizer):=20=E5=BC=95=E5=85=A5=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=9B=BE=E6=8C=89=E9=9C=80=E5=8A=A0=E8=BD=BD=E5=92=8C?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=89=A9=E5=B1=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为了解决记忆图谱数据量过大导致前端加载缓慢和渲染卡顿的问题,本次更新引入了核心图按需加载和节点扩展机制。 主要变更包括: - **后端 (API):** - 新增 `/api/graph/core` 端点,该端点不再返回全量图数据,而是智能选取“度”最高的 Top N 核心节点作为初始视图,大幅减少初次加载的数据量。 - 新增 `/api/nodes/{node_id}/expand` 端点,允许前端在用户双击节点时,动态请求该节点的所有邻居节点和相关边,实现按需增量加载。 - 优化了数据加载逻辑,在内存中构建并缓存了节点字典和邻接表,以极高的效率支持节点扩展查询。 - **前端 (UI):** - 初始加载逻辑从请求 `/api/graph/full` 切换到新的 `/api/graph/core` 端点。 - 实现了双击节点触发 `expandNode` 函数的交互,调用后端接口获取并动态地将新节点和边合并到现有图中,而不是重新渲染整个图。 - 使用 `vis.DataSet` 来管理图数据,支持高效地动态添加和更新节点与边。 - 节点大小现在与其“度”(连接数)相关联,使得关键节点在视觉上更加突出。 --- src/api/memory_visualizer_router.py | 135 ++++++++++++++++++++------ src/api/templates/visualizer.html | 144 +++++++++++++++++++--------- 2 files changed, 207 insertions(+), 72 deletions(-) diff --git a/src/api/memory_visualizer_router.py b/src/api/memory_visualizer_router.py index e80e8ec0e..a60601c00 100644 --- a/src/api/memory_visualizer_router.py +++ b/src/api/memory_visualizer_router.py @@ -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]: - """从磁盘加载图数据""" + """ + 从磁盘加载图数据,并构建索引以加速查询。 + 哼,别看我代码写得多,这叫专业!一次性把事情做对,就不用返工了。 + """ global graph_data_cache, 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: available_files = find_available_data_files() if not available_files: - return {"error": "未找到数据文件", "nodes": [], "edges": [], "stats": {}} + return {"error": "未找到数据文件", "nodes": [], "edges": [], "stats": {}, "nodes_dict": {}, "adjacency_list": {}} graph_file = available_files[0] current_data_file = graph_file 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: 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", ""), "group": node.get("node_type", ""), "title": f"{node.get('node_type', '')}: {node.get('content', '')}", + "degree": 0, # 初始化度为0 } for node in nodes if node.get("id") @@ -104,26 +108,39 @@ def load_graph_data_from_file(file_path: Optional[Path] = None) -> Dict[str, Any edges_list = [] seen_edge_ids = set() + adjacency_list = {node_id: [] for node_id in nodes_dict} + for edge in edges: edge_id = edge.get("id") - if edge_id and edge_id not in seen_edge_ids: - edges_list.append( - { - **edge, - "from": edge.get("source", edge.get("source_id")), - "to": edge.get("target", edge.get("target_id")), - "label": edge.get("relation", ""), - "arrows": "to", - } - ) + source_id = edge.get("source", edge.get("source_id")) + 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, + "from": source_id, + "to": target_id, + "label": edge.get("relation", ""), + "arrows": "to", + } + edges_list.append(formatted_edge) 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", {}) total_memories = stats.get("total_memories", 0) + # 缓存所有处理过的数据,包括索引 graph_data_cache = { "nodes": list(nodes_dict.values()), "edges": edges_list, + "nodes_dict": nodes_dict, # 缓存节点字典,方便快速查找 + "adjacency_list": adjacency_list, # 缓存邻接表,光速定位邻居 "memories": [], "stats": { "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: import traceback - traceback.print_exc() raise HTTPException(status_code=500, detail=f"加载图数据失败: {e}") - @router.get("/", response_class=HTMLResponse) async def index(request: Request): """主页面""" @@ -203,29 +218,91 @@ def _format_graph_data_from_manager(memory_manager) -> Dict[str, Any]: "current_file": "memory_manager (实时数据)", } - -@router.get("/api/graph/full") -async def get_full_graph(): - """获取完整记忆图数据""" +@router.get("/api/graph/core") +async def get_core_graph(limit: int = 100): + """ + 获取核心图数据。 + 这可比一下子把所有东西都丢给前端聪明多了,哼。 + """ 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", []) + + # 按度(degree)降序排序,如果度相同,则按创建时间(如果可用)降序 + sorted_nodes = sorted( + all_nodes, + key=lambda n: (n.get("degree", 0), n.get("created_at", 0)), + reverse=True + ) + + core_nodes = sorted_nodes[:limit] + core_node_ids = {node["id"] for node in core_nodes} - data = {} - if memory_manager and memory_manager._initialized: - data = _format_graph_data_from_manager(memory_manager) - else: - # 如果内存管理器不可用,则从文件加载 - data = load_graph_data_from_file() + # 只包含核心节点之间的边,保持初始视图的整洁 + 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}) + return JSONResponse(content={"success": True, "data": data_to_send}) except Exception as e: import traceback - traceback.print_exc() 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") async def list_files_api(): diff --git a/src/api/templates/visualizer.html b/src/api/templates/visualizer.html index 47c105863..6a18d3a77 100644 --- a/src/api/templates/visualizer.html +++ b/src/api/templates/visualizer.html @@ -532,12 +532,17 @@