Add Cortex Portal — web visualization for knowledge graph

Express API server wrapping existing store/graph core with REST endpoints
for nodes, edges, graph, and search. React + Vite portal with React Flow
for interactive graph visualization, Tailwind CSS styling, and full CRUD UI
(sidebar, node panel, add/link modals, search bar, toast notifications).
This commit is contained in:
2026-02-02 17:01:29 +01:00
parent 21107443a7
commit 08c26754a8
25 changed files with 4566 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
import { Command } from 'commander';
export const serveCommand = new Command('serve')
.description('Start the Cortex Portal web server')
.option('-p, --port <number>', 'Port number', '3100')
.action((opts) => {
process.env.PORT = opts.port;
require('../../server/index');
});

View File

@@ -8,6 +8,7 @@ import { listCommand } from './commands/list';
import { updateCommand } from './commands/update';
import { removeCommand } from './commands/remove';
import { graphCommand } from './commands/graph';
import { serveCommand } from './commands/serve';
import { closeDb } from '../core/db';
const program = new Command();
@@ -25,6 +26,7 @@ program.addCommand(listCommand);
program.addCommand(updateCommand);
program.addCommand(removeCommand);
program.addCommand(graphCommand);
program.addCommand(serveCommand);
program.hook('postAction', () => {
closeDb();

29
src/server/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import routes from './routes';
import { closeDb } from '../core/db';
const app = express();
const PORT = parseInt(process.env.PORT || '3100');
app.use(cors());
app.use(express.json());
app.use('/api', routes);
// Serve portal static files in production
const portalDist = path.join(__dirname, '../../portal/dist');
app.use(express.static(portalDist));
app.get('{*path}', (_req, res) => {
res.sendFile(path.join(portalDist, 'index.html'));
});
const server = app.listen(PORT, () => {
console.log(`Cortex Portal running at http://localhost:${PORT}`);
});
process.on('SIGINT', () => {
closeDb();
server.close();
process.exit(0);
});

136
src/server/routes.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Router, Request, Response } from 'express';
import { addNode, getNode, listNodes, updateNode, removeNode, addEdge, removeEdge, query } from '../core/store';
import { getConnections, buildTree } from '../core/graph';
import { getDb } from '../core/db';
const router = Router();
function param(req: Request, name: string): string {
const v = req.params[name];
return Array.isArray(v) ? v[0] : v;
}
// List nodes
router.get('/nodes', (req: Request, res: Response) => {
try {
const options: any = {};
if (req.query.kind) options.kind = req.query.kind as string;
if (req.query.status) options.status = req.query.status as string;
if (req.query.tags) options.tags = (req.query.tags as string).split(',');
if (req.query.limit) options.limit = parseInt(req.query.limit as string);
if (req.query.includeStale === 'true') options.includeStale = true;
const nodes = listNodes(options);
res.json(nodes.map(n => ({ ...n, embedding: undefined })));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get single node + connections
router.get('/nodes/:id', (req: Request, res: Response) => {
try {
const node = getNode(param(req, 'id'));
if (!node) return res.status(404).json({ error: 'Node not found' });
const connections = getConnections(param(req, 'id'));
res.json({ ...node, embedding: undefined, connections });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Add node
router.post('/nodes', async (req: Request, res: Response) => {
try {
const node = await addNode(req.body);
res.status(201).json({ ...node, embedding: undefined });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// Update node
router.patch('/nodes/:id', async (req: Request, res: Response) => {
try {
const node = await updateNode(param(req, 'id'), req.body);
if (!node) return res.status(404).json({ error: 'Node not found' });
res.json({ ...node, embedding: undefined });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// Delete node
router.delete('/nodes/:id', (req: Request, res: Response) => {
try {
const hard = req.query.hard === 'true';
const ok = removeNode(param(req, 'id'), hard);
if (!ok) return res.status(404).json({ error: 'Node not found' });
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Add edge
router.post('/edges', (req: Request, res: Response) => {
try {
const { fromId, toId, type, metadata } = req.body;
const edge = addEdge(fromId, toId, type, metadata);
res.status(201).json(edge);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// Delete edge
router.delete('/edges/:id', (req: Request, res: Response) => {
try {
const ok = removeEdge(param(req, 'id'));
if (!ok) return res.status(404).json({ error: 'Edge not found' });
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Graph — returns nodes + edges for React Flow
router.get('/graph', (_req: Request, res: Response) => {
try {
const db = getDb();
const nodes = (db.prepare('SELECT * FROM nodes WHERE is_stale = 0').all() as any[]).map(row => ({
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
const edges = (db.prepare('SELECT * FROM edges').all() as any[]).map(row => ({
id: row.id,
fromId: row.from_id,
toId: row.to_id,
type: row.type,
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
}));
res.json({ nodes, edges });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Search
router.post('/search', async (req: Request, res: Response) => {
try {
const { text, options } = req.body;
const results = await query(text, options || {});
res.json(results.map(r => ({ ...r, node: { ...r.node, embedding: undefined } })));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;