316 lines
15 KiB
HTML
316 lines
15 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>Fischer AgentKit - Knowledge Graph Dashboard</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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; overflow: hidden; height: 100vh; }
|
|
.header { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #334155; }
|
|
.header h1 { font-size: 18px; font-weight: 600; background: linear-gradient(135deg, #60a5fa, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
.stats { display: flex; gap: 16px; font-size: 13px; color: #94a3b8; }
|
|
.stats span { padding: 4px 10px; background: #1e293b; border-radius: 6px; border: 1px solid #334155; }
|
|
.main { display: flex; height: calc(100vh - 52px); }
|
|
.sidebar { width: 340px; background: #1e293b; border-right: 1px solid #334155; display: flex; flex-direction: column; overflow: hidden; }
|
|
.search-box { padding: 12px; border-bottom: 1px solid #334155; }
|
|
.search-box input { width: 100%; padding: 8px 12px; background: #0f172a; border: 1px solid #475569; border-radius: 8px; color: #e2e8f0; font-size: 14px; outline: none; }
|
|
.search-box input:focus { border-color: #60a5fa; }
|
|
.tabs { display: flex; border-bottom: 1px solid #334155; }
|
|
.tab { flex: 1; padding: 8px; text-align: center; font-size: 13px; cursor: pointer; color: #94a3b8; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
|
.tab.active { color: #60a5fa; border-bottom-color: #60a5fa; }
|
|
.tab:hover { color: #e2e8f0; }
|
|
.content { flex: 1; overflow-y: auto; padding: 8px; }
|
|
.content::-webkit-scrollbar { width: 6px; }
|
|
.content::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
|
.node-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; transition: background 0.15s; border: 1px solid transparent; }
|
|
.node-item:hover { background: #334155; border-color: #475569; }
|
|
.node-item.selected { background: #1e3a5f; border-color: #60a5fa; }
|
|
.node-item .name { font-size: 13px; font-weight: 500; color: #e2e8f0; }
|
|
.node-item .meta { font-size: 11px; color: #64748b; margin-top: 2px; }
|
|
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 6px; }
|
|
.badge.file { background: #1e3a5f; color: #60a5fa; }
|
|
.badge.class { background: #3b1f5e; color: #c084fc; }
|
|
.badge.function { background: #1a3d2e; color: #4ade80; }
|
|
.badge.api { background: #7c2d12; color: #fb923c; }
|
|
.badge.service { background: #1e3a5f; color: #60a5fa; }
|
|
.badge.data { background: #3b1f5e; color: #c084fc; }
|
|
.badge.utility { background: #1a3d2e; color: #4ade80; }
|
|
.graph-container { flex: 1; position: relative; }
|
|
#graph { width: 100%; height: 100%; }
|
|
.detail-panel { position: absolute; right: 16px; top: 16px; width: 320px; background: rgba(30,41,59,0.95); border: 1px solid #475569; border-radius: 12px; padding: 16px; display: none; backdrop-filter: blur(12px); max-height: calc(100vh - 100px); overflow-y: auto; }
|
|
.detail-panel.show { display: block; }
|
|
.detail-panel h3 { font-size: 15px; color: #60a5fa; margin-bottom: 8px; }
|
|
.detail-panel .summary { font-size: 13px; color: #cbd5e1; line-height: 1.5; margin-bottom: 12px; }
|
|
.detail-panel .field { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
|
|
.detail-panel .field strong { color: #e2e8f0; }
|
|
.detail-panel .edges-list { margin-top: 8px; }
|
|
.detail-panel .edge-item { font-size: 12px; padding: 4px 8px; background: #0f172a; border-radius: 4px; margin-bottom: 3px; cursor: pointer; }
|
|
.detail-panel .edge-item:hover { background: #334155; }
|
|
.tour-panel { padding: 8px; }
|
|
.tour-card { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; }
|
|
.tour-card:hover { border-color: #60a5fa; }
|
|
.tour-card h4 { font-size: 13px; color: #e2e8f0; margin-bottom: 4px; }
|
|
.tour-card p { font-size: 11px; color: #64748b; }
|
|
.tour-steps { margin-top: 8px; }
|
|
.tour-step { display: flex; align-items: center; gap: 8px; padding: 6px 8px; font-size: 12px; color: #94a3b8; border-radius: 4px; cursor: pointer; }
|
|
.tour-step:hover { background: #334155; color: #e2e8f0; }
|
|
.tour-step .num { width: 20px; height: 20px; background: #334155; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #60a5fa; flex-shrink: 0; }
|
|
.layer-filter { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 12px; border-bottom: 1px solid #334155; }
|
|
.layer-btn { padding: 3px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; border: 1px solid #475569; background: transparent; color: #94a3b8; transition: all 0.15s; }
|
|
.layer-btn.active { background: #334155; color: #e2e8f0; border-color: #60a5fa; }
|
|
.close-btn { position: absolute; top: 8px; right: 8px; background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 16px; }
|
|
.close-btn:hover { color: #e2e8f0; }
|
|
.loading { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; color: #60a5fa; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Fischer AgentKit Knowledge Graph</h1>
|
|
<div class="stats" id="stats"></div>
|
|
</div>
|
|
<div class="main">
|
|
<div class="sidebar">
|
|
<div class="search-box">
|
|
<input type="text" id="search" placeholder="Search nodes..." />
|
|
</div>
|
|
<div class="layer-filter" id="layerFilter"></div>
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="nodes">Nodes</div>
|
|
<div class="tab" data-tab="tours">Tours</div>
|
|
</div>
|
|
<div class="content" id="nodeList"></div>
|
|
<div class="content" id="tourList" style="display:none"></div>
|
|
</div>
|
|
<div class="graph-container">
|
|
<div id="graph"></div>
|
|
<div class="detail-panel" id="detailPanel">
|
|
<button class="close-btn" onclick="document.getElementById('detailPanel').classList.remove('show')">×</button>
|
|
<div id="detailContent"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let graphData = null;
|
|
let network = null;
|
|
let nodesDataset = null;
|
|
let edgesDataset = null;
|
|
let activeLayers = new Set(['api', 'service', 'data', 'utility', 'unknown']);
|
|
let selectedNodeId = null;
|
|
|
|
const LAYER_COLORS = {
|
|
api: { bg: '#fb923c', border: '#ea580c', highlight: '#fdba74' },
|
|
service: { bg: '#60a5fa', border: '#2563eb', highlight: '#93c5fd' },
|
|
data: { bg: '#c084fc', border: '#9333ea', highlight: '#d8b4fe' },
|
|
utility: { bg: '#4ade80', border: '#16a34a', highlight: '#86efac' },
|
|
unknown: { bg: '#94a3b8', border: '#64748b', highlight: '#cbd5e1' }
|
|
};
|
|
|
|
const TYPE_SHAPES = { file: 'box', class: 'diamond', function: 'dot' };
|
|
|
|
async function loadGraph() {
|
|
try {
|
|
const resp = await fetch('knowledge-graph.json');
|
|
graphData = await resp.json();
|
|
initDashboard();
|
|
} catch(e) {
|
|
document.getElementById('graph').innerHTML = '<div class="loading">Failed to load knowledge-graph.json</div>';
|
|
}
|
|
}
|
|
|
|
function initDashboard() {
|
|
const { nodes, edges, tours, project } = graphData;
|
|
document.getElementById('stats').innerHTML = `
|
|
<span>${nodes.length} Nodes</span>
|
|
<span>${edges.length} Edges</span>
|
|
<span>${tours.length} Tours</span>
|
|
<span>${project.languages.join(', ')}</span>
|
|
`;
|
|
renderLayerFilter(nodes);
|
|
renderNodeList(nodes);
|
|
renderTourList(tours);
|
|
initGraph(nodes, edges);
|
|
initSearch(nodes);
|
|
initTabs();
|
|
}
|
|
|
|
function renderLayerFilter(nodes) {
|
|
const layers = [...new Set(nodes.map(n => n.layer).filter(Boolean))];
|
|
const container = document.getElementById('layerFilter');
|
|
container.innerHTML = layers.map(l =>
|
|
`<button class="layer-btn active" data-layer="${l}">${l}</button>`
|
|
).join('');
|
|
container.querySelectorAll('.layer-btn').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const layer = btn.dataset.layer;
|
|
if (activeLayers.has(layer)) { activeLayers.delete(layer); btn.classList.remove('active'); }
|
|
else { activeLayers.add(layer); btn.classList.add('active'); }
|
|
filterGraph();
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderNodeList(nodes) {
|
|
const container = document.getElementById('nodeList');
|
|
const filtered = nodes.filter(n => activeLayers.has(n.layer));
|
|
container.innerHTML = filtered.slice(0, 200).map(n => `
|
|
<div class="node-item" data-id="${n.id}">
|
|
<div class="name">${n.name} <span class="badge ${n.type}">${n.type}</span> <span class="badge ${n.layer}">${n.layer}</span></div>
|
|
<div class="meta">${n.filePath || ''}</div>
|
|
</div>
|
|
`).join('');
|
|
container.querySelectorAll('.node-item').forEach(el => {
|
|
el.onclick = () => focusNode(el.dataset.id);
|
|
});
|
|
}
|
|
|
|
function renderTourList(tours) {
|
|
const container = document.getElementById('tourList');
|
|
container.innerHTML = tours.map((t, i) => `
|
|
<div class="tour-card" data-tour="${i}">
|
|
<h4>${t.name}</h4>
|
|
<p>${t.description}</p>
|
|
<div class="tour-steps">
|
|
${t.steps.map((s, j) => `
|
|
<div class="tour-step" data-node="${s.nodeId}">
|
|
<span class="num">${j+1}</span>
|
|
<span>${s.why || s.nodeId}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
container.querySelectorAll('.tour-step').forEach(el => {
|
|
el.onclick = (e) => { e.stopPropagation(); focusNode(el.dataset.node); };
|
|
});
|
|
}
|
|
|
|
function initGraph(nodes, edges) {
|
|
const visNodes = nodes.filter(n => activeLayers.has(n.layer)).map(n => {
|
|
const colors = LAYER_COLORS[n.layer] || LAYER_COLORS.unknown;
|
|
return {
|
|
id: n.id,
|
|
label: n.name,
|
|
shape: TYPE_SHAPES[n.type] || 'dot',
|
|
color: colors,
|
|
font: { color: '#e2e8f0', size: n.type === 'file' ? 12 : 10 },
|
|
size: n.type === 'file' ? 20 : n.type === 'class' ? 15 : 8,
|
|
title: `${n.name}\n${n.summary || ''}\n[${n.layer}]`,
|
|
...n
|
|
};
|
|
});
|
|
|
|
const nodeIds = new Set(visNodes.map(n => n.id));
|
|
const visEdges = edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)).map((e, i) => ({
|
|
id: e.id || `edge-${i}`,
|
|
from: e.source,
|
|
to: e.target,
|
|
arrows: e.type === 'contains' ? '' : 'to',
|
|
color: { color: '#334155', highlight: '#60a5fa', hover: '#475569' },
|
|
width: 0.5,
|
|
dashes: e.type === 'imports',
|
|
title: e.type
|
|
}));
|
|
|
|
nodesDataset = new vis.DataSet(visNodes);
|
|
edgesDataset = new vis.DataSet(visEdges);
|
|
|
|
const container = document.getElementById('graph');
|
|
const data = { nodes: nodesDataset, edges: edgesDataset };
|
|
const options = {
|
|
physics: { barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 80, springConstant: 0.04 }, stabilization: { iterations: 100 } },
|
|
interaction: { hover: true, tooltipDelay: 200, navigationButtons: true, keyboard: true },
|
|
layout: { improvedLayout: true }
|
|
};
|
|
network = new vis.Network(container, data, options);
|
|
network.on('click', params => {
|
|
if (params.nodes.length > 0) focusNode(params.nodes[0]);
|
|
});
|
|
}
|
|
|
|
function filterGraph() {
|
|
const nodes = graphData.nodes.filter(n => activeLayers.has(n.layer));
|
|
renderNodeList(nodes);
|
|
if (nodesDataset) {
|
|
const nodeIds = new Set(nodes.map(n => n.id));
|
|
const edges = graphData.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
|
|
nodesDataset.clear();
|
|
nodesDataset.add(nodes.map(n => {
|
|
const colors = LAYER_COLORS[n.layer] || LAYER_COLORS.unknown;
|
|
return { id: n.id, label: n.name, shape: TYPE_SHAPES[n.type] || 'dot', color: colors, font: { color: '#e2e8f0', size: n.type === 'file' ? 12 : 10 }, size: n.type === 'file' ? 20 : n.type === 'class' ? 15 : 8, title: `${n.name}\n${n.summary || ''}\n[${n.layer}]`, ...n };
|
|
}));
|
|
edgesDataset.clear();
|
|
edgesDataset.add(edges.map((e, i) => ({ id: e.id || `edge-${i}`, from: e.source, to: e.target, arrows: e.type === 'contains' ? '' : 'to', color: { color: '#334155', highlight: '#60a5fa', hover: '#475569' }, width: 0.5, dashes: e.type === 'imports', title: e.type })));
|
|
}
|
|
}
|
|
|
|
function focusNode(nodeId) {
|
|
selectedNodeId = nodeId;
|
|
const node = graphData.nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
|
|
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('selected'));
|
|
const el = document.querySelector(`.node-item[data-id="${nodeId}"]`);
|
|
if (el) { el.classList.add('selected'); el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
|
|
|
|
if (network) {
|
|
network.focus(nodeId, { scale: 1.5, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
|
|
network.selectNodes([nodeId]);
|
|
}
|
|
|
|
const inEdges = graphData.edges.filter(e => e.target === nodeId);
|
|
const outEdges = graphData.edges.filter(e => e.source === nodeId);
|
|
|
|
const panel = document.getElementById('detailPanel');
|
|
const content = document.getElementById('detailContent');
|
|
content.innerHTML = `
|
|
<h3>${node.name}</h3>
|
|
<div class="summary">${node.summary || 'No summary'}</div>
|
|
<div class="field"><strong>Type:</strong> ${node.type}</div>
|
|
<div class="field"><strong>Layer:</strong> ${node.layer}</div>
|
|
<div class="field"><strong>Path:</strong> ${node.filePath || '-'}</div>
|
|
<div class="field"><strong>Complexity:</strong> ${node.complexity || '-'}</div>
|
|
${node.tags ? `<div class="field"><strong>Tags:</strong> ${node.tags.join(', ')}</div>` : ''}
|
|
${inEdges.length > 0 ? `<div class="edges-list"><strong>Incoming (${inEdges.length}):</strong>${inEdges.slice(0,10).map(e => `<div class="edge-item" onclick="focusNode('${e.source}')">${e.type} ← ${e.source.split(':').pop()}</div>`).join('')}</div>` : ''}
|
|
${outEdges.length > 0 ? `<div class="edges-list"><strong>Outgoing (${outEdges.length}):</strong>${outEdges.slice(0,10).map(e => `<div class="edge-item" onclick="focusNode('${e.target}')">${e.type} → ${e.target.split(':').pop()}</div>`).join('')}</div>` : ''}
|
|
`;
|
|
panel.classList.add('show');
|
|
}
|
|
|
|
function initSearch(nodes) {
|
|
const input = document.getElementById('search');
|
|
input.oninput = () => {
|
|
const q = input.value.toLowerCase();
|
|
const filtered = nodes.filter(n =>
|
|
activeLayers.has(n.layer) &&
|
|
(n.name.toLowerCase().includes(q) || (n.summary || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q))
|
|
);
|
|
renderNodeList(filtered);
|
|
if (q.length > 1 && network) {
|
|
const matchIds = filtered.map(n => n.id);
|
|
if (matchIds.length > 0 && matchIds.length < 50) {
|
|
network.fit({ nodes: matchIds, animation: { duration: 500 } });
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function initTabs() {
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.onclick = () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const target = tab.dataset.tab;
|
|
document.getElementById('nodeList').style.display = target === 'nodes' ? 'block' : 'none';
|
|
document.getElementById('tourList').style.display = target === 'tours' ? 'block' : 'none';
|
|
};
|
|
});
|
|
}
|
|
|
|
loadGraph();
|
|
</script>
|
|
</body>
|
|
</html>
|