479 lines
15 KiB
Python
479 lines
15 KiB
Python
"""
|
||
记忆图可视化 - 独立版本
|
||
|
||
直接从存储的数据文件生成可视化,无需启动完整的记忆管理器
|
||
"""
|
||
|
||
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/<memory_id>")
|
||
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)
|