Refactor graph search to update search engin after node expand or prune
This commit is contained in:
@@ -10,29 +10,27 @@ import { searchResultLimit } from '@/lib/constants'
|
|||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import MiniSearch from 'minisearch'
|
import MiniSearch from 'minisearch'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { OptionItem } from './graphSearchTypes'
|
||||||
|
import { messageId, searchCache } from './graphSearchUtils'
|
||||||
|
|
||||||
interface OptionItem {
|
const NodeOption = ({ id }: { id: string }) => {
|
||||||
id: string
|
const graph = useGraphStore.use.sigmaGraph()
|
||||||
type: 'nodes' | 'edges' | 'message'
|
if (!graph?.hasNode(id)) {
|
||||||
message?: string
|
return null
|
||||||
|
}
|
||||||
|
return <NodeById id={id} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptionComponent(item: OptionItem) {
|
function OptionComponent(item: OptionItem) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{item.type === 'nodes' && <NodeById id={item.id} />}
|
{item.type === 'nodes' && <NodeOption id={item.id} />}
|
||||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||||
{item.type === 'message' && <div>{item.message}</div>}
|
{item.type === 'message' && <div>{item.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = '__message_item'
|
|
||||||
// Reset this cache when graph changes to ensure fresh search results
|
|
||||||
const lastGraph: any = {
|
|
||||||
graph: null,
|
|
||||||
searchEngine: null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component thats display the search input.
|
* Component thats display the search input.
|
||||||
@@ -53,18 +51,18 @@ export const GraphSearchInput = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (graph) {
|
if (graph) {
|
||||||
// Reset cache to ensure fresh search results with new graph data
|
// Reset cache to ensure fresh search results with new graph data
|
||||||
lastGraph.graph = null;
|
searchCache.graph = null;
|
||||||
lastGraph.searchEngine = null;
|
searchCache.searchEngine = null;
|
||||||
}
|
}
|
||||||
}, [graph]);
|
}, [graph]);
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
if (lastGraph.graph == graph) {
|
if (searchCache.graph == graph) {
|
||||||
return lastGraph.searchEngine
|
return searchCache.searchEngine
|
||||||
}
|
}
|
||||||
if (!graph || graph.nodes().length == 0) return
|
if (!graph || graph.nodes().length == 0) return
|
||||||
|
|
||||||
lastGraph.graph = graph
|
searchCache.graph = graph
|
||||||
|
|
||||||
const searchEngine = new MiniSearch({
|
const searchEngine = new MiniSearch({
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
@@ -85,7 +83,7 @@ export const GraphSearchInput = ({
|
|||||||
}))
|
}))
|
||||||
searchEngine.addAll(documents)
|
searchEngine.addAll(documents)
|
||||||
|
|
||||||
lastGraph.searchEngine = searchEngine
|
searchCache.searchEngine = searchEngine
|
||||||
return searchEngine
|
return searchEngine
|
||||||
}, [graph])
|
}, [graph])
|
||||||
|
|
||||||
@@ -95,22 +93,38 @@ export const GraphSearchInput = ({
|
|||||||
const loadOptions = useCallback(
|
const loadOptions = useCallback(
|
||||||
async (query?: string): Promise<OptionItem[]> => {
|
async (query?: string): Promise<OptionItem[]> => {
|
||||||
if (onFocus) onFocus(null)
|
if (onFocus) onFocus(null)
|
||||||
if (!graph || !searchEngine) return []
|
|
||||||
|
|
||||||
// If no query, return first searchResultLimit nodes
|
// Safety checks to prevent crashes
|
||||||
|
if (!graph || !searchEngine) {
|
||||||
|
// Reset cache to ensure fresh search engine initialization on next render
|
||||||
|
searchCache.graph = null
|
||||||
|
searchCache.searchEngine = null
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify graph has nodes before proceeding
|
||||||
|
if (graph.nodes().length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no query, return first searchResultLimit nodes that exist
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const nodeIds = graph.nodes().slice(0, searchResultLimit)
|
const nodeIds = graph.nodes()
|
||||||
|
.filter(id => graph.hasNode(id))
|
||||||
|
.slice(0, searchResultLimit)
|
||||||
return nodeIds.map(id => ({
|
return nodeIds.map(id => ({
|
||||||
id,
|
id,
|
||||||
type: 'nodes'
|
type: 'nodes'
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If has query, search nodes
|
// If has query, search nodes and verify they still exist
|
||||||
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
|
const result: OptionItem[] = searchEngine.search(query)
|
||||||
id: r.id,
|
.filter((r: { id: string }) => graph.hasNode(r.id))
|
||||||
type: 'nodes'
|
.map((r: { id: string }) => ({
|
||||||
}))
|
id: r.id,
|
||||||
|
type: 'nodes'
|
||||||
|
}))
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
return result.length <= searchResultLimit
|
return result.length <= searchResultLimit
|
||||||
|
5
lightrag_webui/src/components/graph/graphSearchTypes.ts
Normal file
5
lightrag_webui/src/components/graph/graphSearchTypes.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface OptionItem {
|
||||||
|
id: string
|
||||||
|
type: 'nodes' | 'edges' | 'message'
|
||||||
|
message?: string
|
||||||
|
}
|
28
lightrag_webui/src/components/graph/graphSearchUtils.ts
Normal file
28
lightrag_webui/src/components/graph/graphSearchUtils.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { DirectedGraph } from 'graphology'
|
||||||
|
import MiniSearch from 'minisearch'
|
||||||
|
|
||||||
|
export const messageId = '__message_item'
|
||||||
|
|
||||||
|
// Reset this cache when graph changes to ensure fresh search results
|
||||||
|
export const searchCache: {
|
||||||
|
graph: DirectedGraph | null;
|
||||||
|
searchEngine: MiniSearch | null;
|
||||||
|
} = {
|
||||||
|
graph: null,
|
||||||
|
searchEngine: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSearchEngine = (nodeId: string, graph: DirectedGraph) => {
|
||||||
|
if (!searchCache.searchEngine || !graph) return
|
||||||
|
|
||||||
|
const newDocument = {
|
||||||
|
id: nodeId,
|
||||||
|
label: graph.getNodeAttribute(nodeId, 'label')
|
||||||
|
}
|
||||||
|
searchCache.searchEngine.add(newDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeFromSearchEngine = (nodeId: string) => {
|
||||||
|
if (!searchCache.searchEngine) return
|
||||||
|
searchCache.searchEngine.remove({ id: nodeId })
|
||||||
|
}
|
@@ -11,6 +11,7 @@ import { useSettingsStore } from '@/stores/settings'
|
|||||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||||
|
|
||||||
import seedrandom from 'seedrandom'
|
import seedrandom from 'seedrandom'
|
||||||
|
import { searchCache, updateSearchEngine, removeFromSearchEngine } from '@/components/graph/graphSearchUtils'
|
||||||
|
|
||||||
const validateGraph = (graph: RawGraph) => {
|
const validateGraph = (graph: RawGraph) => {
|
||||||
if (!graph) {
|
if (!graph) {
|
||||||
@@ -544,6 +545,8 @@ const useLightrangeGraph = () => {
|
|||||||
rawGraph.nodes.push(newNode);
|
rawGraph.nodes.push(newNode);
|
||||||
// Update nodeIdMap
|
// Update nodeIdMap
|
||||||
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
|
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
|
||||||
|
// Update search engine with new node
|
||||||
|
updateSearchEngine(nodeId, sigmaGraph);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,9 +575,13 @@ const useLightrangeGraph = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the dynamic edge map
|
// Update the dynamic edge map and invalidate search cache
|
||||||
rawGraph.buildDynamicMap();
|
rawGraph.buildDynamicMap();
|
||||||
|
|
||||||
|
// Force search engine rebuild by invalidating cache
|
||||||
|
searchCache.graph = null;
|
||||||
|
searchCache.searchEngine = null;
|
||||||
|
|
||||||
// STEP 4: Update the expanded node's size
|
// STEP 4: Update the expanded node's size
|
||||||
if (sigmaGraph.hasNode(nodeId)) {
|
if (sigmaGraph.hasNode(nodeId)) {
|
||||||
// Get the new degree of the expanded node
|
// Get the new degree of the expanded node
|
||||||
@@ -710,12 +717,18 @@ const useLightrangeGraph = () => {
|
|||||||
|
|
||||||
// Remove from nodeIdMap
|
// Remove from nodeIdMap
|
||||||
delete rawGraph.nodeIdMap[nodeToDelete];
|
delete rawGraph.nodeIdMap[nodeToDelete];
|
||||||
|
// Remove from search engine
|
||||||
|
removeFromSearchEngine(nodeToDelete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the dynamic edge map
|
// Rebuild the dynamic edge map and invalidate search cache
|
||||||
rawGraph.buildDynamicMap();
|
rawGraph.buildDynamicMap();
|
||||||
|
|
||||||
|
// Force search engine rebuild by invalidating cache
|
||||||
|
searchCache.graph = null;
|
||||||
|
searchCache.searchEngine = null;
|
||||||
|
|
||||||
// Show notification if we deleted more than just the selected node
|
// Show notification if we deleted more than just the selected node
|
||||||
if (nodesToDelete.size > 1) {
|
if (nodesToDelete.size > 1) {
|
||||||
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
|
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
|
||||||
|
Reference in New Issue
Block a user