Files
Mofox-Core/src/api/templates/visualizer.html

1753 lines
65 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MoFox Bot - 记忆图可视化</title>
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
gap: 10px;
padding: 10px;
}
.sidebar {
width: 320px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.graph-container {
flex: 1;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
overflow: hidden;
position: relative;
}
#memory-graph {
width: 100%;
height: 100%;
}
.controls {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
padding: 15px 20px;
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 10px;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
h2 {
color: #555;
font-size: 16px;
margin-bottom: 10px;
font-weight: 600;
}
.search-box {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.search-box input {
flex: 1;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
.search-box input:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn-secondary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-item {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.legend {
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
.legend-color {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
background: #f8f9fa;
border-radius: 6px;
}
.filter-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.filter-item label {
flex: 1;
font-size: 13px;
cursor: pointer;
}
.performance-mode {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #fff3cd;
border-radius: 6px;
border: 1px solid #ffc107;
}
.performance-mode h3 {
font-size: 13px;
color: #856404;
margin: 0;
}
.performance-options {
display: flex;
flex-direction: column;
gap: 6px;
}
.performance-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.performance-item input[type="radio"] {
cursor: pointer;
}
.performance-item label {
cursor: pointer;
flex: 1;
}
.performance-tips {
margin-top: 8px;
padding: 8px;
background: #e8f5e9;
border-radius: 4px;
font-size: 11px;
color: #2e7d32;
line-height: 1.4;
}
.performance-tips strong {
display: block;
margin-bottom: 4px;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #667eea;
font-size: 18px;
font-weight: 500;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.loading-progress {
margin-top: 10px;
font-size: 14px;
color: #764ba2;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.info-panel {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
font-size: 13px;
max-height: 200px;
overflow-y: auto;
}
.info-panel h3 {
font-size: 14px;
color: #667eea;
margin-bottom: 8px;
}
.info-panel p {
margin: 4px 0;
color: #555;
}
.control-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.control-buttons .btn {
flex: 1;
min-width: 120px;
}
/* 文件选择器模态框 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
position: relative;
background: white;
margin: 5% auto;
padding: 30px;
width: 80%;
max-width: 700px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #667eea;
}
.modal-header h2 {
margin: 0;
color: #333;
}
.close-btn {
font-size: 28px;
font-weight: bold;
color: #999;
cursor: pointer;
transition: color 0.3s;
}
.close-btn:hover {
color: #333;
}
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-item {
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.file-item:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.file-item.current {
border-color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
}
.file-item-name {
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.file-item-info {
font-size: 12px;
color: #666;
display: flex;
justify-content: space-between;
}
.file-item-current {
display: inline-block;
background: #667eea;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
margin-left: 10px;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: 300px;
}
.modal-content {
width: 95%;
margin: 10% auto;
}
}
</style>
</head>
<body>
<div class="container">
<!-- 侧边栏 -->
<div class="sidebar">
<h1>🦊 记忆图可视化</h1>
<!-- 文件选择 -->
<div>
<h2>📂 数据文件</h2>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<button class="btn" style="flex: 1;" onclick="showFileSelector()">选择文件</button>
<button class="btn btn-secondary" style="flex: 1;" onclick="loadFileList()">刷新列表</button>
</div>
<div id="currentFileInfo" style="font-size: 12px; color: #666; padding: 8px; background: #f8f9fa; border-radius: 6px;">
<p>加载中...</p>
</div>
</div>
<!-- 搜索 -->
<div>
<h2>🔍 搜索记忆</h2>
<div class="search-box">
<input type="text" id="searchInput" placeholder="输入关键词搜索...">
<button class="btn" onclick="searchMemories()">搜索</button>
</div>
</div>
<!-- 统计信息 -->
<div>
<h2>📊 统计信息</h2>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="statNodes">0</div>
<div class="stat-label">节点数</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statEdges">0</div>
<div class="stat-label">边数</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statMemories">0</div>
<div class="stat-label">记忆数</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statDensity">0%</div>
<div class="stat-label">图密度</div>
</div>
</div>
</div>
<!-- 节点类型图例 -->
<div>
<h2>🎨 节点类型</h2>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #FF6B6B;"></div>
<span>主体 (SUBJECT)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #4ECDC4;"></div>
<span>主题 (TOPIC)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #45B7D1;"></div>
<span>客体 (OBJECT)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #FFA07A;"></div>
<span>属性 (ATTRIBUTE)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #98D8C8;"></div>
<span>值 (VALUE)</span>
</div>
</div>
</div>
<!-- 过滤器 -->
<div>
<h2>🔧 过滤器</h2>
<div class="filter-group">
<div class="filter-item">
<input type="checkbox" id="filterSubject" checked onchange="applyFilters()">
<label for="filterSubject">显示主体节点</label>
</div>
<div class="filter-item">
<input type="checkbox" id="filterTopic" checked onchange="applyFilters()">
<label for="filterTopic">显示主题节点</label>
</div>
<div class="filter-item">
<input type="checkbox" id="filterObject" checked onchange="applyFilters()">
<label for="filterObject">显示客体节点</label>
</div>
<div class="filter-item">
<input type="checkbox" id="filterAttribute" checked onchange="applyFilters()">
<label for="filterAttribute">显示属性节点</label>
</div>
<div class="filter-item">
<input type="checkbox" id="filterValue" checked onchange="applyFilters()">
<label for="filterValue">显示值节点</label>
</div>
</div>
</div>
<!-- 性能模式 -->
<div>
<h2>⚡ 性能模式</h2>
<div class="performance-mode">
<h3>数据加载模式</h3>
<div class="performance-options">
<div class="performance-item">
<input type="radio" id="modeAuto" name="loadMode" value="auto" checked onchange="changeLoadMode()">
<label for="modeAuto">自动选择(推荐)</label>
</div>
<div class="performance-item">
<input type="radio" id="modeFull" name="loadMode" value="full" onchange="changeLoadMode()">
<label for="modeFull">完整加载(&lt;500节点</label>
</div>
<div class="performance-item">
<input type="radio" id="modeCluster" name="loadMode" value="cluster" onchange="changeLoadMode()">
<label for="modeCluster">聚类简化300节点</label>
</div>
<div class="performance-item">
<input type="radio" id="modePaginated" name="loadMode" value="paginated" onchange="changeLoadMode()">
<label for="modePaginated">分页加载500/页)</label>
</div>
</div>
<div class="performance-tips">
<strong>💡 性能提示:</strong>
• 节点 &gt;500: 优先使用聚类或分页模式<br>
• 加载完成后点击"禁用物理"按钮<br>
• 鼠标悬停可查看节点标签<br>
• 点击节点高亮关联路径完整模式1跳
</div>
</div>
</div>
<!-- 选中节点信息 -->
<div>
<h2> 节点信息</h2>
<div class="info-panel" id="nodeInfo">
<p style="color: #999;">点击节点查看详细信息</p>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 控制按钮 -->
<div class="controls">
<div class="control-buttons">
<button class="btn" onclick="loadGraph()">🔄 刷新图形</button>
<button class="btn btn-secondary" onclick="fitNetwork()">📐 适应窗口</button>
<button class="btn" onclick="exportGraph()">💾 导出数据</button>
<button class="btn" onclick="togglePhysics()" id="physicsToggle">⚙️ 启用物理</button>
</div>
<div id="paginationControls" class="control-buttons" style="margin-top: 10px; display: none;">
<button class="btn" onclick="loadPreviousPage()" id="prevPageBtn">⬅️ 上一页</button>
<span style="padding: 10px; font-size: 14px; font-weight: bold;" id="pageInfo">第 1 页</span>
<button class="btn" onclick="loadNextPage()" id="nextPageBtn">下一页 ➡️</button>
</div>
</div>
<!-- 图形显示区 -->
<div class="graph-container">
<div id="memory-graph"></div>
<div class="loading" id="loading">
<div class="loading-spinner"></div>
<div>正在加载记忆图...</div>
</div>
</div>
</div>
</div>
<!-- 文件选择模态框 -->
<div id="fileModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>📂 选择数据文件</h2>
<span class="close-btn" onclick="closeFileSelector()">&times;</span>
</div>
<div id="fileListContainer">
<div style="text-align: center; padding: 20px;">
<div class="loading-spinner"></div>
<p>加载文件列表...</p>
</div>
</div>
</div>
</div>
<script>
let network = null;
let availableFiles = [];
let graphData = {
nodes: [],
edges: [],
memories: []
};
let originalData = null;
let currentLoadMode = 'auto';
let currentPage = 1;
let totalPages = 1;
let isLoading = false;
let hoveredNodeId = null; // 当前鼠标悬停的节点ID
let highlightedNodeIds = new Set(); // 当前高亮的节点ID集合
// 邻接表缓存,用于高亮优化
let adjacencyCache = null;
let nodeDegreeCache = null;
// 节点颜色配置
const nodeColors = {
'SUBJECT': '#FF6B6B',
'TOPIC': '#4ECDC4',
'OBJECT': '#45B7D1',
'ATTRIBUTE': '#FFA07A',
'VALUE': '#98D8C8'
};
// 切换加载模式
function changeLoadMode() {
const selected = document.querySelector('input[name="loadMode"]:checked');
if (selected) {
currentLoadMode = selected.value;
console.log('切换加载模式:', currentLoadMode);
loadGraph();
}
}
// 构建邻接表缓存
function buildAdjacencyCache(edges) {
adjacencyCache = new Map();
nodeDegreeCache = new Map();
edges.forEach(edge => {
const from = edge.from;
const to = edge.to;
if (!adjacencyCache.has(from)) {
adjacencyCache.set(from, []);
nodeDegreeCache.set(from, 0);
}
if (!adjacencyCache.has(to)) {
adjacencyCache.set(to, []);
nodeDegreeCache.set(to, 0);
}
adjacencyCache.get(from).push({ nodeId: to, edgeId: edge.id });
adjacencyCache.get(to).push({ nodeId: from, edgeId: edge.id });
nodeDegreeCache.set(from, nodeDegreeCache.get(from) + 1);
nodeDegreeCache.set(to, nodeDegreeCache.get(to) + 1);
});
console.log(`邻接表缓存构建完成: ${adjacencyCache.size} 个节点`);
}
// 初始化图形
function initNetwork() {
const container = document.getElementById('memory-graph');
// 动态配置:大数据集使用极简配置
const getNetworkOptions = (nodeCount) => {
const isLargeDataset = nodeCount > 500;
const isVeryLargeDataset = nodeCount > 2000;
return {
nodes: {
shape: 'dot',
size: isVeryLargeDataset ? 10 : (isLargeDataset ? 15 : 20),
font: {
size: isVeryLargeDataset ? 0 : (isLargeDataset ? 10 : 14), // 超大数据集隐藏文字
color: '#333',
face: 'Microsoft YaHei'
},
borderWidth: isVeryLargeDataset ? 0 : 2,
borderWidthSelected: 3,
shadow: false, // 始终禁用阴影
scaling: {
min: isVeryLargeDataset ? 5 : 10,
max: isVeryLargeDataset ? 15 : 30
}
},
edges: {
width: isVeryLargeDataset ? 0.5 : (isLargeDataset ? 1 : 2),
color: {
color: '#848484',
highlight: '#667eea',
hover: '#764ba2',
opacity: isVeryLargeDataset ? 0.3 : 0.7 // 超大数据集边半透明
},
arrows: {
to: {
enabled: !isVeryLargeDataset, // 超大数据集禁用箭头
scaleFactor: 0.3
}
},
smooth: false, // 始终禁用平滑以提升性能
font: {
size: 0, // 始终隐藏边标签
strokeWidth: 0
},
shadow: false,
selectionWidth: 2
},
physics: {
enabled: true,
barnesHut: {
gravitationalConstant: isVeryLargeDataset ? -800 : (isLargeDataset ? -2000 : -8000),
centralGravity: isVeryLargeDataset ? 0.1 : 0.3,
springLength: isVeryLargeDataset ? 50 : (isLargeDataset ? 95 : 150),
springConstant: isVeryLargeDataset ? 0.001 : (isLargeDataset ? 0.02 : 0.04),
damping: isVeryLargeDataset ? 0.3 : 0.09,
avoidOverlap: 0
},
stabilization: {
enabled: true,
iterations: isVeryLargeDataset ? 50 : (isLargeDataset ? 100 : 300),
updateInterval: isVeryLargeDataset ? 100 : (isLargeDataset ? 50 : 25),
onlyDynamicEdges: false,
fit: true
},
solver: 'barnesHut',
timestep: isVeryLargeDataset ? 1.0 : 0.5,
adaptiveTimestep: true,
maxVelocity: isVeryLargeDataset ? 100 : 50,
minVelocity: isVeryLargeDataset ? 5 : 0.75
},
interaction: {
hover: true, // 始终启用hover - 我们用自定义事件处理标签显示
tooltipDelay: 300,
zoomView: true,
dragView: true,
hideEdgesOnDrag: true, // 始终在拖拽时隐藏边
hideEdgesOnZoom: true, // 始终在缩放时隐藏边
hideNodesOnDrag: isVeryLargeDataset, // 超大数据集拖拽时也隐藏节点
navigationButtons: false,
keyboard: false
},
layout: {
improvedLayout: !isVeryLargeDataset, // 超大数据集禁用改进布局
randomSeed: 2 // 固定随机种子以获得一致的布局
}
};
};
const data = {
nodes: new vis.DataSet([]),
edges: new vis.DataSet([])
};
// 初始使用中等配置
network = new vis.Network(container, data, getNetworkOptions(500));
// 保存配置函数供后续使用
network.updateOptions = (nodeCount) => {
network.setOptions(getNetworkOptions(nodeCount));
};
// 添加事件监听
network.on('click', function(params) {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
showNodeInfo(nodeId);
highlightConnectedNodes(nodeId);
} else {
// 点击空白处,恢复所有节点
resetNodeHighlight();
}
});
// 添加鼠标悬停事件(仅在小数据集启用)
network.on('hoverNode', function(params) {
hoveredNodeId = params.node;
updateNodeLabel(params.node, true); // 显示悬停节点的标签
});
network.on('blurNode', function(params) {
// 如果节点不在高亮集合中,隐藏标签
if (!highlightedNodeIds.has(params.node)) {
updateNodeLabel(params.node, false);
}
hoveredNodeId = null;
});
// 稳定化完成后停止物理引擎
network.on('stabilizationIterationsDone', function() {
console.log('稳定化完成,停止物理引擎');
network.setOptions({ physics: { enabled: false } });
updateLoadingProgress('布局完成');
});
// 添加稳定化进度监听
network.on('stabilizationProgress', function(params) {
const progress = Math.round((params.iterations / params.total) * 100);
updateLoadingProgress(`布局中: ${progress}%`);
});
}
// 更新加载进度提示
function updateLoadingProgress(message) {
const progressDiv = document.querySelector('.loading-progress');
if (progressDiv) {
progressDiv.textContent = message;
} else {
const loadingDiv = document.getElementById('loading');
if (loadingDiv) {
const existing = loadingDiv.querySelector('.loading-progress');
if (!existing) {
const newDiv = document.createElement('div');
newDiv.className = 'loading-progress';
newDiv.textContent = message;
loadingDiv.appendChild(newDiv);
} else {
existing.textContent = message;
}
}
}
}
// 动态更新节点标签显示
function updateNodeLabel(nodeId, show) {
if (!network || !graphData) return;
const nodes = network.body.data.nodes;
const node = nodes.get(nodeId);
if (!node) return;
// 获取原始节点数据
const originalNode = graphData.nodes.find(n => n.id === nodeId);
if (!originalNode) return;
// 更新节点标签
nodes.update({
id: nodeId,
label: show ? originalNode.label : '',
font: show ? { size: 14, color: '#333' } : { size: 0 }
});
}
// 批量更新边标签显示
function updateEdgeLabels(edgeIds, show) {
if (!network || !graphData || edgeIds.size === 0) return;
const edges = network.body.data.edges;
const edgeUpdates = [];
edgeIds.forEach(edgeId => {
const edge = edges.get(edgeId);
if (!edge) return;
// 获取原始边数据
const originalEdge = graphData.edges.find(e => e.id === edgeId);
if (!originalEdge) return;
edgeUpdates.push({
id: edgeId,
label: show ? (originalEdge.label || '') : '',
font: show ? { size: 11, color: '#666' } : { size: 0 }
});
});
if (edgeUpdates.length > 0) {
edges.update(edgeUpdates);
}
}
// 加载图形数据(智能选择模式)
async function loadGraph() {
if (isLoading) {
console.log('已有加载任务在进行中');
return;
}
try {
isLoading = true;
document.getElementById('loading').style.display = 'block';
updateLoadingProgress('获取数据摘要...');
// 首先获取摘要信息以决定加载策略
const summaryResponse = await fetch('/visualizer/api/graph/summary');
const summaryResult = await summaryResponse.json();
if (!summaryResult.success) {
throw new Error(summaryResult.error);
}
const totalNodes = summaryResult.data.stats.total_nodes;
console.log(`图数据节点总数: ${totalNodes}`);
// 根据模式和数据量选择加载策略(更保守的阈值)
let loadMode = currentLoadMode;
if (loadMode === 'auto') {
if (totalNodes <= 500) {
loadMode = 'full';
} else if (totalNodes <= 2000) {
loadMode = 'cluster';
} else {
loadMode = 'paginated';
}
console.log(`自动选择加载模式: ${loadMode} (节点数: ${totalNodes})`);
}
// 根据选择的模式加载数据
let result;
if (loadMode === 'full') {
updateLoadingProgress('加载完整数据...');
result = await loadFullGraph();
} else if (loadMode === 'cluster') {
updateLoadingProgress('加载聚类数据...');
result = await loadClusteredGraph();
} else if (loadMode === 'paginated') {
updateLoadingProgress('加载分页数据 (第1页)...');
result = await loadPaginatedGraph(1);
}
if (result && result.success) {
updateLoadingProgress('渲染图形...');
originalData = result.data;
updateGraph(result.data);
updateStats(result.data.stats);
// 构建缓存以优化后续操作
buildAdjacencyCache(result.data.edges || []);
// 显示/隐藏分页控制
const paginationControls = document.getElementById('paginationControls');
if (result.data.pagination) {
const p = result.data.pagination;
totalPages = p.total_pages;
currentPage = p.page;
paginationControls.style.display = 'flex';
document.getElementById('pageInfo').textContent = `${p.page}/${p.total_pages}`;
document.getElementById('prevPageBtn').disabled = !p.has_prev;
document.getElementById('nextPageBtn').disabled = !p.has_next;
} else {
paginationControls.style.display = 'none';
}
// 显示加载信息
if (result.data.clustered) {
console.log(`✅ 聚类简化: ${result.data.stats.original_nodes}${result.data.stats.clustered_nodes} 节点`);
} else if (result.data.pagination) {
const p = result.data.pagination;
console.log(`✅ 分页加载: 第 ${p.page}/${p.total_pages} 页 (共 ${p.total_nodes} 节点)`);
} else {
console.log(`✅ 完整加载: ${result.data.stats.total_nodes} 节点`);
}
} else {
alert('加载失败: ' + (result ? result.error : '未知错误'));
}
} catch (error) {
console.error('加载图形失败:', error);
alert('加载失败: ' + error.message);
} finally {
isLoading = false;
document.getElementById('loading').style.display = 'none';
}
}
// 加载完整图数据
async function loadFullGraph() {
const response = await fetch('/visualizer/api/graph/full');
return await response.json();
}
// 加载聚类简化数据
async function loadClusteredGraph(maxNodes = 300) {
const response = await fetch(`/visualizer/api/graph/clustered?max_nodes=${maxNodes}&cluster_threshold=10`);
return await response.json();
}
// 加载分页数据
async function loadPaginatedGraph(page = 1, pageSize = 500) {
const response = await fetch(`/visualizer/api/graph/paginated?page=${page}&page_size=${pageSize}&min_importance=0.0`);
return await response.json();
}
// 更新图形显示(优化版本 - 使用节点限制和延迟渲染)
function updateGraph(data) {
graphData = data;
const nodeCount = data.nodes.length;
const edgeCount = data.edges.length;
console.log(`准备更新图形: ${nodeCount} 个节点, ${edgeCount} 条边`);
// 对于超大数据集,进一步限制
const MAX_RENDERABLE_NODES = 5000;
const MAX_RENDERABLE_EDGES = 10000;
let nodesToRender = data.nodes;
let edgesToRender = data.edges;
let isLimited = false;
// 如果超过限制,只渲染最重要的节点
if (nodeCount > MAX_RENDERABLE_NODES) {
console.warn(`节点数 ${nodeCount} 超过渲染限制 ${MAX_RENDERABLE_NODES},将只显示最重要的节点`);
// 计算节点重要性
const nodeDegrees = new Map();
data.edges.forEach(edge => {
nodeDegrees.set(edge.from, (nodeDegrees.get(edge.from) || 0) + 1);
nodeDegrees.set(edge.to, (nodeDegrees.get(edge.to) || 0) + 1);
});
// 按连接度排序保留前N个
nodesToRender = data.nodes
.map(node => ({
...node,
degree: nodeDegrees.get(node.id) || 0
}))
.sort((a, b) => b.degree - a.degree)
.slice(0, MAX_RENDERABLE_NODES);
const renderableNodeIds = new Set(nodesToRender.map(n => n.id));
edgesToRender = data.edges.filter(e =>
renderableNodeIds.has(e.from) && renderableNodeIds.has(e.to)
);
isLimited = true;
alert(`⚠️ 数据量过大\n原始: ${nodeCount} 节点\n仅渲染: ${nodesToRender.length} 个最重要节点\n建议使用"聚类简化"或"分页加载"模式`);
}
// 如果边数过多,也进行限制
if (edgesToRender.length > MAX_RENDERABLE_EDGES) {
console.warn(`边数 ${edgesToRender.length} 超过渲染限制 ${MAX_RENDERABLE_EDGES}`);
edgesToRender = edgesToRender.slice(0, MAX_RENDERABLE_EDGES);
isLimited = true;
}
// 根据节点数量动态调整网络配置
if (network.updateOptions) {
network.updateOptions(nodesToRender.length);
}
console.log(`实际渲染: ${nodesToRender.length} 个节点, ${edgesToRender.length} 条边`);
// 极简化节点数据 - 移除所有不必要的属性
const startTime = performance.now();
const nodes = nodesToRender.map(node => {
const baseNode = {
id: node.id,
label: '', // 默认不显示标签,仅在悬停或高亮时显示
group: node.group,
color: nodeColors[node.group] || '#999'
};
// 仅在小数据集中添加titletooltip
if (nodesToRender.length <= 1000) {
baseNode.title = node.title || node.label;
}
// 如果是聚类节点,使用不同的样式并始终显示标签
if (node.is_cluster) {
baseNode.shape = 'star';
baseNode.size = 25 + Math.min(node.cluster_size / 10, 15);
baseNode.label = `${node.group} (${node.cluster_size})`;
baseNode.font = { size: 14, bold: true };
}
return baseNode;
});
// 极简化边数据
const edges = edgesToRender.map(edge => {
const baseEdge = {
id: edge.id,
from: edge.from,
to: edge.to,
label: '' // 默认不显示标签,仅在高亮时显示
};
// 在小数据集中保留边宽度
if (edgesToRender.length <= 1000) {
baseEdge.width = (edge.importance || 0.5) * 2 + 0.5;
}
return baseEdge;
});
// 批量更新网络使用异步渲染以避免阻塞UI
const prepTime = performance.now() - startTime;
console.log(`数据准备耗时: ${prepTime.toFixed(2)}ms`);
// 使用 requestAnimationFrame 异步渲染避免阻塞UI
requestAnimationFrame(() => {
const renderStart = performance.now();
// 先清空现有数据
network.setData({
nodes: new vis.DataSet([]),
edges: new vis.DataSet([])
});
// 分批添加节点和边以提升响应性
const BATCH_SIZE = 500;
let nodeIndex = 0;
let edgeIndex = 0;
const nodeDataSet = new vis.DataSet();
const edgeDataSet = new vis.DataSet();
function addNodeBatch() {
const batch = nodes.slice(nodeIndex, nodeIndex + BATCH_SIZE);
if (batch.length > 0) {
nodeDataSet.add(batch);
nodeIndex += BATCH_SIZE;
updateLoadingProgress(`加载节点: ${Math.min(nodeIndex, nodes.length)}/${nodes.length}`);
if (nodeIndex < nodes.length) {
setTimeout(addNodeBatch, 0);
} else {
// 节点加载完成,开始加载边
setTimeout(addEdgeBatch, 0);
}
}
}
function addEdgeBatch() {
const batch = edges.slice(edgeIndex, edgeIndex + BATCH_SIZE);
if (batch.length > 0) {
edgeDataSet.add(batch);
edgeIndex += BATCH_SIZE;
updateLoadingProgress(`加载边: ${Math.min(edgeIndex, edges.length)}/${edges.length}`);
if (edgeIndex < edges.length) {
setTimeout(addEdgeBatch, 0);
} else {
// 所有数据加载完成,更新网络
finishRendering();
}
}
}
function finishRendering() {
network.setData({
nodes: nodeDataSet,
edges: edgeDataSet
});
const renderTime = performance.now() - renderStart;
console.log(`图形渲染总耗时: ${renderTime.toFixed(2)}ms`);
updateLoadingProgress('渲染完成');
}
// 对于小数据集,直接一次性加载
if (nodes.length <= 1000) {
nodeDataSet.add(nodes);
edgeDataSet.add(edges);
network.setData({
nodes: nodeDataSet,
edges: edgeDataSet
});
const renderTime = performance.now() - renderStart;
console.log(`图形渲染总耗时: ${renderTime.toFixed(2)}ms`);
} else {
// 大数据集使用分批加载
addNodeBatch();
}
});
}
// 更新统计信息
function updateStats(stats) {
document.getElementById('statNodes').textContent = stats.total_nodes;
document.getElementById('statEdges').textContent = stats.total_edges;
document.getElementById('statMemories').textContent = stats.total_memories;
const density = stats.total_nodes > 0
? ((stats.total_edges / (stats.total_nodes * (stats.total_nodes - 1))) * 100).toFixed(2)
: 0;
document.getElementById('statDensity').textContent = density + '%';
}
// 显示节点信息
function showNodeInfo(nodeId) {
const node = graphData.nodes.find(n => n.id === nodeId);
if (!node) return;
const infoPanel = document.getElementById('nodeInfo');
infoPanel.innerHTML = `
<h3>${node.label}</h3>
<p><strong>类型:</strong> ${node.type}</p>
<p><strong>ID:</strong> ${node.id.substring(0, 8)}...</p>
<p><strong>创建时间:</strong> ${new Date(node.created_at).toLocaleString('zh-CN')}</p>
${node.metadata && Object.keys(node.metadata).length > 0
? `<p><strong>元数据:</strong> ${JSON.stringify(node.metadata, null, 2)}</p>`
: ''}
`;
}
// 搜索记忆
async function searchMemories() {
const query = document.getElementById('searchInput').value;
if (!query) {
alert('请输入搜索关键词');
return;
}
try {
const response = await fetch(`api/search?q=${encodeURIComponent(query)}&limit=50`);
const result = await response.json();
if (result.success) {
highlightSearchResults(result.data.results);
} else {
alert('搜索失败: ' + result.error);
}
} catch (error) {
console.error('搜索失败:', error);
alert('搜索失败: ' + error.message);
}
}
// 高亮搜索结果
function highlightSearchResults(results) {
const memoryIds = results.map(r => r.id);
const relatedNodeIds = new Set();
// 找出相关的节点
graphData.memories.forEach(memory => {
if (memoryIds.includes(memory.id)) {
// 这里需要找到该记忆相关的所有节点
// 简化实现:高亮所有节点
graphData.nodes.forEach(node => relatedNodeIds.add(node.id));
}
});
// 高亮节点
if (relatedNodeIds.size > 0) {
network.selectNodes([...relatedNodeIds]);
network.fit({
nodes: [...relatedNodeIds],
animation: true
});
} else {
alert('未找到相关节点');
}
}
// 高亮与选中节点连接的节点(优化版本,使用缓存)
function highlightConnectedNodes(nodeId) {
if (!network || !graphData || !adjacencyCache) return;
const startTime = performance.now();
// 根据加载模式和数据规模决定探索深度
// 完整加载模式1跳
// 聚类/分页模式3跳因为数据量已经减少
const nodeCount = graphData.nodes.length;
let MAX_DEPTH = 3;
if (currentLoadMode === 'full' || (currentLoadMode === 'auto' && nodeCount <= 500)) {
MAX_DEPTH = 1; // 完整加载模式限制为1跳
console.log('完整加载模式使用1跳探索深度');
} else {
console.log('聚类/分页模式使用3跳探索深度');
}
// 使用缓存的邻接表进行 BFS
const connectedNodeIds = new Set();
const connectedEdgeIds = new Set();
const visited = new Set();
const queue = [{ nodeId: nodeId, depth: 0 }];
// BFS 遍历
while (queue.length > 0) {
const { nodeId: currentNode, depth } = queue.shift();
if (visited.has(currentNode)) continue;
visited.add(currentNode);
connectedNodeIds.add(currentNode);
if (depth >= MAX_DEPTH) continue;
// 使用缓存的邻接表
const neighbors = adjacencyCache.get(currentNode) || [];
neighbors.forEach(({ nodeId: neighborId, edgeId }) => {
connectedEdgeIds.add(edgeId);
if (!visited.has(neighborId)) {
queue.push({ nodeId: neighborId, depth: depth + 1 });
}
});
}
const searchTime = performance.now() - startTime;
console.log(`BFS搜索耗时: ${searchTime.toFixed(2)}ms, 找到 ${connectedNodeIds.size} 个连接节点 (深度${MAX_DEPTH}跳)`);
// 更新高亮节点集合
highlightedNodeIds = connectedNodeIds;
// 批量更新节点和边
const allNodes = network.body.data.nodes;
const allEdges = network.body.data.edges;
const nodeUpdates = [];
const edgeUpdates = [];
// 更新节点
allNodes.get().forEach(node => {
const originalNode = graphData.nodes.find(n => n.id === node.id);
if (!originalNode) return;
if (connectedNodeIds.has(node.id)) {
const isSelected = node.id === nodeId;
nodeUpdates.push({
id: node.id,
opacity: 1.0,
borderWidth: isSelected ? 5 : 3,
label: originalNode.label || '', // 显示高亮节点的标签
font: {
color: isSelected ? '#667eea' : '#333',
size: isSelected ? 16 : 14,
bold: true
}
});
} else {
const originalColor = nodeColors[node.group] || '#999';
const dimmedColor = hexToRgba(originalColor, 0.08);
nodeUpdates.push({
id: node.id,
color: {
background: dimmedColor,
border: dimmedColor,
},
opacity: 0.08,
label: '', // 隐藏非高亮节点的标签
font: { color: 'rgba(51, 51, 51, 0.08)', size: 0 }
});
}
});
// 更新边
allEdges.get().forEach(edge => {
const originalEdge = graphData.edges.find(e => e.id === edge.id);
if (connectedEdgeIds.has(edge.id)) {
edgeUpdates.push({
id: edge.id,
color: { color: '#667eea', opacity: 1.0 },
width: 4,
label: originalEdge?.label || '', // 显示高亮边的标签
font: { color: '#667eea', size: 12 }
});
} else {
edgeUpdates.push({
id: edge.id,
color: { color: '#848484', opacity: 0.03 },
width: 1,
label: '', // 隐藏非高亮边的标签
font: { color: 'rgba(102, 102, 102, 0.03)', size: 0 }
});
}
});
// 批量应用更新
allNodes.update(nodeUpdates);
allEdges.update(edgeUpdates);
const totalTime = performance.now() - startTime;
console.log(`高亮操作总耗时: ${totalTime.toFixed(2)}ms`);
// 聚焦视图
if (connectedNodeIds.size > 1 && connectedNodeIds.size < 100) {
network.fit({
nodes: Array.from(connectedNodeIds),
animation: {
duration: 800,
easingFunction: 'easeInOutQuad'
}
});
}
}
// 重置节点高亮状态(优化版本)
function resetNodeHighlight() {
if (!network || !graphData) return;
const startTime = performance.now();
const allNodes = network.body.data.nodes;
const allEdges = network.body.data.edges;
// 清空高亮节点集合
highlightedNodeIds.clear();
// 批量恢复所有节点(不显示标签,除非是悬停节点)
const nodeUpdates = allNodes.get().map(node => {
const originalColor = nodeColors[node.group] || '#999';
const shouldShowLabel = node.id === hoveredNodeId; // 只显示悬停节点的标签
const originalNode = shouldShowLabel ? graphData.nodes.find(n => n.id === node.id) : null;
return {
id: node.id,
color: originalColor,
opacity: 1.0,
borderWidth: 2,
label: shouldShowLabel && originalNode ? originalNode.label : '',
font: { color: '#333', size: shouldShowLabel ? 14 : 0, bold: false }
};
});
allNodes.update(nodeUpdates);
// 批量恢复所有边(不显示标签)
const edgeUpdates = allEdges.get().map(edge => ({
id: edge.id,
color: { color: '#848484', opacity: 1.0 },
width: 2,
label: '',
font: { color: '#666', size: 0 }
}));
allEdges.update(edgeUpdates);
const endTime = performance.now();
console.log(`重置高亮耗时: ${(endTime - startTime).toFixed(2)}ms`);
}
// 辅助函数:将十六进制颜色转换为 rgba
function hexToRgba(hex, alpha) {
// 如果已经是 rgba 格式,直接返回
if (hex.startsWith('rgba')) return hex;
// 处理 # 开头的十六进制
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// 应用过滤器(优化版本)
function applyFilters() {
if (!originalData) return;
const filters = {
'SUBJECT': document.getElementById('filterSubject').checked,
'TOPIC': document.getElementById('filterTopic').checked,
'OBJECT': document.getElementById('filterObject').checked,
'ATTRIBUTE': document.getElementById('filterAttribute').checked,
'VALUE': document.getElementById('filterValue').checked
};
// 过滤节点
const filteredNodes = originalData.nodes.filter(node =>
filters[node.group]
);
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
// 过滤边(只保留两端节点都存在的边)
const filteredEdges = originalData.edges.filter(edge =>
filteredNodeIds.has(edge.from) && filteredNodeIds.has(edge.to)
);
// 更新图形
updateGraph({
nodes: filteredNodes,
edges: filteredEdges,
memories: originalData.memories,
stats: {
total_nodes: filteredNodes.length,
total_edges: filteredEdges.length,
total_memories: originalData.stats.total_memories
}
});
// 重置高亮状态
resetNodeHighlight();
// 重建缓存
buildAdjacencyCache(filteredEdges);
// 重新启用物理引擎以重新布局
if (network && filteredNodes.length > 0) {
network.setOptions({ physics: { enabled: true } });
// 超时保护
let stabilized = false;
const timeout = setTimeout(() => {
if (!stabilized) {
console.log('物理引擎稳定超时,强制停止');
network.setOptions({ physics: { enabled: false } });
}
}, 5000);
network.once('stabilizationIterationsDone', function() {
stabilized = true;
clearTimeout(timeout);
network.setOptions({ physics: { enabled: false } });
console.log('过滤后重新布局完成');
});
}
}
// 适应窗口
function fitNetwork() {
if (network) {
network.fit({
animation: {
duration: 1000,
easingFunction: 'easeInOutQuad'
}
});
}
}
// 切换物理引擎
function togglePhysics() {
if (!network) return;
const currentPhysics = network.physics.options.enabled;
network.setOptions({ physics: { enabled: !currentPhysics } });
const btn = document.getElementById('physicsToggle');
btn.textContent = currentPhysics ? '⚙️ 启用物理' : '⏸️ 禁用物理';
console.log(`物理引擎: ${currentPhysics ? '已禁用' : '已启用'}`);
}
// 加载下一页
async function loadNextPage() {
if (currentPage < totalPages && !isLoading) {
currentPage++;
await loadPaginatedGraphAndUpdate();
}
}
// 加载上一页
async function loadPreviousPage() {
if (currentPage > 1 && !isLoading) {
currentPage--;
await loadPaginatedGraphAndUpdate();
}
}
// 加载分页并更新UI
async function loadPaginatedGraphAndUpdate() {
try {
isLoading = true;
document.getElementById('loading').style.display = 'block';
updateLoadingProgress(`加载第 ${currentPage} 页...`);
const result = await loadPaginatedGraph(currentPage);
if (result.success) {
originalData = result.data;
updateGraph(result.data);
updateStats(result.data.stats);
buildAdjacencyCache(result.data.edges || []);
// 更新分页UI
const p = result.data.pagination;
totalPages = p.total_pages;
document.getElementById('pageInfo').textContent = `${p.page}/${p.total_pages}`;
document.getElementById('prevPageBtn').disabled = !p.has_prev;
document.getElementById('nextPageBtn').disabled = !p.has_next;
}
} catch (error) {
console.error('加载分页失败:', error);
alert('加载失败: ' + error.message);
} finally {
isLoading = false;
document.getElementById('loading').style.display = 'none';
}
}
// 导出图形数据
function exportGraph() {
const dataStr = JSON.stringify(graphData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `memory_graph_${new Date().getTime()}.json`;
link.click();
URL.revokeObjectURL(url);
}
// 文件选择功能
async function loadFileList() {
try {
const response = await fetch('/visualizer/api/files');
const result = await response.json();
if (result.success) {
availableFiles = result.files;
updateCurrentFileInfo(result.current_file, result.files);
return result.files;
} else {
console.error('加载文件列表失败:', result.error);
return [];
}
} catch (error) {
console.error('加载文件列表失败:', error);
return [];
}
}
function updateCurrentFileInfo(currentFile, files) {
const infoDiv = document.getElementById('currentFileInfo');
if (!currentFile || files.length === 0) {
infoDiv.innerHTML = '<p style="color: #e74c3c;">❌ 未找到数据文件</p>';
return;
}
const currentFileObj = files.find(f => f.path === currentFile);
if (currentFileObj) {
infoDiv.innerHTML = `
<div style="margin-bottom: 5px;"><strong>📄 ${currentFileObj.name}</strong></div>
<div style="display: flex; justify-content: space-between;">
<span>大小: ${currentFileObj.size_kb} KB</span>
<span>修改: ${currentFileObj.modified_readable.split(' ')[0]}</span>
</div>
`;
}
}
async function showFileSelector() {
const modal = document.getElementById('fileModal');
const container = document.getElementById('fileListContainer');
modal.style.display = 'block';
container.innerHTML = `
<div style="text-align: center; padding: 20px;">
<div class="loading-spinner"></div>
<p>加载文件列表...</p>
</div>
`;
const files = await loadFileList();
if (files.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 40px; color: #999;">
<p style="font-size: 48px; margin-bottom: 10px;">📭</p>
<p>未找到任何数据文件</p>
<p style="font-size: 12px; margin-top: 10px;">请先运行Bot生成记忆数据</p>
</div>
`;
return;
}
let html = '<div class="file-list">';
files.forEach(file => {
const currentBadge = file.is_current ? '<span class="file-item-current">当前</span>' : '';
html += `
<div class="file-item ${file.is_current ? 'current' : ''}" onclick="selectFile('${file.path.replace(/\\/g, '\\\\')}')">
<div class="file-item-name">📄 ${file.name}${currentBadge}</div>
<div class="file-item-info">
<span>大小: ${file.size_kb} KB</span>
<span>${file.modified_readable}</span>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function closeFileSelector() {
document.getElementById('fileModal').style.display = 'none';
}
async function selectFile(filePath) {
try {
document.getElementById('loading').style.display = 'block';
closeFileSelector();
const response = await fetch('/visualizer/api/select_file', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_path: filePath })
});
const result = await response.json();
if (result.success) {
// 重新加载图形
await loadGraph();
await loadFileList();
alert('✅ ' + result.message);
} else {
alert('❌ 切换文件失败: ' + result.error);
}
} catch (error) {
console.error('切换文件失败:', error);
alert('❌ 切换文件失败: ' + error.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// 点击模态框外部关闭
window.onclick = function(event) {
const modal = document.getElementById('fileModal');
if (event.target == modal) {
closeFileSelector();
}
}
// 页面加载完成后初始化
window.addEventListener('load', function() {
initNetwork();
loadGraph();
loadFileList();
});
</script>
</body>
</html>