Skip to content

Commit 87af269

Browse files
Skiipy11claude
andcommitted
feat: graph browser index page, full brain view, query-param auth
Three major improvements to the entity graph visualization: 1. **Graph Index Page** (`GET /graph/html`) — Browse all 460 entities grouped by type (agent, client, technology, system, domain, workflow, service, person) with mention counts and search. Click any entity to open its interactive D3.js graph. 2. **Full Brain Graph** (`GET /graph/full/html`) — Top 80 entities by mention count with all their co-occurrence connections (up to 300 edges). Shows the entire knowledge landscape at a glance. 3. **Query-param auth** — All endpoints now accept `?key=API_KEY` in addition to the `X-Api-Key` header. This makes the graph accessible from a browser URL bar without needing custom headers. All three views inherit the existing dark theme, D3.js force-directed layout, search, click-to-highlight, info panel, and PNG export. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b8f0194 commit 87af269

2 files changed

Lines changed: 294 additions & 3 deletions

File tree

api/src/middleware/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function authMiddleware(req, res, next) {
5959
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
6060
}
6161

62-
const key = req.headers['x-api-key'];
62+
const key = req.headers['x-api-key'] || req.query.key;
6363
if (!key) {
6464
recordFailure(ip);
6565
return res.status(401).json({ error: 'Missing API key' });

api/src/routes/graph.js

Lines changed: 293 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,86 @@
11
import { Router } from 'express';
2-
import { readFileSync } from 'fs';
2+
import { readFileSync, existsSync } from 'fs';
33
import { fileURLToPath } from 'url';
44
import { dirname, join } from 'path';
55
import {
6-
isEntityStoreAvailable, findEntity, getEntityMemories, _getStoreInstance,
6+
isEntityStoreAvailable, findEntity, listEntities, getEntityMemories, getEntityStats, _getStoreInstance,
77
} from '../services/stores/interface.js';
88

99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const graphTemplate = readFileSync(join(__dirname, '../templates/graph.html'), 'utf-8');
11+
const indexTemplatePath = join(__dirname, '../templates/graph-index.html');
12+
const indexTemplate = existsSync(indexTemplatePath) ? readFileSync(indexTemplatePath, 'utf-8') : null;
1113

1214
export const graphRouter = Router();
1315

1416
// --- Routes ---
1517

18+
// GET /graph/html — Index page: browse all entities, link into individual graphs
19+
graphRouter.get('/html', async (req, res) => {
20+
try {
21+
if (!isEntityStoreAvailable()) {
22+
return res.status(400).send(
23+
'<h1>Entity store not available</h1><p>Graph visualization requires sqlite or postgres backend.</p>'
24+
);
25+
}
26+
27+
const stats = await getEntityStats();
28+
const allEntities = await listEntities({ limit: 500 });
29+
const apiKey = req.query.key || '';
30+
31+
// Group entities by type
32+
const grouped = {};
33+
for (const e of allEntities.results) {
34+
if (!grouped[e.entity_type]) grouped[e.entity_type] = [];
35+
grouped[e.entity_type].push(e);
36+
}
37+
// Sort each group by mention_count desc
38+
for (const type of Object.keys(grouped)) {
39+
grouped[type].sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0));
40+
}
41+
42+
if (indexTemplate) {
43+
const html = indexTemplate
44+
.replace('{{ENTITIES_DATA}}', JSON.stringify(grouped))
45+
.replace('{{STATS_DATA}}', JSON.stringify(stats))
46+
.replace(/\{\{API_KEY\}\}/g, escapeHtml(apiKey));
47+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
48+
return res.send(html);
49+
}
50+
51+
// Fallback: simple HTML if template doesn't exist yet
52+
let html = buildFallbackIndex(grouped, stats, apiKey);
53+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
54+
res.send(html);
55+
} catch (err) {
56+
console.error('[graph:index] Error:', err.message);
57+
res.status(500).send(`<h1>Error</h1><p>${escapeHtml(err.message)}</p>`);
58+
}
59+
});
60+
61+
// GET /graph/full/html — Full brain graph (all entities)
62+
graphRouter.get('/full/html', async (req, res) => {
63+
try {
64+
if (!isEntityStoreAvailable()) {
65+
return res.status(400).send(
66+
'<h1>Entity store not available</h1><p>Graph visualization requires sqlite or postgres backend.</p>'
67+
);
68+
}
69+
70+
const graphData = await buildFullGraphData(80);
71+
72+
const html = graphTemplate
73+
.replace(/\{\{ENTITY_NAME\}\}/g, 'Full Brain')
74+
.replace('{{GRAPH_DATA}}', JSON.stringify(graphData));
75+
76+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
77+
res.send(html);
78+
} catch (err) {
79+
console.error('[graph:full] Error:', err.message);
80+
res.status(500).send(`<h1>Error</h1><p>${escapeHtml(err.message)}</p>`);
81+
}
82+
});
83+
1684
// GET /graph/:entity/html — Interactive D3.js visualization
1785
graphRouter.get('/:entity/html', async (req, res) => {
1886
try {
@@ -249,6 +317,229 @@ async function findCoOccurringViaInterface(entityId, limit) {
249317
return findCoOccurringDirect(store.db, entityId, limit);
250318
}
251319

320+
/**
321+
* Build full brain graph — top N entities by mention count with their connections.
322+
*/
323+
async function buildFullGraphData(limit = 80) {
324+
const store = _getStoreInstance();
325+
if (!store) return { nodes: [], edges: [], center: 'Brain' };
326+
327+
let topEntities;
328+
if (store.pool) {
329+
// Postgres
330+
const result = await store.pool.query(
331+
`SELECT id, canonical_name, entity_type, mention_count
332+
FROM entities ORDER BY mention_count DESC LIMIT $1`, [limit]
333+
);
334+
topEntities = result.rows;
335+
} else if (store.db) {
336+
// SQLite
337+
topEntities = store.db.prepare(
338+
`SELECT id, canonical_name, entity_type, mention_count
339+
FROM entities ORDER BY mention_count DESC LIMIT ?`
340+
).all(limit);
341+
} else {
342+
return { nodes: [], edges: [], center: 'Brain' };
343+
}
344+
345+
const entityIds = topEntities.map(e => e.id);
346+
const entityIdSet = new Set(entityIds);
347+
348+
const nodes = topEntities.map(e => ({
349+
id: e.canonical_name,
350+
type: e.entity_type,
351+
mention_count: e.mention_count || 1,
352+
}));
353+
354+
// Get edges between these entities
355+
const edgeMap = new Map();
356+
357+
if (store.pool) {
358+
// Postgres: find co-occurrences between top entities
359+
const result = await store.pool.query(`
360+
SELECT e1.canonical_name as source_name, e2.canonical_name as target_name,
361+
COUNT(DISTINCT eml1.memory_id) as strength
362+
FROM entity_memory_links eml1
363+
JOIN entity_memory_links eml2 ON eml1.memory_id = eml2.memory_id AND eml1.entity_id < eml2.entity_id
364+
JOIN entities e1 ON e1.id = eml1.entity_id
365+
JOIN entities e2 ON e2.id = eml2.entity_id
366+
WHERE eml1.entity_id = ANY($1) AND eml2.entity_id = ANY($1)
367+
GROUP BY e1.canonical_name, e2.canonical_name
368+
HAVING COUNT(DISTINCT eml1.memory_id) >= 2
369+
ORDER BY strength DESC
370+
LIMIT 300
371+
`, [entityIds]);
372+
373+
for (const row of result.rows) {
374+
edgeMap.set(`${row.source_name}||${row.target_name}`, {
375+
source: row.source_name,
376+
target: row.target_name,
377+
type: 'co_occurrence',
378+
strength: parseInt(row.strength),
379+
});
380+
}
381+
}
382+
383+
// Find the highest mention entity as center
384+
const center = topEntities.length > 0 ? topEntities[0].canonical_name : 'Brain';
385+
386+
return {
387+
nodes,
388+
edges: Array.from(edgeMap.values()),
389+
center,
390+
};
391+
}
392+
393+
/**
394+
* Fallback index HTML (used if graph-index.html template doesn't exist).
395+
*/
396+
function buildFallbackIndex(grouped, stats, apiKey) {
397+
const typeColors = {
398+
client: '#4ECDB8', person: '#F59E0B', technology: '#3B82F6',
399+
workflow: '#8B5CF6', agent: '#EF4444', domain: '#10B981',
400+
service: '#EC4899', system: '#6366F1',
401+
};
402+
403+
const keyParam = apiKey ? `?key=${encodeURIComponent(apiKey)}` : '';
404+
405+
let entityRows = '';
406+
const typeOrder = ['agent', 'client', 'technology', 'system', 'domain', 'workflow', 'service', 'person'];
407+
408+
for (const type of typeOrder) {
409+
const entities = grouped[type];
410+
if (!entities || entities.length === 0) continue;
411+
const color = typeColors[type] || '#94a3b8';
412+
413+
entityRows += `<div class="type-section">
414+
<h2 style="color:${color}"><span class="type-dot" style="background:${color}"></span>${type} <span class="count">(${entities.length})</span></h2>
415+
<div class="entity-grid">`;
416+
417+
for (const e of entities.slice(0, 30)) {
418+
entityRows += `
419+
<a href="/graph/${encodeURIComponent(e.canonical_name)}/html${keyParam}" class="entity-card">
420+
<span class="name">${escapeHtml(e.canonical_name)}</span>
421+
<span class="mentions">${e.mention_count || 0}</span>
422+
</a>`;
423+
}
424+
if (entities.length > 30) {
425+
entityRows += `<span class="more">+${entities.length - 30} more</span>`;
426+
}
427+
entityRows += `</div></div>`;
428+
}
429+
430+
return `<!DOCTYPE html>
431+
<html lang="en">
432+
<head>
433+
<meta charset="UTF-8">
434+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
435+
<title>Knowledge Graph — Entity Browser</title>
436+
<style>
437+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
438+
* { margin: 0; padding: 0; box-sizing: border-box; }
439+
:root {
440+
--bg: #0a0a0f; --surface: rgba(255,255,255,0.04); --surface-hover: rgba(255,255,255,0.08);
441+
--border: rgba(255,255,255,0.08); --text: #e2e8f0; --text-muted: #94a3b8; --text-dim: #64748b;
442+
}
443+
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
444+
445+
.header {
446+
padding: 40px 40px 32px;
447+
border-bottom: 1px solid var(--border);
448+
background: linear-gradient(180deg, rgba(78,205,184,0.04) 0%, transparent 100%);
449+
}
450+
.header h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; margin-bottom: 8px; }
451+
.header p { color: var(--text-muted); font-size: 14px; }
452+
453+
.stats-row {
454+
display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap;
455+
}
456+
.stat-card {
457+
padding: 16px 24px; background: var(--surface); border: 1px solid var(--border);
458+
border-radius: 12px; min-width: 120px;
459+
}
460+
.stat-card .stat-value { font-size: 24px; font-weight: 700; }
461+
.stat-card .stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 4px; }
462+
463+
.actions { display: flex; gap: 12px; margin-top: 20px; }
464+
.action-btn {
465+
padding: 10px 20px; background: rgba(78,205,184,0.1); border: 1px solid rgba(78,205,184,0.3);
466+
border-radius: 10px; color: #4ECDB8; font-family: inherit; font-size: 13px; font-weight: 600;
467+
cursor: pointer; text-decoration: none; transition: all 0.2s ease;
468+
}
469+
.action-btn:hover { background: rgba(78,205,184,0.2); border-color: rgba(78,205,184,0.5); }
470+
.action-btn.secondary { background: var(--surface); border-color: var(--border); color: var(--text-muted); }
471+
.action-btn.secondary:hover { background: var(--surface-hover); color: var(--text); }
472+
473+
.content { padding: 32px 40px; }
474+
475+
.search-box {
476+
width: 100%; max-width: 400px; padding: 12px 16px 12px 40px;
477+
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
478+
color: var(--text); font-family: inherit; font-size: 14px; outline: none;
479+
margin-bottom: 32px; transition: all 0.3s ease;
480+
}
481+
.search-box:focus { border-color: rgba(78,205,184,0.4); box-shadow: 0 0 20px rgba(78,205,184,0.08); }
482+
.search-box::placeholder { color: var(--text-dim); }
483+
484+
.type-section { margin-bottom: 32px; }
485+
.type-section h2 {
486+
font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
487+
margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
488+
}
489+
.type-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
490+
.count { font-weight: 400; color: var(--text-dim); font-size: 12px; }
491+
492+
.entity-grid { display: flex; flex-wrap: wrap; gap: 8px; }
493+
.entity-card {
494+
display: flex; align-items: center; gap: 10px;
495+
padding: 8px 14px; background: var(--surface); border: 1px solid var(--border);
496+
border-radius: 8px; text-decoration: none; color: var(--text); font-size: 13px;
497+
transition: all 0.2s ease; cursor: pointer;
498+
}
499+
.entity-card:hover { background: var(--surface-hover); border-color: rgba(255,255,255,0.15); transform: translateY(-1px); }
500+
.entity-card .name { font-weight: 500; }
501+
.entity-card .mentions {
502+
font-size: 10px; background: rgba(255,255,255,0.08); padding: 2px 8px;
503+
border-radius: 10px; color: var(--text-dim); font-weight: 600;
504+
}
505+
.more { font-size: 12px; color: var(--text-dim); padding: 8px 14px; }
506+
507+
.footer {
508+
padding: 32px 40px; border-top: 1px solid var(--border); text-align: center;
509+
font-size: 11px; color: var(--text-dim);
510+
}
511+
</style>
512+
</head>
513+
<body>
514+
<div class="header">
515+
<h1>Knowledge Graph</h1>
516+
<p>Browse and explore entity relationships in the Shared Brain</p>
517+
<div class="stats-row">
518+
<div class="stat-card"><div class="stat-value">${stats.total || 0}</div><div class="stat-label">Entities</div></div>
519+
<div class="stat-card"><div class="stat-value">${Object.keys(grouped).length}</div><div class="stat-label">Types</div></div>
520+
</div>
521+
<div class="actions">
522+
<a href="/graph/full/html${keyParam}" class="action-btn">View Full Brain Graph</a>
523+
</div>
524+
</div>
525+
<div class="content">
526+
<input type="text" class="search-box" placeholder="Search entities..." id="search" autocomplete="off">
527+
${entityRows}
528+
</div>
529+
<div class="footer">Powered by Shared Brain &mdash; Multi-Agent Memory</div>
530+
<script>
531+
document.getElementById('search').addEventListener('input', function() {
532+
const q = this.value.toLowerCase();
533+
document.querySelectorAll('.entity-card').forEach(card => {
534+
const name = card.querySelector('.name').textContent.toLowerCase();
535+
card.style.display = name.includes(q) ? '' : 'none';
536+
});
537+
});
538+
</script>
539+
</body>
540+
</html>`;
541+
}
542+
252543
// --- Utilities ---
253544

254545
function escapeHtml(str) {

0 commit comments

Comments
 (0)