""" 记忆图可视化 - 独立版本 直接从存储的数据文件生成可视化,无需启动完整的记忆管理器 """ import sys from datetime import datetime from pathlib import Path from typing import Any import orjson # 添加项目根目录 project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) from flask import Flask, jsonify, render_template_string, request from flask_cors import CORS app = Flask(__name__) CORS(app) # 数据缓存 graph_data_cache = None data_dir = project_root / "data" / "memory_graph" current_data_file = None # 当前选择的数据文件 def find_available_data_files() -> list[Path]: """查找所有可用的记忆图数据文件""" files = [] if not data_dir.exists(): return files # 查找多种可能的文件名 possible_files = [ "graph_store.json", "memory_graph.json", "graph_data.json", ] for filename in possible_files: file_path = data_dir / filename if file_path.exists(): files.append(file_path) # 查找所有备份文件 for pattern in ["graph_store_*.json", "memory_graph_*.json", "graph_data_*.json"]: for backup_file in data_dir.glob(pattern): if backup_file not in files: files.append(backup_file) # 查找backups子目录 backups_dir = data_dir / "backups" if backups_dir.exists(): for backup_file in backups_dir.glob("**/*.json"): if backup_file not in files: files.append(backup_file) # 查找data/backup目录 backup_dir = data_dir.parent / "backup" if backup_dir.exists(): for backup_file in backup_dir.glob("**/graph_*.json"): if backup_file not in files: files.append(backup_file) for backup_file in backup_dir.glob("**/memory_*.json"): if backup_file not in files: files.append(backup_file) return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True) def load_graph_data(file_path: Path | None = None) -> dict[str, Any]: """从磁盘加载图数据""" global graph_data_cache, current_data_file # 如果指定了新文件,清除缓存 if file_path is not None and file_path != current_data_file: graph_data_cache = None current_data_file = file_path if graph_data_cache is not None: return graph_data_cache try: # 确定要加载的文件 if current_data_file is not None: graph_file = current_data_file else: # 尝试查找可用的数据文件 available_files = find_available_data_files() if not available_files: print("⚠️ 未找到任何图数据文件") print(f"📂 搜索目录: {data_dir}") return { "nodes": [], "edges": [], "memories": [], "stats": {"total_nodes": 0, "total_edges": 0, "total_memories": 0}, "error": "未找到数据文件", "available_files": [] } # 使用最新的文件 graph_file = available_files[0] current_data_file = graph_file print(f"📂 自动选择最新文件: {graph_file}") if not graph_file.exists(): print(f"⚠️ 图数据文件不存在: {graph_file}") return { "nodes": [], "edges": [], "memories": [], "stats": {"total_nodes": 0, "total_edges": 0, "total_memories": 0}, "error": f"文件不存在: {graph_file}" } print(f"📂 加载图数据: {graph_file}") with open(graph_file, encoding="utf-8") as f: data = orjson.loads(f.read()) # 解析数据 nodes_dict = {} edges_list = [] memory_info = [] # 实际文件格式是 {nodes: [], edges: [], metadata: {}} # 不是 {memories: [{nodes: [], edges: []}]} nodes = data.get("nodes", []) edges = data.get("edges", []) metadata = data.get("metadata", {}) print(f"✅ 找到 {len(nodes)} 个节点, {len(edges)} 条边") # 处理节点 for node in nodes: node_id = node.get("id", "") if node_id and node_id not in nodes_dict: memory_ids = node.get("metadata", {}).get("memory_ids", []) nodes_dict[node_id] = { "id": node_id, "label": node.get("content", ""), "type": node.get("node_type", ""), "group": extract_group_from_type(node.get("node_type", "")), "title": f"{node.get('node_type', '')}: {node.get('content', '')}", "metadata": node.get("metadata", {}), "created_at": node.get("created_at", ""), "memory_ids": memory_ids, } # 处理边 - 使用集合去重,避免重复的边ID existing_edge_ids = set() for edge in edges: # 边的ID字段可能是 'id' 或 'edge_id' edge_id = edge.get("edge_id") or edge.get("id", "") # 如果ID为空或已存在,跳过这条边 if not edge_id or edge_id in existing_edge_ids: continue existing_edge_ids.add(edge_id) memory_id = edge.get("metadata", {}).get("memory_id", "") # 注意: GraphStore 保存的格式使用 'source'/'target', 不是 'source_id'/'target_id' edges_list.append({ "id": edge_id, "from": edge.get("source", edge.get("source_id", "")), "to": edge.get("target", edge.get("target_id", "")), "label": edge.get("relation", ""), "type": edge.get("edge_type", ""), "importance": edge.get("importance", 0.5), "title": f"{edge.get('edge_type', '')}: {edge.get('relation', '')}", "arrows": "to", "memory_id": memory_id, }) # 从元数据中获取统计信息 stats = metadata.get("statistics", {}) total_memories = stats.get("total_memories", 0) # TODO: 如果需要记忆详细信息,需要从其他地方加载 # 目前只有节点和边的数据 graph_data_cache = { "nodes": list(nodes_dict.values()), "edges": edges_list, "memories": memory_info, # 空列表,因为文件中没有记忆详情 "stats": { "total_nodes": len(nodes_dict), "total_edges": len(edges_list), "total_memories": total_memories, }, "current_file": str(graph_file), "file_size": graph_file.stat().st_size, "file_modified": datetime.fromtimestamp(graph_file.stat().st_mtime).isoformat(), } print(f"📊 统计: {len(nodes_dict)} 个节点, {len(edges_list)} 条边, {total_memories} 条记忆") print(f"📄 数据文件: {graph_file} ({graph_file.stat().st_size / 1024:.2f} KB)") return graph_data_cache except Exception as e: print(f"❌ 加载失败: {e}") import traceback traceback.print_exc() return {"nodes": [], "edges": [], "memories": [], "stats": {}} def extract_group_from_type(node_type: str) -> str: """从节点类型提取分组名""" # 假设类型格式为 "主体" 或 "SUBJECT" type_mapping = { "主体": "SUBJECT", "主题": "TOPIC", "客体": "OBJECT", "属性": "ATTRIBUTE", "值": "VALUE", } return type_mapping.get(node_type, node_type) def generate_memory_text(memory: dict[str, Any]) -> str: """生成记忆的文本描述""" try: nodes = {n["id"]: n for n in memory.get("nodes", [])} edges = memory.get("edges", []) subject_id = memory.get("subject_id", "") if not subject_id or subject_id not in nodes: return f"[记忆 {memory.get('id', '')[:8]}]" parts = [nodes[subject_id]["content"]] # 找主题节点 for edge in edges: if edge.get("edge_type") == "记忆类型" and edge.get("source_id") == subject_id: topic_id = edge.get("target_id", "") if topic_id in nodes: parts.append(nodes[topic_id]["content"]) # 找客体 for e2 in edges: if e2.get("edge_type") == "核心关系" and e2.get("source_id") == topic_id: obj_id = e2.get("target_id", "") if obj_id in nodes: parts.append(f"{e2.get('relation', '')} {nodes[obj_id]['content']}") break break return " ".join(parts) except Exception: return f"[记忆 {memory.get('id', '')[:8]}]" # 使用内嵌的HTML模板(与之前相同) HTML_TEMPLATE = open(project_root / "tools" / "memory_visualizer" / "templates" / "visualizer.html", encoding="utf-8").read() @app.route("/") def index(): """主页面""" return render_template_string(HTML_TEMPLATE) @app.route("/api/graph/full") def get_full_graph(): """获取完整记忆图数据""" try: data = load_graph_data() return jsonify({ "success": True, "data": data }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 @app.route("/api/memory/") def get_memory_detail(memory_id: str): """获取记忆详情""" try: data = load_graph_data() memory = next((m for m in data["memories"] if m["id"] == memory_id), None) if memory is None: return jsonify({ "success": False, "error": "记忆不存在" }), 404 return jsonify({ "success": True, "data": memory }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 @app.route("/api/search") def search_memories(): """搜索记忆""" try: query = request.args.get("q", "").lower() limit = int(request.args.get("limit", 50)) data = load_graph_data() # 简单的文本匹配搜索 results = [] for memory in data["memories"]: text = memory.get("text", "").lower() if query in text: results.append(memory) return jsonify({ "success": True, "data": { "results": results[:limit], "count": len(results), } }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 @app.route("/api/stats") def get_statistics(): """获取统计信息""" try: data = load_graph_data() # 扩展统计信息 node_types = {} memory_types = {} for node in data["nodes"]: node_type = node.get("type", "Unknown") node_types[node_type] = node_types.get(node_type, 0) + 1 for memory in data["memories"]: mem_type = memory.get("type", "Unknown") memory_types[mem_type] = memory_types.get(mem_type, 0) + 1 stats = data.get("stats", {}) stats["node_types"] = node_types stats["memory_types"] = memory_types return jsonify({ "success": True, "data": stats }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 @app.route("/api/reload") def reload_data(): """重新加载数据""" global graph_data_cache graph_data_cache = None data = load_graph_data() return jsonify({ "success": True, "message": "数据已重新加载", "stats": data.get("stats", {}) }) @app.route("/api/files") def list_files(): """列出所有可用的数据文件""" try: files = find_available_data_files() file_list = [] for f in files: stat = f.stat() file_list.append({ "path": str(f), "name": f.name, "size": stat.st_size, "size_kb": round(stat.st_size / 1024, 2), "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "modified_readable": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "is_current": str(f) == str(current_data_file) if current_data_file else False }) return jsonify({ "success": True, "files": file_list, "count": len(file_list), "current_file": str(current_data_file) if current_data_file else None }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 @app.route("/api/select_file", methods=["POST"]) def select_file(): """选择要加载的数据文件""" global graph_data_cache, current_data_file try: data = request.get_json() file_path = data.get("file_path") if not file_path: return jsonify({ "success": False, "error": "未提供文件路径" }), 400 file_path = Path(file_path) if not file_path.exists(): return jsonify({ "success": False, "error": f"文件不存在: {file_path}" }), 404 # 清除缓存并加载新文件 graph_data_cache = None current_data_file = file_path graph_data = load_graph_data(file_path) return jsonify({ "success": True, "message": f"已切换到文件: {file_path.name}", "stats": graph_data.get("stats", {}) }) except Exception as e: return jsonify({ "success": False, "error": str(e) }), 500 def run_server(host: str = "127.0.0.1", port: int = 5001, debug: bool = False): """启动服务器""" print("=" * 60) print("🦊 MoFox Bot - 记忆图可视化工具 (独立版)") print("=" * 60) print(f"📂 数据目录: {data_dir}") print(f"🌐 访问地址: http://{host}:{port}") print("⏹️ 按 Ctrl+C 停止服务器") print("=" * 60) print() # 预加载数据 load_graph_data() app.run(host=host, port=port, debug=debug) if __name__ == "__main__": try: run_server(debug=True) except KeyboardInterrupt: print("\n\n👋 服务器已停止") except Exception as e: print(f"\n❌ 启动失败: {e}") sys.exit(1)