643 lines
17 KiB
HTML
643 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>织忆 MemoryWeave — 知识图谱可视化</title>
|
|
<script src="/static/d3.min.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
background: #0d1117;
|
|
color: #e6edf3;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 56px;
|
|
background: linear-gradient(180deg, #161b22 0%, #0d1117 100%);
|
|
border-bottom: 1px solid #30363d;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 24px;
|
|
z-index: 100;
|
|
}
|
|
|
|
#header h1 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #58a6ff;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
#header h1 span {
|
|
color: #8b949e;
|
|
font-weight: 400;
|
|
}
|
|
|
|
#controls {
|
|
position: fixed;
|
|
top: 72px;
|
|
left: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.control-group {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.control-group label {
|
|
display: block;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: #8b949e;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
#namespace-select {
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
color: #e6edf3;
|
|
padding: 8px 12px;
|
|
font-size: 14px;
|
|
min-width: 160px;
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
|
|
#namespace-select:hover {
|
|
border-color: #58a6ff;
|
|
}
|
|
|
|
#namespace-select option {
|
|
background: #161b22;
|
|
}
|
|
|
|
#stats {
|
|
position: fixed;
|
|
top: 72px;
|
|
right: 24px;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
font-size: 13px;
|
|
z-index: 100;
|
|
}
|
|
|
|
#stats .stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 24px;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
#stats .stat-label {
|
|
color: #8b949e;
|
|
}
|
|
|
|
#stats .stat-value {
|
|
color: #58a6ff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
#graph-container {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
}
|
|
|
|
svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.node {
|
|
cursor: pointer;
|
|
transition: filter 0.2s;
|
|
}
|
|
|
|
.node:hover {
|
|
filter: brightness(1.3);
|
|
}
|
|
|
|
.node-label {
|
|
font-size: 10px;
|
|
fill: #e6edf3;
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.link {
|
|
stroke: #30363d;
|
|
stroke-opacity: 0.6;
|
|
transition: stroke-opacity 0.2s, stroke 0.2s;
|
|
}
|
|
|
|
.link:hover {
|
|
stroke-opacity: 1;
|
|
stroke: #58a6ff;
|
|
}
|
|
|
|
.link.highlighted {
|
|
stroke: #58a6ff;
|
|
stroke-opacity: 1;
|
|
stroke-width: 2px;
|
|
}
|
|
|
|
.link-label {
|
|
font-size: 9px;
|
|
fill: #8b949e;
|
|
text-anchor: middle;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.link-label.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
#tooltip {
|
|
position: fixed;
|
|
display: none;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
max-width: 320px;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
z-index: 200;
|
|
}
|
|
|
|
#tooltip h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #58a6ff;
|
|
margin-bottom: 8px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid #30363d;
|
|
}
|
|
|
|
#tooltip .prop {
|
|
display: flex;
|
|
margin: 6px 0;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#tooltip .prop-key {
|
|
color: #8b949e;
|
|
min-width: 80px;
|
|
}
|
|
|
|
#tooltip .prop-value {
|
|
color: #e6edf3;
|
|
word-break: break-all;
|
|
}
|
|
|
|
#tooltip .type-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
#loading {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: #8b949e;
|
|
font-size: 14px;
|
|
}
|
|
|
|
#error {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: #f85149;
|
|
font-size: 14px;
|
|
display: none;
|
|
text-align: center;
|
|
}
|
|
|
|
.legend {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 24px;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.legend-title {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: #8b949e;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="header">
|
|
<h1>🧠 织忆 <span>— 知识图谱可视化</span></h1>
|
|
</div>
|
|
|
|
<div id="controls">
|
|
<div class="control-group">
|
|
<label>命名空间</label>
|
|
<select id="namespace-select">
|
|
<option value="">全部</option>
|
|
<option value="hermes-main">hermes-main</option>
|
|
<option value="openclaw-main">openclaw-main</option>
|
|
<option value="shared">shared</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="stats">
|
|
<div class="stat-row">
|
|
<span class="stat-label">节点</span>
|
|
<span class="stat-value" id="node-count">0</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">边</span>
|
|
<span class="stat-value" id="edge-count">0</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="graph-container"></div>
|
|
|
|
<div class="legend">
|
|
<div class="legend-title">节点类型</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:#58a6ff"></span>entity</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:#3fb950"></span>fact</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:#d29922"></span>decision</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:#a371f7"></span>skill</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:#f85149"></span>event</div>
|
|
</div>
|
|
|
|
<div id="tooltip"></div>
|
|
<div id="loading">加载中...</div>
|
|
<div id="error"></div>
|
|
|
|
<script>
|
|
const TYPE_COLORS = {
|
|
entity: '#58a6ff',
|
|
fact: '#3fb950',
|
|
decision: '#d29922',
|
|
skill: '#a371f7',
|
|
event: '#f85149'
|
|
};
|
|
|
|
const API_BASE = window.location.origin;
|
|
const API_KEY = 'zhiyi-dev-key-2026';
|
|
let nodes = [], links = [];
|
|
let simulation, svg, g, linkGroup, nodeGroup, labelGroup;
|
|
let width, height;
|
|
let currentHighlight = null;
|
|
|
|
function formatValue(val) {
|
|
if (val === null || val === undefined) return '—';
|
|
if (typeof val === 'object') return JSON.stringify(val);
|
|
return String(val);
|
|
}
|
|
|
|
async function loadGraph(namespace = '') {
|
|
const loading = document.getElementById('loading');
|
|
const error = document.getElementById('error');
|
|
loading.style.display = 'block';
|
|
error.style.display = 'none';
|
|
|
|
try {
|
|
const url = namespace
|
|
? `${API_BASE}/api/v1/graph/export?namespace=${encodeURIComponent(namespace)}&limit=200`
|
|
: `${API_BASE}/api/v1/graph/export?limit=300`;
|
|
|
|
const resp = await fetch(url, {
|
|
headers: { 'X-API-Key': API_KEY }
|
|
});
|
|
if (!resp.ok) { const t = await resp.text(); console.error('API error:', resp.status, t.substring(0, 500)); throw new Error(`HTTP ${resp.status}`); }
|
|
|
|
const data = await resp.json();
|
|
nodes = data.nodes || [];
|
|
links = data.edges || [];
|
|
|
|
document.getElementById('node-count').textContent = nodes.length;
|
|
document.getElementById('edge-count').textContent = links.length;
|
|
|
|
loading.style.display = 'none';
|
|
renderGraph();
|
|
} catch (e) {
|
|
loading.style.display = 'none';
|
|
error.style.display = 'block';
|
|
error.textContent = `加载失败: ${e.message}`;
|
|
console.error('Graph load error:', e);
|
|
// Show response status if available
|
|
if (typeof resp !== 'undefined' && resp) {
|
|
console.log('Response status:', resp.status);
|
|
resp.text().then(t => console.log('Response body:', t.substring(0, 500)));
|
|
}
|
|
}
|
|
}
|
|
|
|
function initSVG() {
|
|
const container = document.getElementById('graph-container');
|
|
width = container.clientWidth;
|
|
height = container.clientHeight;
|
|
|
|
svg = d3.select('#graph-container')
|
|
.append('svg')
|
|
.attr('width', width)
|
|
.attr('height', height);
|
|
|
|
// 背景网格
|
|
const defs = svg.append('defs');
|
|
const pattern = defs.append('pattern')
|
|
.attr('id', 'grid')
|
|
.attr('width', 40)
|
|
.attr('height', 40)
|
|
.attr('patternUnits', 'userSpaceOnUse');
|
|
|
|
pattern.append('circle')
|
|
.attr('cx', 20).attr('cy', 20).attr('r', 1)
|
|
.attr('fill', '#21262d');
|
|
|
|
svg.append('rect')
|
|
.attr('width', '100%').attr('height', '100%')
|
|
.attr('fill', 'url(#grid)');
|
|
|
|
g = svg.append('g');
|
|
linkGroup = g.append('g').attr('class', 'links');
|
|
labelGroup = g.append('g').attr('class', 'link-labels');
|
|
nodeGroup = g.append('g').attr('class', 'nodes');
|
|
|
|
// 缩放
|
|
svg.call(d3.zoom()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform);
|
|
}));
|
|
}
|
|
|
|
function renderGraph() {
|
|
linkGroup.selectAll('*').remove();
|
|
labelGroup.selectAll('*').remove();
|
|
nodeGroup.selectAll('*').remove();
|
|
|
|
if (simulation) simulation.stop();
|
|
|
|
// 计算节点大小
|
|
const maxPagerank = Math.max(...nodes.map(n => n.pagerank || 0), 1);
|
|
const nodeSize = d3.scaleSqrt()
|
|
.domain([0, maxPagerank])
|
|
.range([4, 20]);
|
|
|
|
// 创建力模拟
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
|
.force('charge', d3.forceManyBody().strength(-300))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(d => nodeSize(d.pagerank || 0) + 5));
|
|
|
|
// 绘制边
|
|
const link = linkGroup.selectAll('line')
|
|
.data(links)
|
|
.enter().append('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke-width', d => Math.sqrt(d.weight || 1));
|
|
|
|
// 边标签
|
|
const linkLabel = labelGroup.selectAll('text')
|
|
.data(links)
|
|
.enter().append('text')
|
|
.attr('class', 'link-label')
|
|
.text(d => d.relation || '');
|
|
|
|
// 绘制节点
|
|
const node = nodeGroup.selectAll('g')
|
|
.data(nodes)
|
|
.enter().append('g')
|
|
.attr('class', 'node')
|
|
.call(d3.drag()
|
|
.on('start', dragstarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragended));
|
|
|
|
node.append('circle')
|
|
.attr('r', d => nodeSize(d.pagerank || 0))
|
|
.attr('fill', d => TYPE_COLORS[d.type] || '#8b949e')
|
|
.attr('stroke', '#0d1117')
|
|
.attr('stroke-width', 2);
|
|
|
|
node.append('text')
|
|
.attr('class', 'node-label')
|
|
.attr('dy', d => nodeSize(d.pagerank || 0) + 14)
|
|
.text(d => d.name || d.id);
|
|
|
|
// 事件
|
|
node.on('mouseover', function(event, d) {
|
|
highlightNeighbors(d);
|
|
showLinkLabels(true);
|
|
})
|
|
.on('mouseout', function() {
|
|
clearHighlight();
|
|
showLinkLabels(false);
|
|
})
|
|
.on('click', function(event, d) {
|
|
event.stopPropagation();
|
|
showTooltip(d, event);
|
|
});
|
|
|
|
link.on('mouseover', function(event, d) {
|
|
d3.select(this).classed('highlighted', true);
|
|
showLinkLabels(true, d);
|
|
})
|
|
.on('mouseout', function() {
|
|
d3.select(this).classed('highlighted', false);
|
|
showLinkLabels(false);
|
|
});
|
|
|
|
svg.on('click', () => hideTooltip());
|
|
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
linkLabel
|
|
.attr('x', d => (d.source.x + d.target.x) / 2)
|
|
.attr('y', d => (d.source.y + d.target.y) / 2);
|
|
|
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
});
|
|
}
|
|
|
|
function highlightNeighbors(node) {
|
|
currentHighlight = node;
|
|
const neighborIds = new Set([node.id]);
|
|
|
|
links.forEach(l => {
|
|
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
if (srcId === node.id) neighborIds.add(tgtId);
|
|
if (tgtId === node.id) neighborIds.add(srcId);
|
|
});
|
|
|
|
nodeGroup.selectAll('.node').style('opacity', d =>
|
|
neighborIds.has(d.id) ? 1 : 0.2
|
|
);
|
|
|
|
linkGroup.selectAll('line').style('opacity', d => {
|
|
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
return srcId === node.id || tgtId === node.id ? 1 : 0.1;
|
|
});
|
|
}
|
|
|
|
function clearHighlight() {
|
|
currentHighlight = null;
|
|
nodeGroup.selectAll('.node').style('opacity', 1);
|
|
linkGroup.selectAll('line').style('opacity', 1);
|
|
}
|
|
|
|
function showLinkLabels(visible, specificLink = null) {
|
|
labelGroup.selectAll('text')
|
|
.classed('visible', d => {
|
|
if (!visible) return false;
|
|
if (specificLink) return d === specificLink;
|
|
if (currentHighlight) {
|
|
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
return srcId === currentHighlight.id || tgtId === currentHighlight.id;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function showTooltip(node, event) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
const typeColor = TYPE_COLORS[node.type] || '#8b949e';
|
|
|
|
let html = `
|
|
<h3>${formatValue(node.name || node.id)}</h3>
|
|
<div class="prop">
|
|
<span class="prop-key">ID</span>
|
|
<span class="prop-value">${formatValue(node.id)}</span>
|
|
</div>
|
|
<div class="prop">
|
|
<span class="prop-key">类型</span>
|
|
<span class="prop-value">${formatValue(node.type)}</span>
|
|
</div>
|
|
<div class="prop">
|
|
<span class="prop-key">命名空间</span>
|
|
<span class="prop-value">${formatValue(node.namespace)}</span>
|
|
</div>
|
|
<div class="prop">
|
|
<span class="prop-key">PageRank</span>
|
|
<span class="prop-value">${((node.pagerank || 0) * 100).toFixed(2)}%</span>
|
|
</div>
|
|
`;
|
|
|
|
if (node.properties && typeof node.properties === 'object') {
|
|
html += '<div style="margin-top:8px;border-top:1px solid #30363d;padding-top:8px;">';
|
|
for (const [k, v] of Object.entries(node.properties)) {
|
|
html += `<div class="prop">
|
|
<span class="prop-key">${formatValue(k)}</span>
|
|
<span class="prop-value">${formatValue(v)}</span>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += `<span class="type-badge" style="background:${typeColor}20;color:${typeColor}">${formatValue(node.type)}</span>`;
|
|
|
|
tooltip.innerHTML = html;
|
|
tooltip.style.display = 'block';
|
|
tooltip.style.left = Math.min(event.clientX + 16, window.innerWidth - 340) + 'px';
|
|
tooltip.style.top = Math.min(event.clientY + 16, window.innerHeight - tooltip.offsetHeight - 20) + 'px';
|
|
}
|
|
|
|
function hideTooltip() {
|
|
document.getElementById('tooltip').style.display = 'none';
|
|
}
|
|
|
|
function dragstarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
|
|
// 初始化
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initSVG();
|
|
loadGraph();
|
|
|
|
document.getElementById('namespace-select').addEventListener('change', (e) => {
|
|
loadGraph(e.target.value);
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
const container = document.getElementById('graph-container');
|
|
width = container.clientWidth;
|
|
height = container.clientHeight;
|
|
svg.attr('width', width).attr('height', height);
|
|
if (simulation) {
|
|
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |