Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
NodeManager.tsx27.3 kB
import React, { useState, useEffect, useCallback, useId } from 'react'; import './styles.css'; // VSCode API is acquired globally in main.tsx declare const vscode: any; interface NodeType { type: string; count: number; } interface Node { id: string; type: string; displayName: string; created: string; updated: string; edgeCount: number; embeddingCount: number; properties: Record<string, any>; } interface NodeDetails { node: { id: string; type: string; properties: Record<string, any>; }; edges: { outgoing: Array<{ type: string; targetId: string; targetType: string; targetName: string; properties: Record<string, any>; }>; incoming: Array<{ type: string; sourceId: string; sourceType: string; sourceName: string; properties: Record<string, any>; }>; total: number; }; } interface Pagination { page: number; limit: number; total: number; totalPages: number; } export function NodeManager() { const [apiUrl, setApiUrl] = useState<string>(''); const [authHeaders, setAuthHeaders] = useState<Record<string, string>>({}); const authHeadersRef = React.useRef<Record<string, string>>({}); const [types, setTypes] = useState<NodeType[]>([]); const [expandedType, setExpandedType] = useState<string | null>(null); const [nodes, setNodes] = useState<Record<string, Node[]>>({}); const [pagination, setPagination] = useState<Record<string, Pagination>>({}); const [selectedNode, setSelectedNode] = useState<NodeDetails | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string>(''); // Search state const [searchQuery, setSearchQuery] = useState<string>(''); const [searchResults, setSearchResults] = useState<string[]>([]); // Node IDs const [searchResultNodes, setSearchResultNodes] = useState<Record<string, Node[]>>({}); // Full nodes by type const [isSearching, setIsSearching] = useState(false); const [showSearchSettings, setShowSearchSettings] = useState(false); const [searchSettings, setSearchSettings] = useState({ minSimilarity: 0.75, limit: 50, types: [] as string[] }); // Generate stable IDs for form inputs const minSimilarityId = useId(); const searchLimitId = useId(); const nodeTypesId = useId(); const loadTypes = useCallback(async () => { if (!apiUrl) { console.warn('[NodeManager] Cannot load types - apiUrl not set'); return; } setLoading(true); setError(''); try { console.log('[NodeManager] Loading types with auth headers:', Object.keys(authHeadersRef.current).length > 0 ? 'Present' : 'Empty'); const response = await fetch(`${apiUrl}/api/nodes/types`, { headers: authHeadersRef.current }); const data = await response.json(); if (response.ok) { setTypes(data.types); } else { setError(data.error || 'Failed to load node types'); } } catch (err: any) { setError(`Failed to load types: ${err.message}`); } finally { setLoading(false); } }, [apiUrl]); // Don't auto-load types - wait for config message with auth headers // loadTypes will be called from the config message handler after both apiUrl and authHeaders are set const loadNodesForType = useCallback(async (type: string, page: number = 1) => { if (!apiUrl) { console.warn('[NodeManager] Cannot load nodes - apiUrl not set'); return; } setLoading(true); setError(''); try { const url = `${apiUrl}/api/nodes/types/${encodeURIComponent(type)}?page=${page}&limit=20`; console.log(`[NodeManager] Fetching nodes for type "${type}" from:`, url); const response = await fetch(url, { headers: authHeadersRef.current }); const data = await response.json(); console.log(`[NodeManager] Response for type "${type}":`, { status: response.status, data }); if (response.ok) { setNodes(prev => ({ ...prev, [type]: data.nodes })); setPagination(prev => ({ ...prev, [type]: data.pagination })); } else { const errorMsg = data.error || `Failed to load ${type} nodes`; setError(errorMsg); console.error(`[NodeManager] API error for type "${type}":`, errorMsg); } } catch (err: any) { const errorMsg = `Failed to load ${type} nodes: ${err.message}`; setError(errorMsg); console.error(`[NodeManager] Error loading ${type} nodes:`, err); } finally { setLoading(false); } }, [apiUrl]); const handleDeleteNode = useCallback(async (nodeId: string) => { setLoading(true); setError(''); try { const response = await fetch(`${apiUrl}/api/nodes/${encodeURIComponent(nodeId)}`, { method: 'DELETE', headers: authHeadersRef.current }); const data = await response.json(); if (response.ok) { vscode.postMessage({ command: 'showInfo', message: `Node deleted successfully (${data.deleted.edgesRemoved} edges removed)` }); // Clear selected node if it was the deleted one if (selectedNode?.node.id === nodeId) { setSelectedNode(null); } // Reload types and current list loadTypes(); if (expandedType) { loadNodesForType(expandedType, pagination[expandedType]?.page || 1); } } else { setError(data.error || 'Failed to delete node'); } } catch (err: any) { setError('Failed to delete node'); console.error('Error deleting node:', err); } finally { setLoading(false); } }, [apiUrl, selectedNode, expandedType, pagination, loadTypes, loadNodesForType]); // Message listener for extension host communication useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data; if (message.command === 'config') { console.log('[NodeManager] Received config:', message.config.apiUrl, 'Auth headers:', Object.keys(message.authHeaders || {}).length > 0 ? 'Present' : 'Empty'); if (message.authHeaders && Object.keys(message.authHeaders).length > 0) { console.log('[NodeManager] Auth header keys:', Object.keys(message.authHeaders)); } const newApiUrl = message.config.apiUrl; setApiUrl(newApiUrl); setAuthHeaders(message.authHeaders || {}); // Manually trigger loadTypes after setting both apiUrl and authHeaders // This ensures authHeadersRef is updated before the fetch call setTimeout(() => { if (newApiUrl) { console.log('[NodeManager] Triggering loadTypes with URL:', newApiUrl); loadTypes(); } else { console.warn('[NodeManager] No apiUrl provided, skipping loadTypes'); } }, 0); } else if (message.command === 'deleteConfirmed') { handleDeleteNode(message.nodeId); } }; window.addEventListener('message', handleMessage); // Signal that we're ready vscode.postMessage({ command: 'ready' }); return () => window.removeEventListener('message', handleMessage); }, [handleDeleteNode]); // Sync authHeaders to ref React.useEffect(() => { authHeadersRef.current = authHeaders; }, [authHeaders]); const loadNodeDetails = async (type: string, id: string) => { setLoading(true); setError(''); try { const response = await fetch(`${apiUrl}/api/nodes/types/${encodeURIComponent(type)}/${encodeURIComponent(id)}/details`, { headers: authHeadersRef.current }); const data = await response.json(); if (response.ok) { setSelectedNode(data); } else { setError(data.error || 'Failed to load node details'); } } catch (err: any) { setError('Failed to load node details'); console.error('Error loading node details:', err); } finally { setLoading(false); } }; const handleTypeClick = (type: string) => { if (expandedType === type) { setExpandedType(null); setSelectedNode(null); } else { setExpandedType(type); setSelectedNode(null); // If search is active and we have cached results, use them if (searchResults.length > 0 && searchResultNodes[type]) { setNodes(prev => ({ ...prev, [type]: searchResultNodes[type] })); } else if (searchResults.length === 0) { // No search active - fetch from API loadNodesForType(type); } // If search is active but no results for this type, nodes[type] will remain undefined } }; const handleNodeClick = (node: Node) => { loadNodeDetails(node.type, node.id); }; const handleDeleteClick = (node: Node, event: React.MouseEvent) => { event.stopPropagation(); vscode.postMessage({ command: 'confirmDelete', nodeId: node.id, nodeType: node.type }); }; const handleGenerateEmbeddings = async (node: Node, event: React.MouseEvent) => { event.stopPropagation(); setLoading(true); setError(''); try { const response = await fetch(`${apiUrl}/api/nodes/${encodeURIComponent(node.id)}/embeddings`, { method: 'POST', headers: authHeadersRef.current }); const data = await response.json(); if (response.ok) { vscode.postMessage({ command: 'showInfo', message: data.message }); // Reload the list to update embedding counts if (expandedType) { loadNodesForType(expandedType, pagination[expandedType]?.page || 1); } } else { setError(data.error || 'Failed to generate embeddings'); } } catch (err: any) { setError(`Failed to generate embeddings: ${err.message}`); console.error('Error generating embeddings:', err); } finally { setLoading(false); } }; const handleDownloadNode = (node: Node, event: React.MouseEvent) => { event.stopPropagation(); // Send message to extension host to save the file vscode.postMessage({ command: 'downloadNode', node: { id: node.id, type: node.type, displayName: node.displayName, created: node.created, updated: node.updated, edgeCount: node.edgeCount, embeddingCount: node.embeddingCount, properties: node.properties } }); }; const handleSearch = async () => { console.log('[NodeManager] handleSearch called, query:', searchQuery); if (!searchQuery.trim()) { // Clear search - show all nodes setSearchResults([]); setIsSearching(false); return; } setIsSearching(true); setError(''); try { console.log('[NodeManager] Starting vector search with auth headers:', Object.keys(authHeadersRef.current).length > 0 ? 'Present' : 'Empty'); const params = new URLSearchParams({ query: searchQuery, limit: searchSettings.limit.toString(), min_similarity: searchSettings.minSimilarity.toString() }); // Determine types to search let typesToSearch = searchSettings.types; if (typesToSearch.length === 0) { // If no types selected, search all types EXCEPT files and chunks typesToSearch = types .map(t => t.type) .filter(t => t !== 'file' && t !== 'file_chunk' && t !== 'node_chunk'); } else { // Filter out files and chunks from selected types typesToSearch = typesToSearch.filter(t => t !== 'file' && t !== 'file_chunk' && t !== 'node_chunk'); } if (typesToSearch.length > 0) { params.append('types', typesToSearch.join(',')); } console.log('[NodeManager] Fetching:', `${apiUrl}/api/nodes/vector-search?${params}`); const response = await fetch(`${apiUrl}/api/nodes/vector-search?${params}`, { headers: authHeadersRef.current }); console.log('[NodeManager] Response status:', response.status); const data = await response.json(); if (response.ok) { // Extract node IDs and organize full nodes by type const nodeIds = data.results.map((r: any) => r.id); const nodesByType: Record<string, Node[]> = {}; data.results.forEach((r: any) => { const node: Node = { id: r.id, type: r.type, displayName: r.title || r.name || r.id, created: r.created || '', updated: r.updated || '', edgeCount: 0, // We don't have this from search embeddingCount: 0, // We don't have this from search properties: r.props || {} }; if (!nodesByType[r.type]) { nodesByType[r.type] = []; } nodesByType[r.type].push(node); }); setSearchResults(nodeIds); setSearchResultNodes(nodesByType); } else { setError(data.error || 'Search failed'); setSearchResults([]); setSearchResultNodes({}); } } catch (err: any) { setError(`Search failed: ${err.message}`); setSearchResults([]); setSearchResultNodes({}); } finally { setIsSearching(false); } }; const clearSearch = () => { setSearchQuery(''); setSearchResults([]); setSearchResultNodes({}); setIsSearching(false); }; // Filter nodes based on search results const filterNodesBySearch = (nodeList: Node[]) => { if (searchResults.length === 0 && searchQuery.trim() === '') { // No search active - show all nodes return nodeList; } if (searchResults.length === 0 && searchQuery.trim() !== '') { // Search active but no results return []; } // Filter by search results return nodeList.filter(node => searchResults.includes(node.id)); }; const handlePageChange = (type: string, newPage: number) => { loadNodesForType(type, newPage); }; const handleBackToList = () => { setSelectedNode(null); }; const formatDate = (dateString: string) => { if (!dateString) return 'N/A'; try { const date = new Date(dateString); return date.toLocaleString(); } catch { return dateString; } }; const formatValue = (key: string, value: any): string => { if (value === null || value === undefined) return 'null'; // Truncate embedding arrays if (key === 'embedding' && Array.isArray(value) && value.length > 0) { const preview = value.slice(0, 5); return `[${preview.join(', ')}, ... (${value.length} dimensions)]`; } if (typeof value === 'object') return JSON.stringify(value, null, 2); return String(value); }; return ( <div className="node-manager"> <div className="header"> <h1>📊 Node Manager</h1> <p className="subtitle">Browse and manage nodes in your knowledge graph</p> {error && <div className="error-banner">{error}</div>} </div> {/* Vector Search */} <div className="search-container"> <div className="search-bar"> <input type="text" className="search-input" placeholder="🔍 Vector search nodes by meaning..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleSearch(); } }} /> {searchQuery && ( <button type="button" className="clear-search-btn" onClick={clearSearch} title="Clear search" > ✕ </button> )} <button type="button" className="search-btn" onClick={handleSearch} disabled={isSearching} title="Search" > {isSearching ? '⏳' : '🔍'} </button> <button type="button" className="settings-btn" onClick={() => setShowSearchSettings(!showSearchSettings)} title="Search settings" > ⚙️ </button> </div> {showSearchSettings && ( <div className="search-settings"> <div className="setting-item"> <label htmlFor={minSimilarityId}>Min Similarity:</label> <input id={minSimilarityId} type="number" min="0" max="1" step="0.05" value={searchSettings.minSimilarity} onChange={(e) => setSearchSettings({ ...searchSettings, minSimilarity: parseFloat(e.target.value) })} /> <span className="setting-value">{searchSettings.minSimilarity}</span> </div> <div className="setting-item"> <label htmlFor={searchLimitId}>Max Results:</label> <input id={searchLimitId} type="number" min="1" max="100" value={searchSettings.limit} onChange={(e) => setSearchSettings({ ...searchSettings, limit: parseInt(e.target.value, 10) })} /> <span className="setting-value">{searchSettings.limit}</span> </div> <div className="setting-item"> <label htmlFor={nodeTypesId}>Node Types:</label> <select id={nodeTypesId} multiple value={searchSettings.types} onChange={(e) => { const selected = Array.from(e.target.selectedOptions, option => option.value); setSearchSettings({ ...searchSettings, types: selected }); }} > {types.map(t => ( <option key={t.type} value={t.type}>{t.type}</option> ))} </select> </div> </div> )} {searchQuery && ( <div className="search-status"> {isSearching ? ( <span>Searching...</span> ) : searchResults.length > 0 ? ( <span>Found {searchResults.length} matching nodes</span> ) : ( <span>No results found</span> )} </div> )} </div> {loading && !types.length && ( <div className="loading">Loading node types...</div> )} {!selectedNode ? ( <div className="types-container"> {types.map(nodeType => ( <div key={nodeType.type} className="type-section"> <div className={`type-header ${expandedType === nodeType.type ? 'expanded' : ''}`} onClick={() => handleTypeClick(nodeType.type)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleTypeClick(nodeType.type); }} role="button" tabIndex={0} > <span className="type-icon">{expandedType === nodeType.type ? '▼' : '▶'}</span> <span className="type-name">{nodeType.type}</span> <span className="type-count"> {searchResults.length > 0 ? `${searchResultNodes[nodeType.type]?.length || 0}/${nodeType.count}` : nodeType.count} </span> </div> {expandedType === nodeType.type && ( <div className="nodes-list"> {loading && !nodes[nodeType.type]?.length ? ( <div className="loading-small">Loading nodes...</div> ) : ( <> {filterNodesBySearch(nodes[nodeType.type] || []).map(node => ( <div key={node.id} className="node-item" onClick={() => handleNodeClick(node)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleNodeClick(node); }} role="button" tabIndex={0} > <button type="button" className="download-btn" onClick={(e) => handleDownloadNode(node, e)} title="Download node as JSON" > 📥 </button> <div className="node-main"> <div className="node-name">{node.displayName}</div> <div className="node-meta"> <span className="node-id">{node.id}</span> <span className="node-edges">🔗 {node.edgeCount} edges</span> <span className="node-embeddings"> 🧠 {node.embeddingCount} embedding{node.embeddingCount !== 1 ? 's' : ''} </span> </div> <div className="node-dates"> <span>Updated: {formatDate(node.updated)}</span> </div> </div> <div className="node-actions"> <button type="button" className="embeddings-btn" onClick={(e) => handleGenerateEmbeddings(node, e)} title={node.embeddingCount > 0 ? "Regenerate embeddings" : "Generate embeddings"} > ✨ </button> <button type="button" className="delete-btn" onClick={(e) => handleDeleteClick(node, e)} title="Delete node and all its edges" > 🗑️ </button> </div> </div> ))} {pagination[nodeType.type] && pagination[nodeType.type].totalPages > 1 && ( <div className="pagination"> <button type="button" disabled={pagination[nodeType.type].page === 1} onClick={() => handlePageChange(nodeType.type, pagination[nodeType.type].page - 1)} > ← Previous </button> <span className="page-info"> Page {pagination[nodeType.type].page} of {pagination[nodeType.type].totalPages} </span> <button type="button" disabled={pagination[nodeType.type].page === pagination[nodeType.type].totalPages} onClick={() => handlePageChange(nodeType.type, pagination[nodeType.type].page + 1)} > Next → </button> </div> )} </> )} </div> )} </div> ))} </div> ) : ( <div className="node-detail"> <div className="detail-header"> <button type="button" className="back-btn" onClick={handleBackToList}> ← Back to List </button> <h2>{selectedNode.node.properties.title || selectedNode.node.properties.name || selectedNode.node.id}</h2> </div> <div className="detail-section"> <h3>Node Information</h3> <div className="detail-grid"> <div className="detail-item"> <span className="detail-label">ID:</span> <span className="detail-value">{selectedNode.node.id}</span> </div> <div className="detail-item"> <span className="detail-label">Type:</span> <span className="detail-value">{selectedNode.node.type}</span> </div> <div className="detail-item"> <span className="detail-label">Total Edges:</span> <span className="detail-value">{selectedNode.edges.total}</span> </div> </div> </div> <div className="detail-section"> <h3>Properties</h3> <div className="properties-list"> {Object.entries(selectedNode.node.properties).map(([key, value]) => ( <div key={key} className="property-item"> <span className="property-key">{key}:</span> <pre className="property-value">{formatValue(key, value)}</pre> </div> ))} </div> </div> {selectedNode.edges.outgoing.length > 0 && ( <div className="detail-section"> <h3>Outgoing Edges ({selectedNode.edges.outgoing.length})</h3> <div className="edges-list"> {selectedNode.edges.outgoing.map((edge, idx) => ( <div key={`${edge.type}-${edge.targetId}-${idx}`} className="edge-item"> <div className="edge-type">[{edge.type}]</div> <div className="edge-target"> → {edge.targetName} <span className="edge-target-type">({edge.targetType})</span> </div> </div> ))} </div> </div> )} {selectedNode.edges.incoming.length > 0 && ( <div className="detail-section"> <h3>Incoming Edges ({selectedNode.edges.incoming.length})</h3> <div className="edges-list"> {selectedNode.edges.incoming.map((edge, idx) => ( <div key={`${edge.type}-${edge.sourceId}-${idx}`} className="edge-item"> <div className="edge-type">[{edge.type}]</div> <div className="edge-source"> ← {edge.sourceName} <span className="edge-source-type">({edge.sourceType})</span> </div> </div> ))} </div> </div> )} </div> )} </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server