From d418ceee82c4ee70a0e2c78d2b13f3afd96ad1c7 Mon Sep 17 00:00:00 2001 From: ArnoChen Date: Tue, 11 Feb 2025 06:48:04 +0800 Subject: [PATCH] add label filter --- lightrag/api/graph_viewer_webui/bun.lock | 1 + lightrag/api/graph_viewer_webui/package.json | 1 + lightrag/api/graph_viewer_webui/src/App.tsx | 18 +- .../graph_viewer_webui/src/GraphViewer.tsx | 5 +- .../graph_viewer_webui/src/api/lightrag.ts | 3 +- .../src/components/BackendMessageAlert.tsx | 22 -- .../src/components/GraphLabels.tsx | 87 +++++++ .../src/components/GraphSearch.tsx | 78 ++++-- .../src/components/MessageAlert.tsx | 38 +++ .../src/components/ui/AsyncSearch.tsx | 243 ++++++++++++++++++ .../src/components/ui/AsyncSelect.tsx | 160 +++++++----- .../src/hooks/useLightragGraph.tsx | 34 ++- .../graph_viewer_webui/src/lib/constants.ts | 4 + .../api/graph_viewer_webui/src/lib/utils.ts | 4 + .../graph_viewer_webui/src/stores/graph.ts | 9 - .../graph_viewer_webui/src/stores/settings.ts | 19 +- 16 files changed, 583 insertions(+), 143 deletions(-) delete mode 100644 lightrag/api/graph_viewer_webui/src/components/BackendMessageAlert.tsx create mode 100644 lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx create mode 100644 lightrag/api/graph_viewer_webui/src/components/MessageAlert.tsx create mode 100644 lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx diff --git a/lightrag/api/graph_viewer_webui/bun.lock b/lightrag/api/graph_viewer_webui/bun.lock index fcd1a18d..21073f15 100644 --- a/lightrag/api/graph_viewer_webui/bun.lock +++ b/lightrag/api/graph_viewer_webui/bun.lock @@ -27,6 +27,7 @@ "graphology": "^0.26.0", "graphology-generators": "^0.11.2", "lucide-react": "^0.475.0", + "minisearch": "^7.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "seedrandom": "^3.0.5", diff --git a/lightrag/api/graph_viewer_webui/package.json b/lightrag/api/graph_viewer_webui/package.json index cd488cec..1057f3f6 100644 --- a/lightrag/api/graph_viewer_webui/package.json +++ b/lightrag/api/graph_viewer_webui/package.json @@ -33,6 +33,7 @@ "graphology": "^0.26.0", "graphology-generators": "^0.11.2", "lucide-react": "^0.475.0", + "minisearch": "^7.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "seedrandom": "^3.0.5", diff --git a/lightrag/api/graph_viewer_webui/src/App.tsx b/lightrag/api/graph_viewer_webui/src/App.tsx index d859c239..11a5761a 100644 --- a/lightrag/api/graph_viewer_webui/src/App.tsx +++ b/lightrag/api/graph_viewer_webui/src/App.tsx @@ -1,18 +1,28 @@ import ThemeProvider from '@/components/ThemeProvider' -import BackendMessageAlert from '@/components/BackendMessageAlert' +import MessageAlert from '@/components/MessageAlert' import { GraphViewer } from '@/GraphViewer' import { cn } from '@/lib/utils' +import { healthCheckInterval } from '@/lib/constants' import { useBackendState } from '@/stores/state' +import { useEffect } from 'react' function App() { - const health = useBackendState.use.health() + const message = useBackendState.use.message() + + // health check + useEffect(() => { + const interval = setInterval(async () => { + await useBackendState.getState().check() + }, healthCheckInterval * 1000) + return () => clearInterval(interval) + }, []) return ( -
+
- {!health && } + {message !== null && } ) } diff --git a/lightrag/api/graph_viewer_webui/src/GraphViewer.tsx b/lightrag/api/graph_viewer_webui/src/GraphViewer.tsx index 8cd8d561..993b4b3c 100644 --- a/lightrag/api/graph_viewer_webui/src/GraphViewer.tsx +++ b/lightrag/api/graph_viewer_webui/src/GraphViewer.tsx @@ -15,6 +15,7 @@ import ZoomControl from '@/components/ZoomControl' import FullScreenControl from '@/components/FullScreenControl' import Settings from '@/components/Settings' import GraphSearch from '@/components/GraphSearch' +import GraphLabels from '@/components/GraphLabels' import PropertiesView from '@/components/PropertiesView' import { useSettingsStore } from '@/stores/settings' @@ -144,9 +145,9 @@ export const GraphViewer = () => { -
+
+ { - const health = useBackendState.use.health() - const message = useBackendState.use.message() - const messageTitle = useBackendState.use.messageTitle() - - return ( - - {!health && } - {messageTitle} - {message} - - ) -} - -export default BackendMessageAlert diff --git a/lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx b/lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx new file mode 100644 index 00000000..b3c325ad --- /dev/null +++ b/lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx @@ -0,0 +1,87 @@ +import { useCallback, useState } from 'react' +import { AsyncSelect } from '@/components/ui/AsyncSelect' +import { getGraphLabels } from '@/api/lightrag' +import { useSettingsStore } from '@/stores/settings' +import MiniSearch from 'minisearch' + +const GraphLabels = () => { + const label = useSettingsStore.use.queryLabel() + const [labels, setLabels] = useState<{ + labels: string[] + searchEngine: MiniSearch | null + }>({ + labels: [], + searchEngine: null + }) + const [fetched, setFetched] = useState(false) + + const fetchData = useCallback( + async (query?: string): Promise => { + let _labels = labels.labels + let _searchEngine = labels.searchEngine + + if (!fetched || !_searchEngine) { + _labels = ['*'].concat(await getGraphLabels()) + + // Ensure query label exists + if (!_labels.includes(useSettingsStore.getState().queryLabel)) { + useSettingsStore.getState().setQueryLabel(_labels[0]) + } + + // Create search engine + _searchEngine = new MiniSearch({ + idField: 'id', + fields: ['value'], + searchOptions: { + prefix: true, + fuzzy: 0.2, + boost: { + label: 2 + } + } + }) + + // Add documents + const documents = _labels.map((str, index) => ({ id: index, value: str })) + _searchEngine.addAll(documents) + + setLabels({ + labels: _labels, + searchEngine: _searchEngine + }) + setFetched(true) + } + if (!query) { + return _labels + } + + // Search labels + return _searchEngine.search(query).map((result) => _labels[result.id]) + }, + [labels, fetched, setLabels, setFetched] + ) + + const setQueryLabel = useCallback((label: string) => { + useSettingsStore.getState().setQueryLabel(label) + }, []) + + return ( + + className="ml-2" + triggerClassName="max-h-8" + searchInputClassName="max-h-8" + triggerTooltip="Select query label" + fetcher={fetchData} + renderOption={(item) =>
{item}
} + getOptionValue={(item) => item} + getDisplayValue={(item) =>
{item}
} + notFound={
No labels found
} + label="Label" + placeholder="Search labels..." + value={label !== null ? label : ''} + onChange={setQueryLabel} + /> + ) +} + +export default GraphLabels diff --git a/lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx b/lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx index b6267f91..16d4c62d 100644 --- a/lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx +++ b/lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx @@ -1,14 +1,14 @@ -import { FC, useCallback } from 'react' +import { FC, useCallback, useMemo } from 'react' import { EdgeById, NodeById, - useGraphSearch, GraphSearchInputProps, - GraphSearchContextProvider, GraphSearchContextProviderProps } from '@react-sigma/graph-search' -import { AsyncSelect } from '@/components/ui/AsyncSelect' +import { AsyncSearch } from '@/components/ui/AsyncSearch' import { searchResultLimit } from '@/lib/constants' +import { useGraphStore } from '@/stores/graph' +import MiniSearch from 'minisearch' interface OptionItem { id: string @@ -27,6 +27,10 @@ function OptionComponent(item: OptionItem) { } const messageId = '__message_item' +const lastGraph: any = { + graph: null, + searchEngine: null +} /** * Component thats display the search input. @@ -34,15 +38,44 @@ const messageId = '__message_item' export const GraphSearchInput = ({ onChange, onFocus, - type, value }: { onChange: GraphSearchInputProps['onChange'] onFocus?: GraphSearchInputProps['onFocus'] - type?: GraphSearchInputProps['type'] value?: GraphSearchInputProps['value'] }) => { - const { search } = useGraphSearch() + const graph = useGraphStore.use.sigmaGraph() + + const search = useMemo(() => { + if (lastGraph.graph == graph) { + return lastGraph.searchEngine + } + if (!graph || graph.nodes().length == 0) return + + lastGraph.graph = graph + + const searchEngine = new MiniSearch({ + idField: 'id', + fields: ['label'], + searchOptions: { + prefix: true, + fuzzy: 0.2, + boost: { + label: 2 + } + } + }) + + // Add documents + const documents = graph.nodes().map((id: string) => ({ + id: id, + label: graph.getNodeAttribute(id, 'label') + })) + searchEngine.addAll(documents) + + lastGraph.searchEngine = searchEngine + return searchEngine + }, [graph]) /** * Loading the options while the user is typing. @@ -50,8 +83,11 @@ export const GraphSearchInput = ({ const loadOptions = useCallback( async (query?: string): Promise => { if (onFocus) onFocus(null) - if (!query) return [] - const result = (await search(query, type)) as OptionItem[] + if (!query || !search) return [] + const result: OptionItem[] = search.search(query).map((result) => ({ + id: result.id, + type: 'nodes' + })) // prettier-ignore return result.length <= searchResultLimit @@ -65,25 +101,24 @@ export const GraphSearchInput = ({ } ] }, - [type, search, onFocus] + [search, onFocus] ) return ( - item.id} value={value && value.type !== 'message' ? value.id : null} onChange={(id) => { - if (id !== messageId && type) onChange(id ? { id, type } : null) + if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null) }} onFocus={(id) => { - if (id !== messageId && onFocus && type) onFocus(id ? { id, type } : null) + if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null) }} label={'item'} - preload={false} - placeholder="Type search here..." + placeholder="Search nodes..." /> ) } @@ -91,13 +126,8 @@ export const GraphSearchInput = ({ /** * Component that display the search. */ -const GraphSearch: FC = ({ - minisearchOptions, - ...props -}) => ( - - - -) +const GraphSearch: FC = ({ ...props }) => { + return +} export default GraphSearch diff --git a/lightrag/api/graph_viewer_webui/src/components/MessageAlert.tsx b/lightrag/api/graph_viewer_webui/src/components/MessageAlert.tsx new file mode 100644 index 00000000..392459aa --- /dev/null +++ b/lightrag/api/graph_viewer_webui/src/components/MessageAlert.tsx @@ -0,0 +1,38 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import Button from '@/components/ui/Button' +import { useBackendState } from '@/stores/state' +import { controlButtonVariant } from '@/lib/constants' + +import { AlertCircle } from 'lucide-react' + +const MessageAlert = () => { + const health = useBackendState.use.health() + const message = useBackendState.use.message() + const messageTitle = useBackendState.use.messageTitle() + + return ( + + {!health && } + {messageTitle} + + {message} +
+
+
+ +
+ + ) +} + +export default MessageAlert diff --git a/lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx b/lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx new file mode 100644 index 00000000..c417fd6b --- /dev/null +++ b/lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback } from 'react' +import { Loader2 } from 'lucide-react' +import { useDebounce } from '@/hooks/useDebounce' + +import { cn } from '@/lib/utils' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/Command' + +export interface Option { + value: string + label: string + disabled?: boolean + description?: string + icon?: React.ReactNode +} + +export interface AsyncSearchProps { + /** Async function to fetch options */ + fetcher: (query?: string) => Promise + /** Preload all data ahead of time */ + preload?: boolean + /** Function to filter options */ + filterFn?: (option: T, query: string) => boolean + /** Function to render each option */ + renderOption: (option: T) => React.ReactNode + /** Function to get the value from an option */ + getOptionValue: (option: T) => string + /** Custom not found message */ + notFound?: React.ReactNode + /** Custom loading skeleton */ + loadingSkeleton?: React.ReactNode + /** Currently selected value */ + value: string | null + /** Callback when selection changes */ + onChange: (value: string) => void + /** Callback when focus changes */ + onFocus: (value: string) => void + /** Label for the select field */ + label: string + /** Placeholder text when no selection */ + placeholder?: string + /** Disable the entire select */ + disabled?: boolean + /** Custom width for the popover */ + width?: string | number + /** Custom class names */ + className?: string + /** Custom trigger button class names */ + triggerClassName?: string + /** Custom no results message */ + noResultsMessage?: string + /** Allow clearing the selection */ + clearable?: boolean +} + +export function AsyncSearch({ + fetcher, + preload, + filterFn, + renderOption, + getOptionValue, + notFound, + loadingSkeleton, + label, + placeholder = 'Select...', + value, + onChange, + onFocus, + disabled = false, + className, + noResultsMessage +}: AsyncSearchProps) { + const [mounted, setMounted] = useState(false) + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [selectedValue, setSelectedValue] = useState(value) + const [focusedValue, setFocusedValue] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150) + const [originalOptions, setOriginalOptions] = useState([]) + + useEffect(() => { + setMounted(true) + setSelectedValue(value) + }, [value]) + + // Effect for initial fetch + useEffect(() => { + const initializeOptions = async () => { + try { + setLoading(true) + setError(null) + // If we have a value, use it for the initial search + const data = value !== null ? await fetcher(value) : [] + setOriginalOptions(data) + setOptions(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch options') + } finally { + setLoading(false) + } + } + + if (!mounted) { + initializeOptions() + } + }, [mounted, fetcher, value]) + + useEffect(() => { + const fetchOptions = async () => { + try { + setLoading(true) + setError(null) + const data = await fetcher(debouncedSearchTerm) + setOriginalOptions(data) + setOptions(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch options') + } finally { + setLoading(false) + } + } + + if (!mounted) { + fetchOptions() + } else if (!preload) { + fetchOptions() + } else if (preload) { + if (debouncedSearchTerm) { + setOptions( + originalOptions.filter((option) => + filterFn ? filterFn(option, debouncedSearchTerm) : true + ) + ) + } else { + setOptions(originalOptions) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]) + + const handleSelect = useCallback( + (currentValue: string) => { + if (currentValue !== selectedValue) { + setSelectedValue(currentValue) + onChange(currentValue) + } + setOpen(false) + }, + [selectedValue, setSelectedValue, setOpen, onChange] + ) + + const handleFocus = useCallback( + (currentValue: string) => { + if (currentValue !== focusedValue) { + setFocusedValue(currentValue) + onFocus(currentValue) + } + }, + [focusedValue, setFocusedValue, onFocus] + ) + + return ( +
{ + setOpen(true) + }} + onBlur={() => setOpen(false)} + > + +
+ { + setSearchTerm(value) + if (value && !open) setOpen(true) + }} + /> + {loading && options.length > 0 && ( +
+ +
+ )} +
+