yangdx commited on
Commit
b8f3341
·
1 Parent(s): 6b8dc70

Add Node Expansion and Pruning Features

Browse files
lightrag/api/webui/assets/{index-DwcJE583.js → index-DeJuAbj6.js} RENAMED
Binary files a/lightrag/api/webui/assets/index-DwcJE583.js and b/lightrag/api/webui/assets/index-DeJuAbj6.js differ
 
lightrag/api/webui/index.html CHANGED
Binary files a/lightrag/api/webui/index.html and b/lightrag/api/webui/index.html differ
 
lightrag_webui/src/components/graph/PropertiesView.tsx CHANGED
@@ -1,8 +1,10 @@
1
  import { useEffect, useState } from 'react'
2
  import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
3
  import Text from '@/components/ui/Text'
 
4
  import useLightragGraph from '@/hooks/useLightragGraph'
5
  import { useTranslation } from 'react-i18next'
 
6
 
7
  /**
8
  * Component that view properties of elements in graph.
@@ -157,9 +159,40 @@ const PropertyRow = ({
157
 
158
  const NodePropertiesView = ({ node }: { node: NodeType }) => {
159
  const { t } = useTranslation()
 
 
 
 
 
 
 
 
 
160
  return (
161
  <div className="flex flex-col gap-2">
162
- <label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
164
  <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
165
  <PropertyRow
 
1
  import { useEffect, useState } from 'react'
2
  import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
3
  import Text from '@/components/ui/Text'
4
+ import Button from '@/components/ui/Button'
5
  import useLightragGraph from '@/hooks/useLightragGraph'
6
  import { useTranslation } from 'react-i18next'
7
+ import { GitBranchPlus, Scissors } from 'lucide-react'
8
 
9
  /**
10
  * Component that view properties of elements in graph.
 
159
 
160
  const NodePropertiesView = ({ node }: { node: NodeType }) => {
161
  const { t } = useTranslation()
162
+
163
+ const handleExpandNode = () => {
164
+ useGraphStore.getState().triggerNodeExpand(node.id)
165
+ }
166
+
167
+ const handlePruneNode = () => {
168
+ useGraphStore.getState().triggerNodePrune(node.id)
169
+ }
170
+
171
  return (
172
  <div className="flex flex-col gap-2">
173
+ <div className="flex justify-between items-center">
174
+ <label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
175
+ <div className="flex gap-1">
176
+ <Button
177
+ size="icon"
178
+ variant="ghost"
179
+ className="h-6 w-6"
180
+ onClick={handleExpandNode}
181
+ tooltip={t('graphPanel.propertiesView.node.expandNode')}
182
+ >
183
+ <GitBranchPlus className="h-4 w-4" />
184
+ </Button>
185
+ <Button
186
+ size="icon"
187
+ variant="ghost"
188
+ className="h-6 w-6"
189
+ onClick={handlePruneNode}
190
+ tooltip={t('graphPanel.propertiesView.node.pruneNode')}
191
+ >
192
+ <Scissors className="h-4 w-4" />
193
+ </Button>
194
+ </div>
195
+ </div>
196
  <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
197
  <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
198
  <PropertyRow
lightrag_webui/src/hooks/useLightragGraph.tsx CHANGED
@@ -1,8 +1,10 @@
1
  import Graph, { DirectedGraph } from 'graphology'
2
  import { useCallback, useEffect, useRef } from 'react'
 
3
  import { randomColor, errorMessage } from '@/lib/utils'
4
  import * as Constants from '@/lib/constants'
5
- import { useGraphStore, RawGraph } from '@/stores/graph'
 
6
  import { queryGraphs } from '@/api/lightrag'
7
  import { useBackendState } from '@/stores/state'
8
  import { useSettingsStore } from '@/stores/settings'
@@ -172,12 +174,15 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
172
  }
173
 
174
  const useLightrangeGraph = () => {
 
175
  const queryLabel = useSettingsStore.use.queryLabel()
176
  const rawGraph = useGraphStore.use.rawGraph()
177
  const sigmaGraph = useGraphStore.use.sigmaGraph()
178
  const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
179
  const minDegree = useSettingsStore.use.graphMinDegree()
180
  const isFetching = useGraphStore.use.isFetching()
 
 
181
 
182
  // Get tab visibility
183
  const { isTabVisible } = useTabVisibility()
@@ -327,6 +332,382 @@ const useLightrangeGraph = () => {
327
  }
328
  }, [isGraphTabVisible, rawGraph])
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  const lightrageGraph = useCallback(() => {
331
  // If we already have a graph instance, return it
332
  if (sigmaGraph) {
 
1
  import Graph, { DirectedGraph } from 'graphology'
2
  import { useCallback, useEffect, useRef } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
  import { randomColor, errorMessage } from '@/lib/utils'
5
  import * as Constants from '@/lib/constants'
6
+ import { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph'
7
+ import { toast } from 'sonner'
8
  import { queryGraphs } from '@/api/lightrag'
9
  import { useBackendState } from '@/stores/state'
10
  import { useSettingsStore } from '@/stores/settings'
 
174
  }
175
 
176
  const useLightrangeGraph = () => {
177
+ const { t } = useTranslation()
178
  const queryLabel = useSettingsStore.use.queryLabel()
179
  const rawGraph = useGraphStore.use.rawGraph()
180
  const sigmaGraph = useGraphStore.use.sigmaGraph()
181
  const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
182
  const minDegree = useSettingsStore.use.graphMinDegree()
183
  const isFetching = useGraphStore.use.isFetching()
184
+ const nodeToExpand = useGraphStore.use.nodeToExpand()
185
+ const nodeToPrune = useGraphStore.use.nodeToPrune()
186
 
187
  // Get tab visibility
188
  const { isTabVisible } = useTabVisibility()
 
332
  }
333
  }, [isGraphTabVisible, rawGraph])
334
 
335
+ // Handle node expansion
336
+ useEffect(() => {
337
+ const handleNodeExpand = async (nodeId: string | null) => {
338
+ if (!nodeId || !sigmaGraph || !rawGraph) return;
339
+
340
+ try {
341
+ // Set fetching state
342
+ useGraphStore.getState().setIsFetching(true);
343
+
344
+ // Get the node to expand
345
+ const nodeToExpand = rawGraph.getNode(nodeId);
346
+ if (!nodeToExpand) {
347
+ console.error('Node not found:', nodeId);
348
+ useGraphStore.getState().setIsFetching(false);
349
+ return;
350
+ }
351
+
352
+ // Get the label of the node to expand
353
+ const label = nodeToExpand.labels[0];
354
+ if (!label) {
355
+ console.error('Node has no label:', nodeId);
356
+ useGraphStore.getState().setIsFetching(false);
357
+ return;
358
+ }
359
+
360
+ // Fetch the extended subgraph with depth 2
361
+ const extendedGraph = await queryGraphs(label, 2, 0);
362
+
363
+ if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
364
+ console.error('Failed to fetch extended graph');
365
+ useGraphStore.getState().setIsFetching(false);
366
+ return;
367
+ }
368
+
369
+ // Process nodes to add required properties for RawNodeType
370
+ const processedNodes: RawNodeType[] = [];
371
+ for (const node of extendedGraph.nodes) {
372
+ // Generate random color values
373
+ const r = Math.floor(Math.random() * 256);
374
+ const g = Math.floor(Math.random() * 256);
375
+ const b = Math.floor(Math.random() * 256);
376
+ const color = `rgb(${r}, ${g}, ${b})`;
377
+
378
+ // Create a properly typed RawNodeType
379
+ processedNodes.push({
380
+ id: node.id,
381
+ labels: node.labels,
382
+ properties: node.properties,
383
+ size: 10, // Default size
384
+ x: Math.random(), // Random position
385
+ y: Math.random(), // Random position
386
+ color: color, // Random color
387
+ degree: 0 // Initial degree
388
+ });
389
+ }
390
+
391
+ // Process edges to add required properties for RawEdgeType
392
+ const processedEdges: RawEdgeType[] = [];
393
+ for (const edge of extendedGraph.edges) {
394
+ // Create a properly typed RawEdgeType
395
+ processedEdges.push({
396
+ id: edge.id,
397
+ source: edge.source,
398
+ target: edge.target,
399
+ type: edge.type,
400
+ properties: edge.properties,
401
+ dynamicId: '' // Will be set when adding to sigma graph
402
+ });
403
+ }
404
+
405
+ // Store current node positions
406
+ const nodePositions: Record<string, {x: number, y: number}> = {};
407
+ sigmaGraph.forEachNode((node) => {
408
+ nodePositions[node] = {
409
+ x: sigmaGraph.getNodeAttribute(node, 'x'),
410
+ y: sigmaGraph.getNodeAttribute(node, 'y')
411
+ };
412
+ });
413
+
414
+ // Get existing node IDs
415
+ const existingNodeIds = new Set(sigmaGraph.nodes());
416
+
417
+ // Check if there are any new nodes that can be connected to the selected node
418
+ let hasConnectableNewNodes = false;
419
+ for (const newNode of processedNodes) {
420
+ // Skip if node already exists
421
+ if (existingNodeIds.has(newNode.id)) {
422
+ continue;
423
+ }
424
+
425
+ // Check if this node is connected to the selected node
426
+ const isConnected = processedEdges.some(
427
+ edge => (edge.source === nodeId && edge.target === newNode.id) ||
428
+ (edge.target === nodeId && edge.source === newNode.id)
429
+ );
430
+
431
+ if (isConnected) {
432
+ hasConnectableNewNodes = true;
433
+ break;
434
+ }
435
+ }
436
+
437
+ // If no new connectable nodes found, show toast and return
438
+ if (!hasConnectableNewNodes) {
439
+ toast.info(t('graphPanel.propertiesView.node.noNewNodes'));
440
+ useGraphStore.getState().setIsFetching(false);
441
+ return;
442
+ }
443
+
444
+ // Get degree range from existing graph for size calculations
445
+ let minDegree = Number.MAX_SAFE_INTEGER;
446
+ let maxDegree = 0;
447
+ sigmaGraph.forEachNode(node => {
448
+ const degree = sigmaGraph.degree(node);
449
+ minDegree = Math.min(minDegree, degree);
450
+ maxDegree = Math.max(maxDegree, degree);
451
+ });
452
+
453
+ // Calculate size formula parameters
454
+ const range = maxDegree - minDegree || 1; // Avoid division by zero
455
+ const scale = Constants.maxNodeSize - Constants.minNodeSize;
456
+
457
+ // Add new nodes from the processed nodes
458
+ for (const newNode of processedNodes) {
459
+ // Skip if node already exists
460
+ if (existingNodeIds.has(newNode.id)) {
461
+ continue;
462
+ }
463
+
464
+ // Check if this node is connected to the selected node
465
+ const isConnected = processedEdges.some(
466
+ edge => (edge.source === nodeId && edge.target === newNode.id) ||
467
+ (edge.target === nodeId && edge.source === newNode.id)
468
+ );
469
+
470
+ if (isConnected) {
471
+ // Calculate node degree (number of connected edges)
472
+ const nodeDegree = processedEdges.filter(edge =>
473
+ edge.source === newNode.id || edge.target === newNode.id
474
+ ).length;
475
+
476
+ // Calculate node size using the same formula as in fetchGraph
477
+ const nodeSize = Math.round(
478
+ Constants.minNodeSize + scale * Math.pow((nodeDegree - minDegree) / range, 0.5)
479
+ );
480
+
481
+ // Add the new node to the graph with calculated size
482
+ sigmaGraph.addNode(newNode.id, {
483
+ label: newNode.labels.join(', '),
484
+ color: newNode.color,
485
+ x: nodePositions[nodeId].x + (Math.random() - 0.5) * 0.5,
486
+ y: nodePositions[nodeId].y + (Math.random() - 0.5) * 0.5,
487
+ size: nodeSize,
488
+ borderColor: '#000',
489
+ borderSize: 0.2
490
+ });
491
+
492
+ // Add the node to the raw graph
493
+ if (!rawGraph.getNode(newNode.id)) {
494
+ // Update the node size to match the calculated size
495
+ newNode.size = nodeSize;
496
+ // Add to nodes array
497
+ rawGraph.nodes.push(newNode);
498
+ // Update nodeIdMap
499
+ rawGraph.nodeIdMap[newNode.id] = rawGraph.nodes.length - 1;
500
+ }
501
+ }
502
+ }
503
+
504
+ // Add new edges
505
+ for (const newEdge of processedEdges) {
506
+ // Only add edges where both source and target exist in the graph
507
+ if (sigmaGraph.hasNode(newEdge.source) && sigmaGraph.hasNode(newEdge.target)) {
508
+ // Skip if edge already exists
509
+ if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {
510
+ continue;
511
+ }
512
+
513
+ // Add the edge to the sigma graph
514
+ newEdge.dynamicId = sigmaGraph.addDirectedEdge(newEdge.source, newEdge.target, {
515
+ label: newEdge.type || undefined
516
+ });
517
+
518
+ // Add the edge to the raw graph
519
+ if (!rawGraph.getEdge(newEdge.id, false)) {
520
+ // Add to edges array
521
+ rawGraph.edges.push(newEdge);
522
+ // Update edgeIdMap
523
+ rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1;
524
+ // Update dynamic edge map
525
+ rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1;
526
+ }
527
+ }
528
+ }
529
+
530
+ // Update the dynamic edge map
531
+ rawGraph.buildDynamicMap();
532
+
533
+ // Restore positions for existing nodes
534
+ Object.entries(nodePositions).forEach(([id, position]) => {
535
+ if (sigmaGraph.hasNode(id)) {
536
+ sigmaGraph.setNodeAttribute(id, 'x', position.x);
537
+ sigmaGraph.setNodeAttribute(id, 'y', position.y);
538
+ }
539
+ });
540
+
541
+ // Update the size of the expanded node based on its new edge count
542
+ if (sigmaGraph.hasNode(nodeId)) {
543
+ // Get the new degree of the expanded node
544
+ const expandedNodeDegree = sigmaGraph.degree(nodeId);
545
+
546
+ // Calculate new size for the expanded node using the same parameters
547
+ const newSize = Math.round(
548
+ Constants.minNodeSize + scale * Math.pow((expandedNodeDegree - minDegree) / range, 0.5)
549
+ );
550
+
551
+ // Update the size in sigma graph
552
+ sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);
553
+
554
+ // Update the size in raw graph
555
+ const expandedNodeIndex = rawGraph.nodeIdMap[nodeId];
556
+ if (expandedNodeIndex !== undefined) {
557
+ rawGraph.nodes[expandedNodeIndex].size = newSize;
558
+ }
559
+ }
560
+
561
+ // Refresh the layout and store the node ID to reselect after refresh
562
+ const nodeIdToSelect = nodeId;
563
+ useGraphStore.getState().refreshLayout();
564
+
565
+ // Use setTimeout to reselect the node after the layout refresh is complete
566
+ setTimeout(() => {
567
+ if (nodeIdToSelect) {
568
+ useGraphStore.getState().setSelectedNode(nodeIdToSelect, true);
569
+ }
570
+ }, 2000); // Wait a bit longer than the refreshLayout timeout (which is 10ms)
571
+
572
+ } catch (error) {
573
+ console.error('Error expanding node:', error);
574
+ } finally {
575
+ // Reset fetching state and node to expand
576
+ useGraphStore.getState().setIsFetching(false);
577
+ }
578
+ };
579
+
580
+ // If there's a node to expand, handle it
581
+ if (nodeToExpand) {
582
+ handleNodeExpand(nodeToExpand);
583
+ // Reset the nodeToExpand state after handling
584
+ window.setTimeout(() => {
585
+ useGraphStore.getState().triggerNodeExpand(null);
586
+ }, 0);
587
+ }
588
+ }, [nodeToExpand, sigmaGraph, rawGraph]);
589
+
590
+ // Helper function to get all nodes that will be deleted
591
+ const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: DirectedGraph) => {
592
+ const nodesToDelete = new Set<string>([nodeId]);
593
+
594
+ // Find all nodes that would become isolated after deletion
595
+ graph.forEachNode((node) => {
596
+ if (node === nodeId) return; // Skip the node being deleted
597
+
598
+ // Get all neighbors of this node
599
+ const neighbors = graph.neighbors(node);
600
+
601
+ // If this node has only one neighbor and that neighbor is the node being deleted,
602
+ // this node will become isolated, so we should delete it too
603
+ if (neighbors.length === 1 && neighbors[0] === nodeId) {
604
+ nodesToDelete.add(node);
605
+ }
606
+ });
607
+
608
+ return nodesToDelete;
609
+ }, []);
610
+
611
+ // Handle node pruning
612
+ useEffect(() => {
613
+ const handleNodePrune = (nodeId: string | null) => {
614
+ if (!nodeId || !sigmaGraph || !rawGraph) return;
615
+
616
+ try {
617
+ // Check if the node exists
618
+ if (!sigmaGraph.hasNode(nodeId)) {
619
+ console.error('Node not found:', nodeId);
620
+ return;
621
+ }
622
+
623
+ // Get all nodes that will be deleted (including isolated nodes)
624
+ const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph);
625
+
626
+ // Check if we would delete all nodes in the graph
627
+ if (nodesToDelete.size === sigmaGraph.nodes().length) {
628
+ toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError'));
629
+ return;
630
+ }
631
+
632
+ // If the node is selected or focused, clear selection
633
+ const state = useGraphStore.getState();
634
+ if (state.selectedNode === nodeId || state.focusedNode === nodeId) {
635
+ state.clearSelection();
636
+ }
637
+
638
+ // Process all nodes that need to be deleted
639
+ for (const nodeToDelete of nodesToDelete) {
640
+ // Remove the node from the sigma graph (this will also remove connected edges)
641
+ sigmaGraph.dropNode(nodeToDelete);
642
+
643
+ // Remove the node from the raw graph
644
+ const nodeIndex = rawGraph.nodeIdMap[nodeToDelete];
645
+ if (nodeIndex !== undefined) {
646
+ // Find all edges connected to this node
647
+ const edgesToRemove = rawGraph.edges.filter(
648
+ edge => edge.source === nodeToDelete || edge.target === nodeToDelete
649
+ );
650
+
651
+ // Remove edges from raw graph
652
+ for (const edge of edgesToRemove) {
653
+ const edgeIndex = rawGraph.edgeIdMap[edge.id];
654
+ if (edgeIndex !== undefined) {
655
+ // Remove from edges array
656
+ rawGraph.edges.splice(edgeIndex, 1);
657
+ // Update edgeIdMap for all edges after this one
658
+ for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) {
659
+ if (idx > edgeIndex) {
660
+ rawGraph.edgeIdMap[id] = idx - 1;
661
+ }
662
+ }
663
+ // Remove from edgeIdMap
664
+ delete rawGraph.edgeIdMap[edge.id];
665
+ // Remove from edgeDynamicIdMap
666
+ delete rawGraph.edgeDynamicIdMap[edge.dynamicId];
667
+ }
668
+ }
669
+
670
+ // Remove node from nodes array
671
+ rawGraph.nodes.splice(nodeIndex, 1);
672
+
673
+ // Update nodeIdMap for all nodes after this one
674
+ for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) {
675
+ if (idx > nodeIndex) {
676
+ rawGraph.nodeIdMap[id] = idx - 1;
677
+ }
678
+ }
679
+
680
+ // Remove from nodeIdMap
681
+ delete rawGraph.nodeIdMap[nodeToDelete];
682
+ }
683
+ }
684
+
685
+ // Rebuild the dynamic edge map
686
+ rawGraph.buildDynamicMap();
687
+
688
+ // Show notification if we deleted more than just the selected node
689
+ if (nodesToDelete.size > 1) {
690
+ toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
691
+ }
692
+
693
+ // Force a refresh of the graph layout
694
+ useGraphStore.getState().refreshLayout();
695
+
696
+ } catch (error) {
697
+ console.error('Error pruning node:', error);
698
+ }
699
+ };
700
+
701
+ // If there's a node to prune, handle it
702
+ if (nodeToPrune) {
703
+ handleNodePrune(nodeToPrune);
704
+ // Reset the nodeToPrune state after handling
705
+ window.setTimeout(() => {
706
+ useGraphStore.getState().triggerNodePrune(null);
707
+ }, 0);
708
+ }
709
+ }, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);
710
+
711
  const lightrageGraph = useCallback(() => {
712
  // If we already have a graph instance, return it
713
  if (sigmaGraph) {
lightrag_webui/src/locales/en.json CHANGED
@@ -151,6 +151,11 @@
151
  "degree": "Degree",
152
  "properties": "Properties",
153
  "relationships": "Relationships",
 
 
 
 
 
154
  "propertyNames": {
155
  "description": "Description",
156
  "entity_id": "Name",
 
151
  "degree": "Degree",
152
  "properties": "Properties",
153
  "relationships": "Relationships",
154
+ "expandNode": "Expand Node",
155
+ "pruneNode": "Prune Node",
156
+ "deleteAllNodesError": "Refuse to delete all nodes in the graph",
157
+ "nodesRemoved": "{{count}} nodes removed, including orphan nodes",
158
+ "noNewNodes": "No expandable nodes found",
159
  "propertyNames": {
160
  "description": "Description",
161
  "entity_id": "Name",
lightrag_webui/src/locales/zh.json CHANGED
@@ -148,6 +148,11 @@
148
  "degree": "度数",
149
  "properties": "属性",
150
  "relationships": "关系",
 
 
 
 
 
151
  "propertyNames": {
152
  "description": "描述",
153
  "entity_id": "名称",
 
148
  "degree": "度数",
149
  "properties": "属性",
150
  "relationships": "关系",
151
+ "expandNode": "扩展节点",
152
+ "pruneNode": "修剪节点",
153
+ "deleteAllNodesError": "拒绝删除图中的所有节点",
154
+ "nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点",
155
+ "noNewNodes": "没有发现可以扩展的节点",
156
  "propertyNames": {
157
  "description": "描述",
158
  "entity_id": "名称",
lightrag_webui/src/stores/graph.ts CHANGED
@@ -96,6 +96,14 @@ interface GraphState {
96
  // Methods to set global flags
97
  setGraphDataFetchAttempted: (attempted: boolean) => void
98
  setLabelsFetchAttempted: (attempted: boolean) => void
 
 
 
 
 
 
 
 
99
  }
100
 
101
  const useGraphStoreBase = create<GraphState>()((set, get) => ({
@@ -192,7 +200,267 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
192
 
193
  // Methods to set global flags
194
  setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
195
- setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }))
197
 
198
  const useGraphStore = createSelectors(useGraphStoreBase)
 
96
  // Methods to set global flags
97
  setGraphDataFetchAttempted: (attempted: boolean) => void
98
  setLabelsFetchAttempted: (attempted: boolean) => void
99
+
100
+ // Event trigger methods for node operations
101
+ triggerNodeExpand: (nodeId: string | null) => void
102
+ triggerNodePrune: (nodeId: string | null) => void
103
+
104
+ // Node operation state
105
+ nodeToExpand: string | null
106
+ nodeToPrune: string | null
107
  }
108
 
109
  const useGraphStoreBase = create<GraphState>()((set, get) => ({
 
200
 
201
  // Methods to set global flags
202
  setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
203
+ setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted }),
204
+
205
+ // Node operation state
206
+ nodeToExpand: null,
207
+ nodeToPrune: null,
208
+
209
+ // Event trigger methods for node operations
210
+ triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
211
+ triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
212
+
213
+ // Legacy node expansion and pruning methods - will be removed after refactoring
214
+ expandNode: async (nodeId: string) => {
215
+ const state = get();
216
+ if (!state.sigmaGraph || !state.rawGraph || !nodeId) {
217
+ console.error('Cannot expand node: graph or node not available');
218
+ return;
219
+ }
220
+
221
+ try {
222
+ // Set fetching state
223
+ state.setIsFetching(true);
224
+
225
+ // Import queryGraphs dynamically to avoid circular dependency
226
+ const { queryGraphs } = await import('@/api/lightrag');
227
+
228
+ // Get the node to expand
229
+ const nodeToExpand = state.rawGraph.getNode(nodeId);
230
+ if (!nodeToExpand) {
231
+ console.error('Node not found:', nodeId);
232
+ state.setIsFetching(false);
233
+ return;
234
+ }
235
+
236
+ // Get the label of the node to expand
237
+ const label = nodeToExpand.labels[0];
238
+ if (!label) {
239
+ console.error('Node has no label:', nodeId);
240
+ state.setIsFetching(false);
241
+ return;
242
+ }
243
+
244
+ // Fetch the extended subgraph with depth 2
245
+ const extendedGraph = await queryGraphs(label, 2, 0);
246
+
247
+ if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
248
+ console.error('Failed to fetch extended graph');
249
+ state.setIsFetching(false);
250
+ return;
251
+ }
252
+
253
+ // Process nodes to add required properties for RawNodeType
254
+ const processedNodes: RawNodeType[] = [];
255
+ for (const node of extendedGraph.nodes) {
256
+ // Generate random color
257
+ const randomColorValue = () => Math.floor(Math.random() * 256);
258
+ const color = `rgb(${randomColorValue()}, ${randomColorValue()}, ${randomColorValue()})`;
259
+
260
+ // Create a properly typed RawNodeType
261
+ processedNodes.push({
262
+ id: node.id,
263
+ labels: node.labels,
264
+ properties: node.properties,
265
+ size: 10, // Default size
266
+ x: Math.random(), // Random position
267
+ y: Math.random(), // Random position
268
+ color: color, // Random color
269
+ degree: 0 // Initial degree
270
+ });
271
+ }
272
+
273
+ // Process edges to add required properties for RawEdgeType
274
+ const processedEdges: RawEdgeType[] = [];
275
+ for (const edge of extendedGraph.edges) {
276
+ // Create a properly typed RawEdgeType
277
+ processedEdges.push({
278
+ id: edge.id,
279
+ source: edge.source,
280
+ target: edge.target,
281
+ type: edge.type,
282
+ properties: edge.properties,
283
+ dynamicId: '' // Will be set when adding to sigma graph
284
+ });
285
+ }
286
+
287
+ // Store current node positions
288
+ const nodePositions: Record<string, {x: number, y: number}> = {};
289
+ state.sigmaGraph.forEachNode((node) => {
290
+ nodePositions[node] = {
291
+ x: state.sigmaGraph!.getNodeAttribute(node, 'x'),
292
+ y: state.sigmaGraph!.getNodeAttribute(node, 'y')
293
+ };
294
+ });
295
+
296
+ // Get existing node IDs
297
+ const existingNodeIds = new Set(state.sigmaGraph.nodes());
298
+
299
+ // Create a map from id to processed node for quick lookup
300
+ const processedNodeMap = new Map<string, RawNodeType>();
301
+ for (const node of processedNodes) {
302
+ processedNodeMap.set(node.id, node);
303
+ }
304
+
305
+ // Create a map from id to processed edge for quick lookup
306
+ const processedEdgeMap = new Map<string, RawEdgeType>();
307
+ for (const edge of processedEdges) {
308
+ processedEdgeMap.set(edge.id, edge);
309
+ }
310
+
311
+ // Add new nodes from the processed nodes
312
+ for (const newNode of processedNodes) {
313
+ // Skip if node already exists
314
+ if (existingNodeIds.has(newNode.id)) {
315
+ continue;
316
+ }
317
+
318
+ // Check if this node is connected to the selected node
319
+ const isConnected = processedEdges.some(
320
+ edge => (edge.source === nodeId && edge.target === newNode.id) ||
321
+ (edge.target === nodeId && edge.source === newNode.id)
322
+ );
323
+
324
+ if (isConnected) {
325
+ // Add the new node to the graph
326
+ state.sigmaGraph.addNode(newNode.id, {
327
+ label: newNode.labels.join(', '),
328
+ color: newNode.color,
329
+ x: nodePositions[nodeId].x + (Math.random() - 0.5) * 0.5,
330
+ y: nodePositions[nodeId].y + (Math.random() - 0.5) * 0.5,
331
+ size: newNode.size,
332
+ borderColor: '#000',
333
+ borderSize: 0.2
334
+ });
335
+
336
+ // Add the node to the raw graph
337
+ if (!state.rawGraph.getNode(newNode.id)) {
338
+ // Add to nodes array
339
+ state.rawGraph.nodes.push(newNode);
340
+ // Update nodeIdMap
341
+ state.rawGraph.nodeIdMap[newNode.id] = state.rawGraph.nodes.length - 1;
342
+ }
343
+ }
344
+ }
345
+
346
+ // Add new edges
347
+ for (const newEdge of processedEdges) {
348
+ // Only add edges where both source and target exist in the graph
349
+ if (state.sigmaGraph.hasNode(newEdge.source) && state.sigmaGraph.hasNode(newEdge.target)) {
350
+ // Skip if edge already exists
351
+ if (state.sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {
352
+ continue;
353
+ }
354
+
355
+ // Add the edge to the sigma graph
356
+ newEdge.dynamicId = state.sigmaGraph.addDirectedEdge(newEdge.source, newEdge.target, {
357
+ label: newEdge.type || undefined
358
+ });
359
+
360
+ // Add the edge to the raw graph
361
+ if (!state.rawGraph.getEdge(newEdge.id, false)) {
362
+ // Add to edges array
363
+ state.rawGraph.edges.push(newEdge);
364
+ // Update edgeIdMap
365
+ state.rawGraph.edgeIdMap[newEdge.id] = state.rawGraph.edges.length - 1;
366
+ // Update dynamic edge map
367
+ state.rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = state.rawGraph.edges.length - 1;
368
+ }
369
+ }
370
+ }
371
+
372
+ // Update the dynamic edge map
373
+ state.rawGraph.buildDynamicMap();
374
+
375
+ // Restore positions for existing nodes
376
+ Object.entries(nodePositions).forEach(([nodeId, position]) => {
377
+ if (state.sigmaGraph!.hasNode(nodeId)) {
378
+ state.sigmaGraph!.setNodeAttribute(nodeId, 'x', position.x);
379
+ state.sigmaGraph!.setNodeAttribute(nodeId, 'y', position.y);
380
+ }
381
+ });
382
+
383
+ // Refresh the layout
384
+ state.refreshLayout();
385
+
386
+ } catch (error) {
387
+ console.error('Error expanding node:', error);
388
+ } finally {
389
+ // Reset fetching state
390
+ state.setIsFetching(false);
391
+ }
392
+ },
393
+
394
+ pruneNode: (nodeId: string) => {
395
+ const state = get();
396
+ if (!state.sigmaGraph || !state.rawGraph || !nodeId) {
397
+ console.error('Cannot prune node: graph or node not available');
398
+ return;
399
+ }
400
+
401
+ try {
402
+ // Check if the node exists
403
+ if (!state.sigmaGraph.hasNode(nodeId)) {
404
+ console.error('Node not found:', nodeId);
405
+ return;
406
+ }
407
+
408
+ // If the node is selected or focused, clear selection
409
+ if (state.selectedNode === nodeId || state.focusedNode === nodeId) {
410
+ state.clearSelection();
411
+ }
412
+
413
+ // Remove the node from the sigma graph (this will also remove connected edges)
414
+ state.sigmaGraph.dropNode(nodeId);
415
+
416
+ // Remove the node from the raw graph
417
+ const nodeIndex = state.rawGraph.nodeIdMap[nodeId];
418
+ if (nodeIndex !== undefined) {
419
+ // Find all edges connected to this node
420
+ const edgesToRemove = state.rawGraph.edges.filter(
421
+ edge => edge.source === nodeId || edge.target === nodeId
422
+ );
423
+
424
+ // Remove edges from raw graph
425
+ for (const edge of edgesToRemove) {
426
+ const edgeIndex = state.rawGraph.edgeIdMap[edge.id];
427
+ if (edgeIndex !== undefined) {
428
+ // Remove from edges array
429
+ state.rawGraph.edges.splice(edgeIndex, 1);
430
+ // Update edgeIdMap for all edges after this one
431
+ for (const [id, idx] of Object.entries(state.rawGraph.edgeIdMap)) {
432
+ if (idx > edgeIndex) {
433
+ state.rawGraph.edgeIdMap[id] = idx - 1;
434
+ }
435
+ }
436
+ // Remove from edgeIdMap
437
+ delete state.rawGraph.edgeIdMap[edge.id];
438
+ // Remove from edgeDynamicIdMap
439
+ delete state.rawGraph.edgeDynamicIdMap[edge.dynamicId];
440
+ }
441
+ }
442
+
443
+ // Remove node from nodes array
444
+ state.rawGraph.nodes.splice(nodeIndex, 1);
445
+
446
+ // Update nodeIdMap for all nodes after this one
447
+ for (const [id, idx] of Object.entries(state.rawGraph.nodeIdMap)) {
448
+ if (idx > nodeIndex) {
449
+ state.rawGraph.nodeIdMap[id] = idx - 1;
450
+ }
451
+ }
452
+
453
+ // Remove from nodeIdMap
454
+ delete state.rawGraph.nodeIdMap[nodeId];
455
+
456
+ // Rebuild the dynamic edge map
457
+ state.rawGraph.buildDynamicMap();
458
+ }
459
+
460
+ } catch (error) {
461
+ console.error('Error pruning node:', error);
462
+ }
463
+ }
464
  }))
465
 
466
  const useGraphStore = createSelectors(useGraphStoreBase)