Add graph visualization exports (Milestone 7)
- Add interactive HTML export with D3.js force-directed graph - Add SVG export with simple force-directed layout - Add Mermaid diagram export for documentation - Support subgraph export from root node with depth - Add node filtering by kind and tags - Add light/dark theme support - Add CLI commands: export, viz - Add MCP tool: memory_export
This commit is contained in:
84
src/cli/commands/export.ts
Normal file
84
src/cli/commands/export.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { exportGraph, ExportFormat } from '../../core/export';
|
||||||
|
|
||||||
|
export const exportCommand = new Command('export')
|
||||||
|
.description('Export the knowledge graph as HTML, SVG, or Mermaid')
|
||||||
|
.argument('[rootId]', 'Root node ID for subgraph export')
|
||||||
|
.option('-f, --format <format>', 'Output format: html, svg, mermaid', 'html')
|
||||||
|
.option('-o, --output <file>', 'Output file path')
|
||||||
|
.option('-d, --depth <n>', 'Depth for subgraph export', '3')
|
||||||
|
.option('-k, --kind <kind>', 'Filter by node kind')
|
||||||
|
.option('-t, --tags <tags>', 'Filter by tags (comma-separated)')
|
||||||
|
.option('--theme <theme>', 'Theme: light or dark', 'dark')
|
||||||
|
.option('--width <n>', 'Width for SVG', '800')
|
||||||
|
.option('--height <n>', 'Height for SVG', '600')
|
||||||
|
.option('--direction <dir>', 'Mermaid direction: TD, LR, BT, RL', 'TD')
|
||||||
|
.option('--title <title>', 'Title for HTML export')
|
||||||
|
.action(async (rootId: string | undefined, opts) => {
|
||||||
|
try {
|
||||||
|
const format = opts.format.toLowerCase() as ExportFormat;
|
||||||
|
if (!['html', 'svg', 'mermaid'].includes(format)) {
|
||||||
|
console.error(chalk.red(`Invalid format: ${format}. Use html, svg, or mermaid.`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.cyan(`Exporting graph as ${format}...`));
|
||||||
|
|
||||||
|
const content = await exportGraph({
|
||||||
|
format,
|
||||||
|
rootId,
|
||||||
|
depth: parseInt(opts.depth),
|
||||||
|
kind: opts.kind,
|
||||||
|
tags: opts.tags?.split(',').map((t: string) => t.trim()),
|
||||||
|
theme: opts.theme,
|
||||||
|
width: parseInt(opts.width),
|
||||||
|
height: parseInt(opts.height),
|
||||||
|
direction: opts.direction,
|
||||||
|
title: opts.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.output) {
|
||||||
|
fs.writeFileSync(opts.output, content);
|
||||||
|
console.log(chalk.green(`✓ Exported to ${opts.output}`));
|
||||||
|
|
||||||
|
// Show file size
|
||||||
|
const stats = fs.statSync(opts.output);
|
||||||
|
console.log(chalk.dim(` Size: ${(stats.size / 1024).toFixed(1)} KB`));
|
||||||
|
} else {
|
||||||
|
// Output to stdout
|
||||||
|
console.log(content);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red(`Error: ${err.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shorthand for HTML visualization
|
||||||
|
export const vizCommand = new Command('viz')
|
||||||
|
.description('Export interactive HTML visualization')
|
||||||
|
.argument('[rootId]', 'Root node ID for subgraph')
|
||||||
|
.option('-o, --output <file>', 'Output file', 'graph.html')
|
||||||
|
.option('--theme <theme>', 'Theme: light or dark', 'dark')
|
||||||
|
.option('-d, --depth <n>', 'Depth for subgraph', '3')
|
||||||
|
.action(async (rootId: string | undefined, opts) => {
|
||||||
|
try {
|
||||||
|
console.log(chalk.cyan('Generating visualization...'));
|
||||||
|
|
||||||
|
const content = await exportGraph({
|
||||||
|
format: 'html',
|
||||||
|
rootId,
|
||||||
|
depth: parseInt(opts.depth),
|
||||||
|
theme: opts.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(opts.output, content);
|
||||||
|
console.log(chalk.green(`✓ Created ${opts.output}`));
|
||||||
|
console.log(chalk.dim(` Open in browser to view interactive graph`));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red(`Error: ${err.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { contextCommand, contextHookCommand } from './commands/context';
|
|||||||
import { indexCommand } from './commands/index-cmd';
|
import { indexCommand } from './commands/index-cmd';
|
||||||
import { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal';
|
import { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal';
|
||||||
import { ingestCommand, clipCommand } from './commands/ingest';
|
import { ingestCommand, clipCommand } from './commands/ingest';
|
||||||
|
import { exportCommand, vizCommand } from './commands/export';
|
||||||
import { closeDb } from '../core/db';
|
import { closeDb } from '../core/db';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -53,6 +54,8 @@ program.addCommand(journalAliasCommand);
|
|||||||
program.addCommand(quickCaptureCommand);
|
program.addCommand(quickCaptureCommand);
|
||||||
program.addCommand(ingestCommand);
|
program.addCommand(ingestCommand);
|
||||||
program.addCommand(clipCommand);
|
program.addCommand(clipCommand);
|
||||||
|
program.addCommand(exportCommand);
|
||||||
|
program.addCommand(vizCommand);
|
||||||
|
|
||||||
program.hook('postAction', () => {
|
program.hook('postAction', () => {
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|||||||
368
src/core/export/html.ts
Normal file
368
src/core/export/html.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { listNodes } from '../store';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
|
||||||
|
export interface HtmlExportOptions {
|
||||||
|
rootId?: string;
|
||||||
|
depth?: number;
|
||||||
|
kind?: string;
|
||||||
|
tags?: string[];
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
layout?: 'force' | 'radial';
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
tags: string[];
|
||||||
|
group: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_GROUPS: Record<string, number> = {
|
||||||
|
component: 1,
|
||||||
|
decision: 2,
|
||||||
|
task: 3,
|
||||||
|
memory: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<string, { light: string; dark: string }> = {
|
||||||
|
component: { light: '#4CAF50', dark: '#81C784' },
|
||||||
|
decision: { light: '#2196F3', dark: '#64B5F6' },
|
||||||
|
task: { light: '#FF9800', dark: '#FFB74D' },
|
||||||
|
memory: { light: '#9C27B0', dark: '#BA68C8' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function exportHtml(options: HtmlExportOptions = {}): Promise<string> {
|
||||||
|
const data = await getGraphData(options);
|
||||||
|
return generateHtmlTemplate(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGraphData(options: HtmlExportOptions): Promise<GraphData> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Get nodes
|
||||||
|
let nodes: Node[];
|
||||||
|
if (options.rootId) {
|
||||||
|
nodes = getSubgraphNodes(options.rootId, options.depth || 3);
|
||||||
|
} else {
|
||||||
|
nodes = listNodes({
|
||||||
|
kind: options.kind as any,
|
||||||
|
tags: options.tags,
|
||||||
|
limit: 500,
|
||||||
|
includeStale: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get edges between these nodes
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
const edges = db.prepare(`
|
||||||
|
SELECT * FROM edges
|
||||||
|
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
`).all([...nodeIds, ...nodeIds]) as any[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
label: n.title.length > 40 ? n.title.slice(0, 37) + '...' : n.title,
|
||||||
|
kind: n.kind,
|
||||||
|
tags: n.tags,
|
||||||
|
group: KIND_GROUPS[n.kind] || 4,
|
||||||
|
})),
|
||||||
|
links: edges.map(e => ({
|
||||||
|
source: e.from_id,
|
||||||
|
target: e.to_id,
|
||||||
|
type: e.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubgraphNodes(rootId: string, maxDepth: number): Node[] {
|
||||||
|
const db = getDb();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
|
||||||
|
function traverse(id: string, depth: number) {
|
||||||
|
if (depth > maxDepth || visited.has(id)) return;
|
||||||
|
visited.add(id);
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
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 || '{}'),
|
||||||
|
embedding: null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
lastAccessedAt: row.last_accessed_at,
|
||||||
|
isStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get connected nodes
|
||||||
|
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
|
||||||
|
for (const edge of edges) {
|
||||||
|
traverse(edge.to_id, depth + 1);
|
||||||
|
}
|
||||||
|
const reverseEdges = db.prepare('SELECT from_id FROM edges WHERE to_id = ?').all(id) as any[];
|
||||||
|
for (const edge of reverseEdges) {
|
||||||
|
traverse(edge.from_id, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(rootId, 0);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHtmlTemplate(data: GraphData, options: HtmlExportOptions): string {
|
||||||
|
const theme = options.theme || 'dark';
|
||||||
|
const title = options.title || 'Cortex Knowledge Graph';
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: ${isDark ? '#1a1a2e' : '#f5f5f5'};
|
||||||
|
color: ${isDark ? '#eee' : '#333'};
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#graph { width: 100vw; height: 100vh; }
|
||||||
|
#sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 300px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
background: ${isDark ? '#16213e' : '#fff'};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#sidebar.active { display: block; }
|
||||||
|
#sidebar h3 { margin-bottom: 8px; font-size: 16px; }
|
||||||
|
#sidebar .kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#sidebar .content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#sidebar .tags { margin-top: 8px; }
|
||||||
|
#sidebar .tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: ${isDark ? '#0f3460' : '#e0e0e0'};
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
#legend {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: ${isDark ? '#16213e' : '#fff'};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#legend div { display: flex; align-items: center; margin: 4px 0; }
|
||||||
|
#legend .dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; }
|
||||||
|
#controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: ${isDark ? '#16213e' : '#fff'};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
#search {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid ${isDark ? '#333' : '#ddd'};
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${isDark ? '#1a1a2e' : '#fff'};
|
||||||
|
color: ${isDark ? '#eee' : '#333'};
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.node { cursor: pointer; }
|
||||||
|
.node:hover { filter: brightness(1.2); }
|
||||||
|
.link { stroke-opacity: 0.6; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
const data = ${JSON.stringify(data)};
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
const kindColors = ${JSON.stringify(isDark ?
|
||||||
|
Object.fromEntries(Object.entries(KIND_COLORS).map(([k, v]) => [k, v.dark])) :
|
||||||
|
Object.fromEntries(Object.entries(KIND_COLORS).map(([k, v]) => [k, v.light]))
|
||||||
|
)};
|
||||||
|
|
||||||
|
const svg = d3.select("#graph")
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", width)
|
||||||
|
.attr("height", height);
|
||||||
|
|
||||||
|
const g = svg.append("g");
|
||||||
|
|
||||||
|
// Zoom behavior
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.1, 4])
|
||||||
|
.on("zoom", (event) => g.attr("transform", event.transform));
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
// Arrow markers
|
||||||
|
svg.append("defs").selectAll("marker")
|
||||||
|
.data(["depends_on", "contains", "relates_to", "implements"])
|
||||||
|
.join("marker")
|
||||||
|
.attr("id", d => "arrow-" + d)
|
||||||
|
.attr("viewBox", "0 -5 10 10")
|
||||||
|
.attr("refX", 20)
|
||||||
|
.attr("refY", 0)
|
||||||
|
.attr("markerWidth", 6)
|
||||||
|
.attr("markerHeight", 6)
|
||||||
|
.attr("orient", "auto")
|
||||||
|
.append("path")
|
||||||
|
.attr("fill", "${isDark ? '#666' : '#999'}")
|
||||||
|
.attr("d", "M0,-5L10,0L0,5");
|
||||||
|
|
||||||
|
const simulation = d3.forceSimulation(data.nodes)
|
||||||
|
.force("link", d3.forceLink(data.links).id(d => d.id).distance(120))
|
||||||
|
.force("charge", d3.forceManyBody().strength(-400))
|
||||||
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force("collision", d3.forceCollide().radius(40));
|
||||||
|
|
||||||
|
const link = g.append("g")
|
||||||
|
.selectAll("line")
|
||||||
|
.data(data.links)
|
||||||
|
.join("line")
|
||||||
|
.attr("class", "link")
|
||||||
|
.attr("stroke", "${isDark ? '#444' : '#ccc'}")
|
||||||
|
.attr("stroke-width", 1.5)
|
||||||
|
.attr("marker-end", d => "url(#arrow-" + d.type + ")");
|
||||||
|
|
||||||
|
const node = g.append("g")
|
||||||
|
.selectAll("g")
|
||||||
|
.data(data.nodes)
|
||||||
|
.join("g")
|
||||||
|
.attr("class", "node")
|
||||||
|
.call(d3.drag()
|
||||||
|
.on("start", dragstarted)
|
||||||
|
.on("drag", dragged)
|
||||||
|
.on("end", dragended));
|
||||||
|
|
||||||
|
node.append("circle")
|
||||||
|
.attr("r", 12)
|
||||||
|
.attr("fill", d => kindColors[d.kind] || "#888");
|
||||||
|
|
||||||
|
node.append("text")
|
||||||
|
.text(d => d.label)
|
||||||
|
.attr("x", 16)
|
||||||
|
.attr("y", 4)
|
||||||
|
.attr("font-size", "11px")
|
||||||
|
.attr("fill", "${isDark ? '#ccc' : '#333'}");
|
||||||
|
|
||||||
|
node.on("click", (event, d) => {
|
||||||
|
const sidebar = document.getElementById("sidebar");
|
||||||
|
sidebar.classList.add("active");
|
||||||
|
sidebar.innerHTML = \`
|
||||||
|
<h3>\${d.label}</h3>
|
||||||
|
<span class="kind" style="background:\${kindColors[d.kind]}">\${d.kind}</span>
|
||||||
|
<div class="tags">\${d.tags.map(t => '<span class="tag">' + t + '</span>').join('')}</div>
|
||||||
|
<p style="margin-top:12px;font-size:11px;color:${isDark ? '#888' : '#666'}">ID: \${d.id.slice(0,8)}</p>
|
||||||
|
\`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
node.attr("transform", d => "translate(" + d.x + "," + d.y + ")");
|
||||||
|
});
|
||||||
|
|
||||||
|
function dragstarted(event) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
event.subject.fx = event.subject.x;
|
||||||
|
event.subject.fy = event.subject.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragged(event) {
|
||||||
|
event.subject.fx = event.x;
|
||||||
|
event.subject.fy = event.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragended(event) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
event.subject.fx = null;
|
||||||
|
event.subject.fy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
document.getElementById("search").addEventListener("input", (e) => {
|
||||||
|
const query = e.target.value.toLowerCase();
|
||||||
|
node.attr("opacity", d =>
|
||||||
|
query === "" || d.label.toLowerCase().includes(query) ? 1 : 0.2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close sidebar on click outside
|
||||||
|
svg.on("click", () => {
|
||||||
|
document.getElementById("sidebar").classList.remove("active");
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<style>${styles}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="graph"></div>
|
||||||
|
<div id="sidebar"></div>
|
||||||
|
<div id="controls">
|
||||||
|
<input type="text" id="search" placeholder="Search nodes...">
|
||||||
|
</div>
|
||||||
|
<div id="legend">
|
||||||
|
<div><span class="dot" style="background:${KIND_COLORS.component[theme]}"></span>Component</div>
|
||||||
|
<div><span class="dot" style="background:${KIND_COLORS.decision[theme]}"></span>Decision</div>
|
||||||
|
<div><span class="dot" style="background:${KIND_COLORS.task[theme]}"></span>Task</div>
|
||||||
|
<div><span class="dot" style="background:${KIND_COLORS.memory[theme]}"></span>Memory</div>
|
||||||
|
</div>
|
||||||
|
<script>${script}</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
34
src/core/export/index.ts
Normal file
34
src/core/export/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export { exportHtml, HtmlExportOptions } from './html';
|
||||||
|
export { exportMermaid, MermaidExportOptions } from './mermaid';
|
||||||
|
export { exportSvg, SvgExportOptions } from './svg';
|
||||||
|
|
||||||
|
export type ExportFormat = 'html' | 'mermaid' | 'svg';
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
format: ExportFormat;
|
||||||
|
rootId?: string;
|
||||||
|
depth?: number;
|
||||||
|
kind?: string;
|
||||||
|
tags?: string[];
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
direction?: 'TD' | 'LR' | 'BT' | 'RL';
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportGraph(options: ExportOptions): Promise<string> {
|
||||||
|
switch (options.format) {
|
||||||
|
case 'html':
|
||||||
|
const { exportHtml } = await import('./html');
|
||||||
|
return exportHtml(options);
|
||||||
|
case 'mermaid':
|
||||||
|
const { exportMermaid } = await import('./mermaid');
|
||||||
|
return exportMermaid(options);
|
||||||
|
case 'svg':
|
||||||
|
const { exportSvg } = await import('./svg');
|
||||||
|
return exportSvg(options);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown format: ${options.format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/core/export/mermaid.ts
Normal file
133
src/core/export/mermaid.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { listNodes } from '../store';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
|
||||||
|
export interface MermaidExportOptions {
|
||||||
|
rootId?: string;
|
||||||
|
depth?: number;
|
||||||
|
kind?: string;
|
||||||
|
tags?: string[];
|
||||||
|
direction?: 'TD' | 'LR' | 'BT' | 'RL';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportMermaid(options: MermaidExportOptions = {}): Promise<string> {
|
||||||
|
const db = getDb();
|
||||||
|
const direction = options.direction || 'TD';
|
||||||
|
|
||||||
|
// Get nodes
|
||||||
|
let nodes: Node[];
|
||||||
|
if (options.rootId) {
|
||||||
|
nodes = getSubgraphNodes(options.rootId, options.depth || 3, db);
|
||||||
|
} else {
|
||||||
|
nodes = listNodes({
|
||||||
|
kind: options.kind as any,
|
||||||
|
tags: options.tags,
|
||||||
|
limit: 100,
|
||||||
|
includeStale: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get edges between these nodes
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
const edges = db.prepare(`
|
||||||
|
SELECT * FROM edges
|
||||||
|
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
`).all([...nodeIds, ...nodeIds]) as any[];
|
||||||
|
|
||||||
|
const lines: string[] = [`graph ${direction}`];
|
||||||
|
|
||||||
|
// Generate short IDs for readability
|
||||||
|
const shortIds = new Map<string, string>();
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
shortIds.set(n.id, `N${i}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define nodes with shapes based on kind
|
||||||
|
for (const node of nodes) {
|
||||||
|
const shortId = shortIds.get(node.id)!;
|
||||||
|
const label = escapeLabel(node.title);
|
||||||
|
const shape = kindToShape(node.kind);
|
||||||
|
lines.push(` ${shortId}${shape.open}"${label}"${shape.close}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add empty line before edges
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Define edges
|
||||||
|
for (const edge of edges) {
|
||||||
|
const fromShort = shortIds.get(edge.from_id);
|
||||||
|
const toShort = shortIds.get(edge.to_id);
|
||||||
|
if (fromShort && toShort) {
|
||||||
|
const arrow = typeToArrow(edge.type);
|
||||||
|
lines.push(` ${fromShort} ${arrow} ${toShort}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubgraphNodes(rootId: string, maxDepth: number, db: any): Node[] {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
|
||||||
|
function traverse(id: string, depth: number) {
|
||||||
|
if (depth > maxDepth || visited.has(id)) return;
|
||||||
|
visited.add(id);
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
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 || '{}'),
|
||||||
|
embedding: null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
lastAccessedAt: row.last_accessed_at,
|
||||||
|
isStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
|
||||||
|
for (const edge of edges) {
|
||||||
|
traverse(edge.to_id, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(rootId, 0);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindToShape(kind: string): { open: string; close: string } {
|
||||||
|
switch (kind) {
|
||||||
|
case 'component': return { open: '[', close: ']' }; // Rectangle
|
||||||
|
case 'decision': return { open: '{', close: '}' }; // Diamond
|
||||||
|
case 'task': return { open: '([', close: '])' }; // Stadium
|
||||||
|
case 'memory': return { open: '(', close: ')' }; // Rounded
|
||||||
|
default: return { open: '[', close: ']' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeToArrow(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'contains': return '-->'; // Solid arrow
|
||||||
|
case 'depends_on': return '-.->'; // Dotted arrow
|
||||||
|
case 'implements': return '==>'; // Thick arrow
|
||||||
|
case 'relates_to': return '---'; // Line (no arrow)
|
||||||
|
default: return '-->';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLabel(text: string): string {
|
||||||
|
// Truncate and escape for Mermaid
|
||||||
|
const truncated = text.length > 30 ? text.slice(0, 27) + '...' : text;
|
||||||
|
return truncated
|
||||||
|
.replace(/"/g, "'")
|
||||||
|
.replace(/\n/g, ' ')
|
||||||
|
.replace(/[[\]{}()]/g, '');
|
||||||
|
}
|
||||||
200
src/core/export/svg.ts
Normal file
200
src/core/export/svg.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { listNodes } from '../store';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
|
||||||
|
export interface SvgExportOptions {
|
||||||
|
rootId?: string;
|
||||||
|
depth?: number;
|
||||||
|
kind?: string;
|
||||||
|
tags?: string[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<string, string> = {
|
||||||
|
component: '#4CAF50',
|
||||||
|
decision: '#2196F3',
|
||||||
|
task: '#FF9800',
|
||||||
|
memory: '#9C27B0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function exportSvg(options: SvgExportOptions = {}): Promise<string> {
|
||||||
|
const db = getDb();
|
||||||
|
const width = options.width || 800;
|
||||||
|
const height = options.height || 600;
|
||||||
|
|
||||||
|
// Get nodes
|
||||||
|
let nodes: Node[];
|
||||||
|
if (options.rootId) {
|
||||||
|
nodes = getSubgraphNodes(options.rootId, options.depth || 3, db);
|
||||||
|
} else {
|
||||||
|
nodes = listNodes({
|
||||||
|
kind: options.kind as any,
|
||||||
|
tags: options.tags,
|
||||||
|
limit: 100,
|
||||||
|
includeStale: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get edges
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
const edges = db.prepare(`
|
||||||
|
SELECT * FROM edges
|
||||||
|
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
|
||||||
|
`).all([...nodeIds, ...nodeIds]) as any[];
|
||||||
|
|
||||||
|
// Simple force-directed layout (basic version)
|
||||||
|
const positions = calculateLayout(nodes, edges, width, height);
|
||||||
|
|
||||||
|
// Generate SVG
|
||||||
|
const elements: string[] = [];
|
||||||
|
|
||||||
|
// Arrow marker definition
|
||||||
|
elements.push(`
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="20" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill="#666"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
for (const edge of edges) {
|
||||||
|
const from = positions.get(edge.from_id);
|
||||||
|
const to = positions.get(edge.to_id);
|
||||||
|
if (from && to) {
|
||||||
|
elements.push(`<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="#666" stroke-width="1" marker-end="url(#arrowhead)"/>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
const color = KIND_COLORS[node.kind] || '#888';
|
||||||
|
const label = node.title.length > 20 ? node.title.slice(0, 17) + '...' : node.title;
|
||||||
|
|
||||||
|
elements.push(`
|
||||||
|
<g transform="translate(${pos.x}, ${pos.y})">
|
||||||
|
<circle r="15" fill="${color}"/>
|
||||||
|
<text x="20" y="5" font-size="11" font-family="sans-serif" fill="#333">${escapeXml(label)}</text>
|
||||||
|
</g>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||||
|
<rect width="100%" height="100%" fill="#f5f5f5"/>
|
||||||
|
${elements.join('\n')}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubgraphNodes(rootId: string, maxDepth: number, db: any): Node[] {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
|
||||||
|
function traverse(id: string, depth: number) {
|
||||||
|
if (depth > maxDepth || visited.has(id)) return;
|
||||||
|
visited.add(id);
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
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 || '{}'),
|
||||||
|
embedding: null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
lastAccessedAt: row.last_accessed_at,
|
||||||
|
isStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
|
||||||
|
for (const edge of edges) {
|
||||||
|
traverse(edge.to_id, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(rootId, 0);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateLayout(nodes: Node[], edges: any[], width: number, height: number): Map<string, { x: number; y: number }> {
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
|
||||||
|
// Simple grid layout with some randomization
|
||||||
|
const cols = Math.ceil(Math.sqrt(nodes.length));
|
||||||
|
const cellWidth = (width - 100) / cols;
|
||||||
|
const cellHeight = (height - 100) / Math.ceil(nodes.length / cols);
|
||||||
|
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
positions.set(node.id, {
|
||||||
|
x: 50 + col * cellWidth + cellWidth / 2 + (Math.random() - 0.5) * 30,
|
||||||
|
y: 50 + row * cellHeight + cellHeight / 2 + (Math.random() - 0.5) * 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple force-directed adjustment (few iterations)
|
||||||
|
for (let iter = 0; iter < 50; iter++) {
|
||||||
|
// Repulsion between nodes
|
||||||
|
for (const n1 of nodes) {
|
||||||
|
const p1 = positions.get(n1.id)!;
|
||||||
|
for (const n2 of nodes) {
|
||||||
|
if (n1.id === n2.id) continue;
|
||||||
|
const p2 = positions.get(n2.id)!;
|
||||||
|
const dx = p1.x - p2.x;
|
||||||
|
const dy = p1.y - p2.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
if (dist < 100) {
|
||||||
|
const force = (100 - dist) / dist * 0.5;
|
||||||
|
p1.x += dx * force;
|
||||||
|
p1.y += dy * force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction along edges
|
||||||
|
for (const edge of edges) {
|
||||||
|
const p1 = positions.get(edge.from_id);
|
||||||
|
const p2 = positions.get(edge.to_id);
|
||||||
|
if (p1 && p2) {
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
if (dist > 100) {
|
||||||
|
const force = (dist - 100) / dist * 0.1;
|
||||||
|
p1.x += dx * force;
|
||||||
|
p1.y += dy * force;
|
||||||
|
p2.x -= dx * force;
|
||||||
|
p2.y -= dy * force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep in bounds
|
||||||
|
for (const [id, pos] of positions) {
|
||||||
|
pos.x = Math.max(50, Math.min(width - 50, pos.x));
|
||||||
|
pos.y = Math.max(50, Math.min(height - 50, pos.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@@ -650,6 +650,33 @@ server.tool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- memory_export ---
|
||||||
|
import { exportGraph, ExportFormat } from '../core/export';
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'memory_export',
|
||||||
|
'Export the knowledge graph as HTML, SVG, or Mermaid diagram',
|
||||||
|
{
|
||||||
|
format: z.enum(['html', 'svg', 'mermaid']).describe('Export format'),
|
||||||
|
rootId: z.string().optional().describe('Root node ID for subgraph export'),
|
||||||
|
depth: z.number().optional().describe('Depth for subgraph (default: 3)'),
|
||||||
|
kind: z.string().optional().describe('Filter by node kind'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Filter by tags'),
|
||||||
|
theme: z.enum(['light', 'dark']).optional().describe('Theme for HTML (default: dark)'),
|
||||||
|
},
|
||||||
|
async ({ format, rootId, depth, kind, tags, theme }) => {
|
||||||
|
const content = await exportGraph({
|
||||||
|
format: format as ExportFormat,
|
||||||
|
rootId,
|
||||||
|
depth,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text' as const, text: content }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// --- memory_ingest ---
|
// --- memory_ingest ---
|
||||||
import { ingest } from '../core/ingest';
|
import { ingest } from '../core/ingest';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user