feat: add mindmap components from jarvis frontend

- Copied mindmap visualization components (ReactFlow-based interactive graph)
- Added MindmapViewer, ReactFlowEditor, MermaidViewer
- Included all node types: Concept, Task, Idea, Project
- Added controls: NodeCreateModal, ExportButton
- Created mindmap route at /mindmap
- Added useGraphData hook for knowledge graph API
- Copied auth-client and api utilities (dependencies)

Note: Requires better-auth packages to be installed for full compilation
This commit is contained in:
Jason Woltje
2026-01-29 21:45:56 -06:00
parent af8f5df111
commit aa267b56d8
15 changed files with 1758 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useSession } from '@/lib/auth-client';
import { handleSessionExpired, isSessionExpiring } from '@/lib/api';
export interface KnowledgeNode {
id: string;
title: string;
node_type: string;
content: string | null;
tags: string[];
domain: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** Input type for creating a new node (without server-generated fields) */
export type NodeCreateInput = Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>;
/** Input type for creating a new edge (without server-generated fields) */
export type EdgeCreateInput = Omit<KnowledgeEdge, 'created_at'>;
export interface KnowledgeEdge {
source_id: string;
target_id: string;
relation_type: string;
weight: number;
metadata: Record<string, unknown>;
created_at: string;
}
export interface GraphData {
nodes: KnowledgeNode[];
edges: KnowledgeEdge[];
}
export interface MermaidData {
diagram: string;
style: string;
}
export interface GraphStatistics {
node_count: number;
edge_count: number;
nodes_by_type: Record<string, number>;
edges_by_type: Record<string, number>;
}
interface UseGraphDataOptions {
rootId?: string;
maxDepth?: number;
autoFetch?: boolean;
}
interface UseGraphDataResult {
graph: GraphData | null;
mermaid: MermaidData | null;
statistics: GraphStatistics | null;
isLoading: boolean;
error: string | null;
fetchGraph: () => Promise<void>;
fetchMermaid: (style?: 'flowchart' | 'mindmap') => Promise<void>;
fetchStatistics: () => Promise<void>;
createNode: (node: Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>) => Promise<KnowledgeNode | null>;
updateNode: (id: string, updates: Partial<KnowledgeNode>) => Promise<KnowledgeNode | null>;
deleteNode: (id: string) => Promise<boolean>;
createEdge: (edge: Omit<KnowledgeEdge, 'created_at'>) => Promise<KnowledgeEdge | null>;
deleteEdge: (sourceId: string, targetId: string, relationType: string) => Promise<boolean>;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
async function apiFetch<T>(
endpoint: string,
accessToken: string | null,
options: RequestInit = {}
): Promise<T> {
// Skip request if session is already expiring (prevents request storms)
if (isSessionExpiring()) {
throw new Error('Session expired');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
};
// Add Authorization header if we have a token
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(`${API_BASE}/api/v1/knowledge${endpoint}`, {
...options,
credentials: 'include',
headers,
});
if (!response.ok) {
// Handle session expiration
if (response.status === 401) {
handleSessionExpired();
throw new Error('Session expired');
}
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || 'API request failed');
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataResult {
const { rootId, maxDepth = 3, autoFetch = true } = options;
// Get access token from BetterAuth session
const { data: sessionData } = useSession();
const accessToken = (sessionData?.user as { accessToken?: string } | undefined)?.accessToken ?? null;
const [graph, setGraph] = useState<GraphData | null>(null);
const [mermaid, setMermaid] = useState<MermaidData | null>(null);
const [statistics, setStatistics] = useState<GraphStatistics | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchGraph = useCallback(async () => {
if (!accessToken) {
setError('Not authenticated');
return;
}
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (rootId) params.set('root_id', rootId);
params.set('max_depth', maxDepth.toString());
const data = await apiFetch<GraphData>(`/graph?${params}`, accessToken);
setGraph(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch graph');
} finally {
setIsLoading(false);
}
}, [rootId, maxDepth, accessToken]);
const fetchMermaid = useCallback(async (style: 'flowchart' | 'mindmap' = 'flowchart') => {
if (!accessToken) {
setError('Not authenticated');
return;
}
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (rootId) params.set('root_id', rootId);
params.set('style', style);
params.set('max_depth', maxDepth.toString());
params.set('style_by_type', 'true');
const data = await apiFetch<MermaidData>(`/mermaid?${params}`, accessToken);
setMermaid(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch diagram');
} finally {
setIsLoading(false);
}
}, [rootId, maxDepth, accessToken]);
const fetchStatistics = useCallback(async () => {
if (!accessToken) return;
try {
const data = await apiFetch<GraphStatistics>('/graph/statistics', accessToken);
setStatistics(data);
} catch (err) {
console.error('Failed to fetch statistics:', err);
}
}, [accessToken]);
const createNode = useCallback(async (
node: Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>
): Promise<KnowledgeNode | null> => {
if (!accessToken) {
setError('Not authenticated');
return null;
}
try {
const created = await apiFetch<KnowledgeNode>('/nodes', accessToken, {
method: 'POST',
body: JSON.stringify(node),
});
await fetchGraph();
return created;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create node');
return null;
}
}, [fetchGraph, accessToken]);
const updateNode = useCallback(async (
id: string,
updates: Partial<KnowledgeNode>
): Promise<KnowledgeNode | null> => {
if (!accessToken) {
setError('Not authenticated');
return null;
}
try {
const updated = await apiFetch<KnowledgeNode>(`/nodes/${id}`, accessToken, {
method: 'PUT',
body: JSON.stringify(updates),
});
await fetchGraph();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update node');
return null;
}
}, [fetchGraph, accessToken]);
const deleteNode = useCallback(async (id: string): Promise<boolean> => {
if (!accessToken) {
setError('Not authenticated');
return false;
}
try {
await apiFetch(`/nodes/${id}`, accessToken, { method: 'DELETE' });
await fetchGraph();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete node');
return false;
}
}, [fetchGraph, accessToken]);
const createEdge = useCallback(async (
edge: Omit<KnowledgeEdge, 'created_at'>
): Promise<KnowledgeEdge | null> => {
if (!accessToken) {
setError('Not authenticated');
return null;
}
try {
const created = await apiFetch<KnowledgeEdge>('/edges', accessToken, {
method: 'POST',
body: JSON.stringify(edge),
});
await fetchGraph();
return created;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create edge');
return null;
}
}, [fetchGraph, accessToken]);
const deleteEdge = useCallback(async (
sourceId: string,
targetId: string,
relationType: string
): Promise<boolean> => {
if (!accessToken) {
setError('Not authenticated');
return false;
}
try {
const params = new URLSearchParams({
source_id: sourceId,
target_id: targetId,
relation_type: relationType,
});
await apiFetch(`/edges?${params}`, accessToken, { method: 'DELETE' });
await fetchGraph();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete edge');
return false;
}
}, [fetchGraph, accessToken]);
// Initial data fetch - only run when autoFetch is true and we have an access token
useEffect(() => {
if (autoFetch && accessToken) {
void fetchGraph();
void fetchStatistics();
}
}, [autoFetch, accessToken, fetchGraph, fetchStatistics]);
return {
graph,
mermaid,
statistics,
isLoading,
error,
fetchGraph,
fetchMermaid,
fetchStatistics,
createNode,
updateNode,
deleteNode,
createEdge,
deleteEdge,
};
}