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 { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal';
|
||||
import { ingestCommand, clipCommand } from './commands/ingest';
|
||||
import { exportCommand, vizCommand } from './commands/export';
|
||||
import { closeDb } from '../core/db';
|
||||
|
||||
const program = new Command();
|
||||
@@ -53,6 +54,8 @@ program.addCommand(journalAliasCommand);
|
||||
program.addCommand(quickCaptureCommand);
|
||||
program.addCommand(ingestCommand);
|
||||
program.addCommand(clipCommand);
|
||||
program.addCommand(exportCommand);
|
||||
program.addCommand(vizCommand);
|
||||
|
||||
program.hook('postAction', () => {
|
||||
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 ---
|
||||
import { ingest } from '../core/ingest';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user