memoryweave/go/static/graph.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 = 'http://100.65.23.30:7821';
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)}`
: `${API_BASE}/api/v1/graph/export`;
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>