ArnoChen
commited on
Commit
·
3578090
1
Parent(s):
d42b23a
add label filter
Browse files- lightrag/api/graph_viewer_webui/bun.lock +1 -0
- lightrag/api/graph_viewer_webui/package.json +1 -0
- lightrag/api/graph_viewer_webui/src/App.tsx +14 -4
- lightrag/api/graph_viewer_webui/src/GraphViewer.tsx +3 -2
- lightrag/api/graph_viewer_webui/src/api/lightrag.ts +2 -1
- lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx +87 -0
- lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx +54 -24
- lightrag/api/graph_viewer_webui/src/components/{BackendMessageAlert.tsx → MessageAlert.tsx} +19 -3
- lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx +243 -0
- lightrag/api/graph_viewer_webui/src/components/ui/AsyncSelect.tsx +95 -65
- lightrag/api/graph_viewer_webui/src/hooks/useLightragGraph.tsx +20 -14
- lightrag/api/graph_viewer_webui/src/lib/constants.ts +4 -0
- lightrag/api/graph_viewer_webui/src/lib/utils.ts +4 -0
- lightrag/api/graph_viewer_webui/src/stores/graph.ts +0 -9
- lightrag/api/graph_viewer_webui/src/stores/settings.ts +17 -2
lightrag/api/graph_viewer_webui/bun.lock
CHANGED
@@ -27,6 +27,7 @@
|
|
27 |
"graphology": "^0.26.0",
|
28 |
"graphology-generators": "^0.11.2",
|
29 |
"lucide-react": "^0.475.0",
|
|
|
30 |
"react": "^19.0.0",
|
31 |
"react-dom": "^19.0.0",
|
32 |
"seedrandom": "^3.0.5",
|
|
|
27 |
"graphology": "^0.26.0",
|
28 |
"graphology-generators": "^0.11.2",
|
29 |
"lucide-react": "^0.475.0",
|
30 |
+
"minisearch": "^7.1.1",
|
31 |
"react": "^19.0.0",
|
32 |
"react-dom": "^19.0.0",
|
33 |
"seedrandom": "^3.0.5",
|
lightrag/api/graph_viewer_webui/package.json
CHANGED
@@ -33,6 +33,7 @@
|
|
33 |
"graphology": "^0.26.0",
|
34 |
"graphology-generators": "^0.11.2",
|
35 |
"lucide-react": "^0.475.0",
|
|
|
36 |
"react": "^19.0.0",
|
37 |
"react-dom": "^19.0.0",
|
38 |
"seedrandom": "^3.0.5",
|
|
|
33 |
"graphology": "^0.26.0",
|
34 |
"graphology-generators": "^0.11.2",
|
35 |
"lucide-react": "^0.475.0",
|
36 |
+
"minisearch": "^7.1.1",
|
37 |
"react": "^19.0.0",
|
38 |
"react-dom": "^19.0.0",
|
39 |
"seedrandom": "^3.0.5",
|
lightrag/api/graph_viewer_webui/src/App.tsx
CHANGED
@@ -1,18 +1,28 @@
|
|
1 |
import ThemeProvider from '@/components/ThemeProvider'
|
2 |
-
import
|
3 |
import { GraphViewer } from '@/GraphViewer'
|
4 |
import { cn } from '@/lib/utils'
|
|
|
5 |
import { useBackendState } from '@/stores/state'
|
|
|
6 |
|
7 |
function App() {
|
8 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
return (
|
11 |
<ThemeProvider>
|
12 |
-
<div className={cn('h-screen w-screen',
|
13 |
<GraphViewer />
|
14 |
</div>
|
15 |
-
{
|
16 |
</ThemeProvider>
|
17 |
)
|
18 |
}
|
|
|
1 |
import ThemeProvider from '@/components/ThemeProvider'
|
2 |
+
import MessageAlert from '@/components/MessageAlert'
|
3 |
import { GraphViewer } from '@/GraphViewer'
|
4 |
import { cn } from '@/lib/utils'
|
5 |
+
import { healthCheckInterval } from '@/lib/constants'
|
6 |
import { useBackendState } from '@/stores/state'
|
7 |
+
import { useEffect } from 'react'
|
8 |
|
9 |
function App() {
|
10 |
+
const message = useBackendState.use.message()
|
11 |
+
|
12 |
+
// health check
|
13 |
+
useEffect(() => {
|
14 |
+
const interval = setInterval(async () => {
|
15 |
+
await useBackendState.getState().check()
|
16 |
+
}, healthCheckInterval * 1000)
|
17 |
+
return () => clearInterval(interval)
|
18 |
+
}, [])
|
19 |
|
20 |
return (
|
21 |
<ThemeProvider>
|
22 |
+
<div className={cn('h-screen w-screen', message !== null && 'pointer-events-none')}>
|
23 |
<GraphViewer />
|
24 |
</div>
|
25 |
+
{message !== null && <MessageAlert />}
|
26 |
</ThemeProvider>
|
27 |
)
|
28 |
}
|
lightrag/api/graph_viewer_webui/src/GraphViewer.tsx
CHANGED
@@ -15,6 +15,7 @@ import ZoomControl from '@/components/ZoomControl'
|
|
15 |
import FullScreenControl from '@/components/FullScreenControl'
|
16 |
import Settings from '@/components/Settings'
|
17 |
import GraphSearch from '@/components/GraphSearch'
|
|
|
18 |
import PropertiesView from '@/components/PropertiesView'
|
19 |
|
20 |
import { useSettingsStore } from '@/stores/settings'
|
@@ -144,9 +145,9 @@ export const GraphViewer = () => {
|
|
144 |
|
145 |
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
146 |
|
147 |
-
<div className="absolute top-2 left-2">
|
|
|
148 |
<GraphSearch
|
149 |
-
type="nodes"
|
150 |
value={searchInitSelectedNode}
|
151 |
onFocus={onSearchFocus}
|
152 |
onChange={onSearchSelect}
|
|
|
15 |
import FullScreenControl from '@/components/FullScreenControl'
|
16 |
import Settings from '@/components/Settings'
|
17 |
import GraphSearch from '@/components/GraphSearch'
|
18 |
+
import GraphLabels from '@/components/GraphLabels'
|
19 |
import PropertiesView from '@/components/PropertiesView'
|
20 |
|
21 |
import { useSettingsStore } from '@/stores/settings'
|
|
|
145 |
|
146 |
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
147 |
|
148 |
+
<div className="absolute top-2 left-2 flex items-start gap-2">
|
149 |
+
<GraphLabels />
|
150 |
<GraphSearch
|
|
|
151 |
value={searchInitSelectedNode}
|
152 |
onFocus={onSearchFocus}
|
153 |
onChange={onSearchSelect}
|
lightrag/api/graph_viewer_webui/src/api/lightrag.ts
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import { backendBaseUrl } from '@/lib/constants'
|
|
|
2 |
|
3 |
export type LightragNodeType = {
|
4 |
id: string
|
@@ -81,7 +82,7 @@ export const checkHealth = async (): Promise<
|
|
81 |
} catch (e) {
|
82 |
return {
|
83 |
status: 'error',
|
84 |
-
message: e
|
85 |
}
|
86 |
}
|
87 |
}
|
|
|
1 |
import { backendBaseUrl } from '@/lib/constants'
|
2 |
+
import { errorMessage } from '@/lib/utils'
|
3 |
|
4 |
export type LightragNodeType = {
|
5 |
id: string
|
|
|
82 |
} catch (e) {
|
83 |
return {
|
84 |
status: 'error',
|
85 |
+
message: errorMessage(e)
|
86 |
}
|
87 |
}
|
88 |
}
|
lightrag/api/graph_viewer_webui/src/components/GraphLabels.tsx
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback, useState } from 'react'
|
2 |
+
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
3 |
+
import { getGraphLabels } from '@/api/lightrag'
|
4 |
+
import { useSettingsStore } from '@/stores/settings'
|
5 |
+
import MiniSearch from 'minisearch'
|
6 |
+
|
7 |
+
const GraphLabels = () => {
|
8 |
+
const label = useSettingsStore.use.queryLabel()
|
9 |
+
const [labels, setLabels] = useState<{
|
10 |
+
labels: string[]
|
11 |
+
searchEngine: MiniSearch | null
|
12 |
+
}>({
|
13 |
+
labels: [],
|
14 |
+
searchEngine: null
|
15 |
+
})
|
16 |
+
const [fetched, setFetched] = useState(false)
|
17 |
+
|
18 |
+
const fetchData = useCallback(
|
19 |
+
async (query?: string): Promise<string[]> => {
|
20 |
+
let _labels = labels.labels
|
21 |
+
let _searchEngine = labels.searchEngine
|
22 |
+
|
23 |
+
if (!fetched || !_searchEngine) {
|
24 |
+
_labels = ['*'].concat(await getGraphLabels())
|
25 |
+
|
26 |
+
// Ensure query label exists
|
27 |
+
if (!_labels.includes(useSettingsStore.getState().queryLabel)) {
|
28 |
+
useSettingsStore.getState().setQueryLabel(_labels[0])
|
29 |
+
}
|
30 |
+
|
31 |
+
// Create search engine
|
32 |
+
_searchEngine = new MiniSearch({
|
33 |
+
idField: 'id',
|
34 |
+
fields: ['value'],
|
35 |
+
searchOptions: {
|
36 |
+
prefix: true,
|
37 |
+
fuzzy: 0.2,
|
38 |
+
boost: {
|
39 |
+
label: 2
|
40 |
+
}
|
41 |
+
}
|
42 |
+
})
|
43 |
+
|
44 |
+
// Add documents
|
45 |
+
const documents = _labels.map((str, index) => ({ id: index, value: str }))
|
46 |
+
_searchEngine.addAll(documents)
|
47 |
+
|
48 |
+
setLabels({
|
49 |
+
labels: _labels,
|
50 |
+
searchEngine: _searchEngine
|
51 |
+
})
|
52 |
+
setFetched(true)
|
53 |
+
}
|
54 |
+
if (!query) {
|
55 |
+
return _labels
|
56 |
+
}
|
57 |
+
|
58 |
+
// Search labels
|
59 |
+
return _searchEngine.search(query).map((result) => _labels[result.id])
|
60 |
+
},
|
61 |
+
[labels, fetched, setLabels, setFetched]
|
62 |
+
)
|
63 |
+
|
64 |
+
const setQueryLabel = useCallback((label: string) => {
|
65 |
+
useSettingsStore.getState().setQueryLabel(label)
|
66 |
+
}, [])
|
67 |
+
|
68 |
+
return (
|
69 |
+
<AsyncSelect<string>
|
70 |
+
className="ml-2"
|
71 |
+
triggerClassName="max-h-8"
|
72 |
+
searchInputClassName="max-h-8"
|
73 |
+
triggerTooltip="Select query label"
|
74 |
+
fetcher={fetchData}
|
75 |
+
renderOption={(item) => <div>{item}</div>}
|
76 |
+
getOptionValue={(item) => item}
|
77 |
+
getDisplayValue={(item) => <div>{item}</div>}
|
78 |
+
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
79 |
+
label="Label"
|
80 |
+
placeholder="Search labels..."
|
81 |
+
value={label !== null ? label : ''}
|
82 |
+
onChange={setQueryLabel}
|
83 |
+
/>
|
84 |
+
)
|
85 |
+
}
|
86 |
+
|
87 |
+
export default GraphLabels
|
lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx
CHANGED
@@ -1,14 +1,14 @@
|
|
1 |
-
import { FC, useCallback } from 'react'
|
2 |
import {
|
3 |
EdgeById,
|
4 |
NodeById,
|
5 |
-
useGraphSearch,
|
6 |
GraphSearchInputProps,
|
7 |
-
GraphSearchContextProvider,
|
8 |
GraphSearchContextProviderProps
|
9 |
} from '@react-sigma/graph-search'
|
10 |
-
import {
|
11 |
import { searchResultLimit } from '@/lib/constants'
|
|
|
|
|
12 |
|
13 |
interface OptionItem {
|
14 |
id: string
|
@@ -27,6 +27,10 @@ function OptionComponent(item: OptionItem) {
|
|
27 |
}
|
28 |
|
29 |
const messageId = '__message_item'
|
|
|
|
|
|
|
|
|
30 |
|
31 |
/**
|
32 |
* Component thats display the search input.
|
@@ -34,15 +38,44 @@ const messageId = '__message_item'
|
|
34 |
export const GraphSearchInput = ({
|
35 |
onChange,
|
36 |
onFocus,
|
37 |
-
type,
|
38 |
value
|
39 |
}: {
|
40 |
onChange: GraphSearchInputProps['onChange']
|
41 |
onFocus?: GraphSearchInputProps['onFocus']
|
42 |
-
type?: GraphSearchInputProps['type']
|
43 |
value?: GraphSearchInputProps['value']
|
44 |
}) => {
|
45 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
|
47 |
/**
|
48 |
* Loading the options while the user is typing.
|
@@ -50,8 +83,11 @@ export const GraphSearchInput = ({
|
|
50 |
const loadOptions = useCallback(
|
51 |
async (query?: string): Promise<OptionItem[]> => {
|
52 |
if (onFocus) onFocus(null)
|
53 |
-
if (!query) return []
|
54 |
-
const result =
|
|
|
|
|
|
|
55 |
|
56 |
// prettier-ignore
|
57 |
return result.length <= searchResultLimit
|
@@ -65,25 +101,24 @@ export const GraphSearchInput = ({
|
|
65 |
}
|
66 |
]
|
67 |
},
|
68 |
-
[
|
69 |
)
|
70 |
|
71 |
return (
|
72 |
-
<
|
73 |
-
className="bg-background/60 w-
|
74 |
fetcher={loadOptions}
|
75 |
renderOption={OptionComponent}
|
76 |
getOptionValue={(item) => item.id}
|
77 |
value={value && value.type !== 'message' ? value.id : null}
|
78 |
onChange={(id) => {
|
79 |
-
if (id !== messageId
|
80 |
}}
|
81 |
onFocus={(id) => {
|
82 |
-
if (id !== messageId && onFocus
|
83 |
}}
|
84 |
label={'item'}
|
85 |
-
|
86 |
-
placeholder="Type search here..."
|
87 |
/>
|
88 |
)
|
89 |
}
|
@@ -91,13 +126,8 @@ export const GraphSearchInput = ({
|
|
91 |
/**
|
92 |
* Component that display the search.
|
93 |
*/
|
94 |
-
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({
|
95 |
-
|
96 |
-
|
97 |
-
}) => (
|
98 |
-
<GraphSearchContextProvider minisearchOptions={minisearchOptions}>
|
99 |
-
<GraphSearchInput {...props} />
|
100 |
-
</GraphSearchContextProvider>
|
101 |
-
)
|
102 |
|
103 |
export default GraphSearch
|
|
|
1 |
+
import { FC, useCallback, useMemo } from 'react'
|
2 |
import {
|
3 |
EdgeById,
|
4 |
NodeById,
|
|
|
5 |
GraphSearchInputProps,
|
|
|
6 |
GraphSearchContextProviderProps
|
7 |
} from '@react-sigma/graph-search'
|
8 |
+
import { AsyncSearch } from '@/components/ui/AsyncSearch'
|
9 |
import { searchResultLimit } from '@/lib/constants'
|
10 |
+
import { useGraphStore } from '@/stores/graph'
|
11 |
+
import MiniSearch from 'minisearch'
|
12 |
|
13 |
interface OptionItem {
|
14 |
id: string
|
|
|
27 |
}
|
28 |
|
29 |
const messageId = '__message_item'
|
30 |
+
const lastGraph: any = {
|
31 |
+
graph: null,
|
32 |
+
searchEngine: null
|
33 |
+
}
|
34 |
|
35 |
/**
|
36 |
* Component thats display the search input.
|
|
|
38 |
export const GraphSearchInput = ({
|
39 |
onChange,
|
40 |
onFocus,
|
|
|
41 |
value
|
42 |
}: {
|
43 |
onChange: GraphSearchInputProps['onChange']
|
44 |
onFocus?: GraphSearchInputProps['onFocus']
|
|
|
45 |
value?: GraphSearchInputProps['value']
|
46 |
}) => {
|
47 |
+
const graph = useGraphStore.use.sigmaGraph()
|
48 |
+
|
49 |
+
const search = useMemo(() => {
|
50 |
+
if (lastGraph.graph == graph) {
|
51 |
+
return lastGraph.searchEngine
|
52 |
+
}
|
53 |
+
if (!graph || graph.nodes().length == 0) return
|
54 |
+
|
55 |
+
lastGraph.graph = graph
|
56 |
+
|
57 |
+
const searchEngine = new MiniSearch({
|
58 |
+
idField: 'id',
|
59 |
+
fields: ['label'],
|
60 |
+
searchOptions: {
|
61 |
+
prefix: true,
|
62 |
+
fuzzy: 0.2,
|
63 |
+
boost: {
|
64 |
+
label: 2
|
65 |
+
}
|
66 |
+
}
|
67 |
+
})
|
68 |
+
|
69 |
+
// Add documents
|
70 |
+
const documents = graph.nodes().map((id: string) => ({
|
71 |
+
id: id,
|
72 |
+
label: graph.getNodeAttribute(id, 'label')
|
73 |
+
}))
|
74 |
+
searchEngine.addAll(documents)
|
75 |
+
|
76 |
+
lastGraph.searchEngine = searchEngine
|
77 |
+
return searchEngine
|
78 |
+
}, [graph])
|
79 |
|
80 |
/**
|
81 |
* Loading the options while the user is typing.
|
|
|
83 |
const loadOptions = useCallback(
|
84 |
async (query?: string): Promise<OptionItem[]> => {
|
85 |
if (onFocus) onFocus(null)
|
86 |
+
if (!query || !search) return []
|
87 |
+
const result: OptionItem[] = search.search(query).map((result) => ({
|
88 |
+
id: result.id,
|
89 |
+
type: 'nodes'
|
90 |
+
}))
|
91 |
|
92 |
// prettier-ignore
|
93 |
return result.length <= searchResultLimit
|
|
|
101 |
}
|
102 |
]
|
103 |
},
|
104 |
+
[search, onFocus]
|
105 |
)
|
106 |
|
107 |
return (
|
108 |
+
<AsyncSearch
|
109 |
+
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100"
|
110 |
fetcher={loadOptions}
|
111 |
renderOption={OptionComponent}
|
112 |
getOptionValue={(item) => item.id}
|
113 |
value={value && value.type !== 'message' ? value.id : null}
|
114 |
onChange={(id) => {
|
115 |
+
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
|
116 |
}}
|
117 |
onFocus={(id) => {
|
118 |
+
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
|
119 |
}}
|
120 |
label={'item'}
|
121 |
+
placeholder="Search nodes..."
|
|
|
122 |
/>
|
123 |
)
|
124 |
}
|
|
|
126 |
/**
|
127 |
* Component that display the search.
|
128 |
*/
|
129 |
+
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
|
130 |
+
return <GraphSearchInput {...props} />
|
131 |
+
}
|
|
|
|
|
|
|
|
|
|
|
132 |
|
133 |
export default GraphSearch
|
lightrag/api/graph_viewer_webui/src/components/{BackendMessageAlert.tsx → MessageAlert.tsx}
RENAMED
@@ -1,8 +1,11 @@
|
|
1 |
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
|
|
2 |
import { useBackendState } from '@/stores/state'
|
|
|
|
|
3 |
import { AlertCircle } from 'lucide-react'
|
4 |
|
5 |
-
const
|
6 |
const health = useBackendState.use.health()
|
7 |
const message = useBackendState.use.message()
|
8 |
const messageTitle = useBackendState.use.messageTitle()
|
@@ -10,13 +13,26 @@ const BackendMessageAlert = () => {
|
|
10 |
return (
|
11 |
<Alert
|
12 |
variant={health ? 'default' : 'destructive'}
|
13 |
-
className="absolute top-1/2 left-1/2 w-auto -translate-x-1/2 -translate-y-1/2 transform"
|
14 |
>
|
15 |
{!health && <AlertCircle className="h-4 w-4" />}
|
16 |
<AlertTitle>{messageTitle}</AlertTitle>
|
|
|
17 |
<AlertDescription>{message}</AlertDescription>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
</Alert>
|
19 |
)
|
20 |
}
|
21 |
|
22 |
-
export default
|
|
|
1 |
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
2 |
+
import Button from '@/components/ui/Button'
|
3 |
import { useBackendState } from '@/stores/state'
|
4 |
+
import { controlButtonVariant } from '@/lib/constants'
|
5 |
+
|
6 |
import { AlertCircle } from 'lucide-react'
|
7 |
|
8 |
+
const MessageAlert = () => {
|
9 |
const health = useBackendState.use.health()
|
10 |
const message = useBackendState.use.message()
|
11 |
const messageTitle = useBackendState.use.messageTitle()
|
|
|
13 |
return (
|
14 |
<Alert
|
15 |
variant={health ? 'default' : 'destructive'}
|
16 |
+
className="bg-background/90 absolute top-1/2 left-1/2 w-auto -translate-x-1/2 -translate-y-1/2 transform backdrop-blur-lg"
|
17 |
>
|
18 |
{!health && <AlertCircle className="h-4 w-4" />}
|
19 |
<AlertTitle>{messageTitle}</AlertTitle>
|
20 |
+
|
21 |
<AlertDescription>{message}</AlertDescription>
|
22 |
+
<div className="h-2" />
|
23 |
+
<div className="flex">
|
24 |
+
<div className="flex-auto" />
|
25 |
+
<Button
|
26 |
+
size="sm"
|
27 |
+
variant={controlButtonVariant}
|
28 |
+
className="text-primary max-h-8 border !p-2 text-xs"
|
29 |
+
onClick={() => useBackendState.getState().clear()}
|
30 |
+
>
|
31 |
+
Continue
|
32 |
+
</Button>
|
33 |
+
</div>
|
34 |
</Alert>
|
35 |
)
|
36 |
}
|
37 |
|
38 |
+
export default MessageAlert
|
lightrag/api/graph_viewer_webui/src/components/ui/AsyncSearch.tsx
ADDED
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useCallback } from 'react'
|
2 |
+
import { Loader2 } from 'lucide-react'
|
3 |
+
import { useDebounce } from '@/hooks/useDebounce'
|
4 |
+
|
5 |
+
import { cn } from '@/lib/utils'
|
6 |
+
import {
|
7 |
+
Command,
|
8 |
+
CommandEmpty,
|
9 |
+
CommandGroup,
|
10 |
+
CommandInput,
|
11 |
+
CommandItem,
|
12 |
+
CommandList
|
13 |
+
} from '@/components/ui/Command'
|
14 |
+
|
15 |
+
export interface Option {
|
16 |
+
value: string
|
17 |
+
label: string
|
18 |
+
disabled?: boolean
|
19 |
+
description?: string
|
20 |
+
icon?: React.ReactNode
|
21 |
+
}
|
22 |
+
|
23 |
+
export interface AsyncSearchProps<T> {
|
24 |
+
/** Async function to fetch options */
|
25 |
+
fetcher: (query?: string) => Promise<T[]>
|
26 |
+
/** Preload all data ahead of time */
|
27 |
+
preload?: boolean
|
28 |
+
/** Function to filter options */
|
29 |
+
filterFn?: (option: T, query: string) => boolean
|
30 |
+
/** Function to render each option */
|
31 |
+
renderOption: (option: T) => React.ReactNode
|
32 |
+
/** Function to get the value from an option */
|
33 |
+
getOptionValue: (option: T) => string
|
34 |
+
/** Custom not found message */
|
35 |
+
notFound?: React.ReactNode
|
36 |
+
/** Custom loading skeleton */
|
37 |
+
loadingSkeleton?: React.ReactNode
|
38 |
+
/** Currently selected value */
|
39 |
+
value: string | null
|
40 |
+
/** Callback when selection changes */
|
41 |
+
onChange: (value: string) => void
|
42 |
+
/** Callback when focus changes */
|
43 |
+
onFocus: (value: string) => void
|
44 |
+
/** Label for the select field */
|
45 |
+
label: string
|
46 |
+
/** Placeholder text when no selection */
|
47 |
+
placeholder?: string
|
48 |
+
/** Disable the entire select */
|
49 |
+
disabled?: boolean
|
50 |
+
/** Custom width for the popover */
|
51 |
+
width?: string | number
|
52 |
+
/** Custom class names */
|
53 |
+
className?: string
|
54 |
+
/** Custom trigger button class names */
|
55 |
+
triggerClassName?: string
|
56 |
+
/** Custom no results message */
|
57 |
+
noResultsMessage?: string
|
58 |
+
/** Allow clearing the selection */
|
59 |
+
clearable?: boolean
|
60 |
+
}
|
61 |
+
|
62 |
+
export function AsyncSearch<T>({
|
63 |
+
fetcher,
|
64 |
+
preload,
|
65 |
+
filterFn,
|
66 |
+
renderOption,
|
67 |
+
getOptionValue,
|
68 |
+
notFound,
|
69 |
+
loadingSkeleton,
|
70 |
+
label,
|
71 |
+
placeholder = 'Select...',
|
72 |
+
value,
|
73 |
+
onChange,
|
74 |
+
onFocus,
|
75 |
+
disabled = false,
|
76 |
+
className,
|
77 |
+
noResultsMessage
|
78 |
+
}: AsyncSearchProps<T>) {
|
79 |
+
const [mounted, setMounted] = useState(false)
|
80 |
+
const [open, setOpen] = useState(false)
|
81 |
+
const [options, setOptions] = useState<T[]>([])
|
82 |
+
const [loading, setLoading] = useState(false)
|
83 |
+
const [error, setError] = useState<string | null>(null)
|
84 |
+
const [selectedValue, setSelectedValue] = useState(value)
|
85 |
+
const [focusedValue, setFocusedValue] = useState<string | null>(null)
|
86 |
+
const [searchTerm, setSearchTerm] = useState('')
|
87 |
+
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
88 |
+
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
89 |
+
|
90 |
+
useEffect(() => {
|
91 |
+
setMounted(true)
|
92 |
+
setSelectedValue(value)
|
93 |
+
}, [value])
|
94 |
+
|
95 |
+
// Effect for initial fetch
|
96 |
+
useEffect(() => {
|
97 |
+
const initializeOptions = async () => {
|
98 |
+
try {
|
99 |
+
setLoading(true)
|
100 |
+
setError(null)
|
101 |
+
// If we have a value, use it for the initial search
|
102 |
+
const data = value !== null ? await fetcher(value) : []
|
103 |
+
setOriginalOptions(data)
|
104 |
+
setOptions(data)
|
105 |
+
} catch (err) {
|
106 |
+
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
107 |
+
} finally {
|
108 |
+
setLoading(false)
|
109 |
+
}
|
110 |
+
}
|
111 |
+
|
112 |
+
if (!mounted) {
|
113 |
+
initializeOptions()
|
114 |
+
}
|
115 |
+
}, [mounted, fetcher, value])
|
116 |
+
|
117 |
+
useEffect(() => {
|
118 |
+
const fetchOptions = async () => {
|
119 |
+
try {
|
120 |
+
setLoading(true)
|
121 |
+
setError(null)
|
122 |
+
const data = await fetcher(debouncedSearchTerm)
|
123 |
+
setOriginalOptions(data)
|
124 |
+
setOptions(data)
|
125 |
+
} catch (err) {
|
126 |
+
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
127 |
+
} finally {
|
128 |
+
setLoading(false)
|
129 |
+
}
|
130 |
+
}
|
131 |
+
|
132 |
+
if (!mounted) {
|
133 |
+
fetchOptions()
|
134 |
+
} else if (!preload) {
|
135 |
+
fetchOptions()
|
136 |
+
} else if (preload) {
|
137 |
+
if (debouncedSearchTerm) {
|
138 |
+
setOptions(
|
139 |
+
originalOptions.filter((option) =>
|
140 |
+
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
141 |
+
)
|
142 |
+
)
|
143 |
+
} else {
|
144 |
+
setOptions(originalOptions)
|
145 |
+
}
|
146 |
+
}
|
147 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
148 |
+
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
|
149 |
+
|
150 |
+
const handleSelect = useCallback(
|
151 |
+
(currentValue: string) => {
|
152 |
+
if (currentValue !== selectedValue) {
|
153 |
+
setSelectedValue(currentValue)
|
154 |
+
onChange(currentValue)
|
155 |
+
}
|
156 |
+
setOpen(false)
|
157 |
+
},
|
158 |
+
[selectedValue, setSelectedValue, setOpen, onChange]
|
159 |
+
)
|
160 |
+
|
161 |
+
const handleFocus = useCallback(
|
162 |
+
(currentValue: string) => {
|
163 |
+
if (currentValue !== focusedValue) {
|
164 |
+
setFocusedValue(currentValue)
|
165 |
+
onFocus(currentValue)
|
166 |
+
}
|
167 |
+
},
|
168 |
+
[focusedValue, setFocusedValue, onFocus]
|
169 |
+
)
|
170 |
+
|
171 |
+
return (
|
172 |
+
<div
|
173 |
+
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
174 |
+
onFocus={() => {
|
175 |
+
setOpen(true)
|
176 |
+
}}
|
177 |
+
onBlur={() => setOpen(false)}
|
178 |
+
>
|
179 |
+
<Command shouldFilter={false} className="bg-transparent">
|
180 |
+
<div>
|
181 |
+
<CommandInput
|
182 |
+
placeholder={placeholder}
|
183 |
+
value={searchTerm}
|
184 |
+
className="max-h-8"
|
185 |
+
onValueChange={(value) => {
|
186 |
+
setSearchTerm(value)
|
187 |
+
if (value && !open) setOpen(true)
|
188 |
+
}}
|
189 |
+
/>
|
190 |
+
{loading && options.length > 0 && (
|
191 |
+
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
192 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
193 |
+
</div>
|
194 |
+
)}
|
195 |
+
</div>
|
196 |
+
<CommandList className="max-h-auto" hidden={!open || debouncedSearchTerm.length === 0}>
|
197 |
+
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
198 |
+
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
199 |
+
{!loading &&
|
200 |
+
!error &&
|
201 |
+
options.length === 0 &&
|
202 |
+
(notFound || (
|
203 |
+
<CommandEmpty>{noResultsMessage ?? `No ${label.toLowerCase()} found.`}</CommandEmpty>
|
204 |
+
))}
|
205 |
+
<CommandGroup>
|
206 |
+
{options.map((option, idx) => (
|
207 |
+
<>
|
208 |
+
<CommandItem
|
209 |
+
key={getOptionValue(option) + `${idx}`}
|
210 |
+
value={getOptionValue(option)}
|
211 |
+
onSelect={handleSelect}
|
212 |
+
onMouseEnter={() => handleFocus(getOptionValue(option))}
|
213 |
+
className="truncate"
|
214 |
+
>
|
215 |
+
{renderOption(option)}
|
216 |
+
</CommandItem>
|
217 |
+
{idx !== options.length - 1 && (
|
218 |
+
<div key={idx} className="bg-foreground/10 h-[1px]" />
|
219 |
+
)}
|
220 |
+
</>
|
221 |
+
))}
|
222 |
+
</CommandGroup>
|
223 |
+
</CommandList>
|
224 |
+
</Command>
|
225 |
+
</div>
|
226 |
+
)
|
227 |
+
}
|
228 |
+
|
229 |
+
function DefaultLoadingSkeleton() {
|
230 |
+
return (
|
231 |
+
<CommandGroup>
|
232 |
+
<CommandItem disabled>
|
233 |
+
<div className="flex w-full items-center gap-2">
|
234 |
+
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
235 |
+
<div className="flex flex-1 flex-col gap-1">
|
236 |
+
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
237 |
+
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
238 |
+
</div>
|
239 |
+
</div>
|
240 |
+
</CommandItem>
|
241 |
+
</CommandGroup>
|
242 |
+
)
|
243 |
+
}
|
lightrag/api/graph_viewer_webui/src/components/ui/AsyncSelect.tsx
CHANGED
@@ -1,8 +1,9 @@
|
|
1 |
import { useState, useEffect, useCallback } from 'react'
|
2 |
-
import { Loader2 } from 'lucide-react'
|
3 |
import { useDebounce } from '@/hooks/useDebounce'
|
4 |
|
5 |
import { cn } from '@/lib/utils'
|
|
|
6 |
import {
|
7 |
Command,
|
8 |
CommandEmpty,
|
@@ -11,6 +12,7 @@ import {
|
|
11 |
CommandItem,
|
12 |
CommandList
|
13 |
} from '@/components/ui/Command'
|
|
|
14 |
|
15 |
export interface Option {
|
16 |
value: string
|
@@ -31,30 +33,34 @@ export interface AsyncSelectProps<T> {
|
|
31 |
renderOption: (option: T) => React.ReactNode
|
32 |
/** Function to get the value from an option */
|
33 |
getOptionValue: (option: T) => string
|
|
|
|
|
34 |
/** Custom not found message */
|
35 |
notFound?: React.ReactNode
|
36 |
/** Custom loading skeleton */
|
37 |
loadingSkeleton?: React.ReactNode
|
38 |
/** Currently selected value */
|
39 |
-
value: string
|
40 |
/** Callback when selection changes */
|
41 |
onChange: (value: string) => void
|
42 |
-
/** Callback when focus changes */
|
43 |
-
onFocus: (value: string) => void
|
44 |
/** Label for the select field */
|
45 |
label: string
|
46 |
/** Placeholder text when no selection */
|
47 |
placeholder?: string
|
48 |
/** Disable the entire select */
|
49 |
disabled?: boolean
|
50 |
-
/** Custom width for the popover
|
51 |
width?: string | number
|
52 |
/** Custom class names */
|
53 |
className?: string
|
54 |
/** Custom trigger button class names */
|
55 |
triggerClassName?: string
|
|
|
|
|
56 |
/** Custom no results message */
|
57 |
noResultsMessage?: string
|
|
|
|
|
58 |
/** Allow clearing the selection */
|
59 |
clearable?: boolean
|
60 |
}
|
@@ -65,16 +71,20 @@ export function AsyncSelect<T>({
|
|
65 |
filterFn,
|
66 |
renderOption,
|
67 |
getOptionValue,
|
|
|
68 |
notFound,
|
69 |
loadingSkeleton,
|
70 |
label,
|
71 |
placeholder = 'Select...',
|
72 |
value,
|
73 |
onChange,
|
74 |
-
onFocus,
|
75 |
disabled = false,
|
76 |
className,
|
77 |
-
|
|
|
|
|
|
|
|
|
78 |
}: AsyncSelectProps<T>) {
|
79 |
const [mounted, setMounted] = useState(false)
|
80 |
const [open, setOpen] = useState(false)
|
@@ -82,7 +92,7 @@ export function AsyncSelect<T>({
|
|
82 |
const [loading, setLoading] = useState(false)
|
83 |
const [error, setError] = useState<string | null>(null)
|
84 |
const [selectedValue, setSelectedValue] = useState(value)
|
85 |
-
const [
|
86 |
const [searchTerm, setSearchTerm] = useState('')
|
87 |
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
88 |
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
@@ -92,6 +102,16 @@ export function AsyncSelect<T>({
|
|
92 |
setSelectedValue(value)
|
93 |
}, [value])
|
94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
// Effect for initial fetch
|
96 |
useEffect(() => {
|
97 |
const initializeOptions = async () => {
|
@@ -99,7 +119,7 @@ export function AsyncSelect<T>({
|
|
99 |
setLoading(true)
|
100 |
setError(null)
|
101 |
// If we have a value, use it for the initial search
|
102 |
-
const data =
|
103 |
setOriginalOptions(data)
|
104 |
setOptions(data)
|
105 |
} catch (err) {
|
@@ -149,75 +169,85 @@ export function AsyncSelect<T>({
|
|
149 |
|
150 |
const handleSelect = useCallback(
|
151 |
(currentValue: string) => {
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
}
|
157 |
setOpen(false)
|
158 |
},
|
159 |
-
[selectedValue, onChange]
|
160 |
-
)
|
161 |
-
|
162 |
-
const handleFocus = useCallback(
|
163 |
-
(currentValue: string) => {
|
164 |
-
if (currentValue !== focusedValue) {
|
165 |
-
setFocusedValue(currentValue)
|
166 |
-
onFocus(currentValue)
|
167 |
-
}
|
168 |
-
},
|
169 |
-
[focusedValue, onFocus]
|
170 |
)
|
171 |
|
172 |
return (
|
173 |
-
<
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
onValueChange={(value) => {
|
184 |
-
setSearchTerm(value)
|
185 |
-
if (value && !open) setOpen(true)
|
186 |
-
}}
|
187 |
-
/>
|
188 |
-
{loading && options.length > 0 && (
|
189 |
-
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
190 |
-
<Loader2 className="h-4 w-4 animate-spin" />
|
191 |
-
</div>
|
192 |
)}
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
{
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
<
|
204 |
-
|
205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
206 |
<CommandItem
|
207 |
key={getOptionValue(option)}
|
208 |
value={getOptionValue(option)}
|
209 |
onSelect={handleSelect}
|
210 |
-
|
211 |
>
|
212 |
{renderOption(option)}
|
|
|
|
|
|
|
|
|
|
|
|
|
213 |
</CommandItem>
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
</div>
|
221 |
)
|
222 |
}
|
223 |
|
|
|
1 |
import { useState, useEffect, useCallback } from 'react'
|
2 |
+
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
3 |
import { useDebounce } from '@/hooks/useDebounce'
|
4 |
|
5 |
import { cn } from '@/lib/utils'
|
6 |
+
import Button from '@/components/ui/Button'
|
7 |
import {
|
8 |
Command,
|
9 |
CommandEmpty,
|
|
|
12 |
CommandItem,
|
13 |
CommandList
|
14 |
} from '@/components/ui/Command'
|
15 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
16 |
|
17 |
export interface Option {
|
18 |
value: string
|
|
|
33 |
renderOption: (option: T) => React.ReactNode
|
34 |
/** Function to get the value from an option */
|
35 |
getOptionValue: (option: T) => string
|
36 |
+
/** Function to get the display value for the selected option */
|
37 |
+
getDisplayValue: (option: T) => React.ReactNode
|
38 |
/** Custom not found message */
|
39 |
notFound?: React.ReactNode
|
40 |
/** Custom loading skeleton */
|
41 |
loadingSkeleton?: React.ReactNode
|
42 |
/** Currently selected value */
|
43 |
+
value: string
|
44 |
/** Callback when selection changes */
|
45 |
onChange: (value: string) => void
|
|
|
|
|
46 |
/** Label for the select field */
|
47 |
label: string
|
48 |
/** Placeholder text when no selection */
|
49 |
placeholder?: string
|
50 |
/** Disable the entire select */
|
51 |
disabled?: boolean
|
52 |
+
/** Custom width for the popover *
|
53 |
width?: string | number
|
54 |
/** Custom class names */
|
55 |
className?: string
|
56 |
/** Custom trigger button class names */
|
57 |
triggerClassName?: string
|
58 |
+
/** Custom search input class names */
|
59 |
+
searchInputClassName?: string
|
60 |
/** Custom no results message */
|
61 |
noResultsMessage?: string
|
62 |
+
/** Custom trigger tooltip */
|
63 |
+
triggerTooltip?: string
|
64 |
/** Allow clearing the selection */
|
65 |
clearable?: boolean
|
66 |
}
|
|
|
71 |
filterFn,
|
72 |
renderOption,
|
73 |
getOptionValue,
|
74 |
+
getDisplayValue,
|
75 |
notFound,
|
76 |
loadingSkeleton,
|
77 |
label,
|
78 |
placeholder = 'Select...',
|
79 |
value,
|
80 |
onChange,
|
|
|
81 |
disabled = false,
|
82 |
className,
|
83 |
+
triggerClassName,
|
84 |
+
searchInputClassName,
|
85 |
+
noResultsMessage,
|
86 |
+
triggerTooltip,
|
87 |
+
clearable = true
|
88 |
}: AsyncSelectProps<T>) {
|
89 |
const [mounted, setMounted] = useState(false)
|
90 |
const [open, setOpen] = useState(false)
|
|
|
92 |
const [loading, setLoading] = useState(false)
|
93 |
const [error, setError] = useState<string | null>(null)
|
94 |
const [selectedValue, setSelectedValue] = useState(value)
|
95 |
+
const [selectedOption, setSelectedOption] = useState<T | null>(null)
|
96 |
const [searchTerm, setSearchTerm] = useState('')
|
97 |
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
98 |
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
|
|
102 |
setSelectedValue(value)
|
103 |
}, [value])
|
104 |
|
105 |
+
// Initialize selectedOption when options are loaded and value exists
|
106 |
+
useEffect(() => {
|
107 |
+
if (value && options.length > 0) {
|
108 |
+
const option = options.find((opt) => getOptionValue(opt) === value)
|
109 |
+
if (option) {
|
110 |
+
setSelectedOption(option)
|
111 |
+
}
|
112 |
+
}
|
113 |
+
}, [value, options, getOptionValue])
|
114 |
+
|
115 |
// Effect for initial fetch
|
116 |
useEffect(() => {
|
117 |
const initializeOptions = async () => {
|
|
|
119 |
setLoading(true)
|
120 |
setError(null)
|
121 |
// If we have a value, use it for the initial search
|
122 |
+
const data = await fetcher(value)
|
123 |
setOriginalOptions(data)
|
124 |
setOptions(data)
|
125 |
} catch (err) {
|
|
|
169 |
|
170 |
const handleSelect = useCallback(
|
171 |
(currentValue: string) => {
|
172 |
+
const newValue = clearable && currentValue === selectedValue ? '' : currentValue
|
173 |
+
setSelectedValue(newValue)
|
174 |
+
setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null)
|
175 |
+
onChange(newValue)
|
|
|
176 |
setOpen(false)
|
177 |
},
|
178 |
+
[selectedValue, onChange, clearable, options, getOptionValue]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
)
|
180 |
|
181 |
return (
|
182 |
+
<Popover open={open} onOpenChange={setOpen}>
|
183 |
+
<PopoverTrigger asChild>
|
184 |
+
<Button
|
185 |
+
variant="outline"
|
186 |
+
role="combobox"
|
187 |
+
aria-expanded={open}
|
188 |
+
className={cn(
|
189 |
+
'justify-between',
|
190 |
+
disabled && 'cursor-not-allowed opacity-50',
|
191 |
+
triggerClassName
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
)}
|
193 |
+
disabled={disabled}
|
194 |
+
tooltip={triggerTooltip}
|
195 |
+
side="bottom"
|
196 |
+
>
|
197 |
+
{selectedOption ? getDisplayValue(selectedOption) : placeholder}
|
198 |
+
<ChevronsUpDown className="opacity-50" size={10} />
|
199 |
+
</Button>
|
200 |
+
</PopoverTrigger>
|
201 |
+
<PopoverContent className={cn('p-0', className)} onCloseAutoFocus={(e) => e.preventDefault()}>
|
202 |
+
<Command shouldFilter={false}>
|
203 |
+
<div className="relative w-full border-b">
|
204 |
+
<CommandInput
|
205 |
+
placeholder={`Search ${label.toLowerCase()}...`}
|
206 |
+
value={searchTerm}
|
207 |
+
onValueChange={(value) => {
|
208 |
+
setSearchTerm(value)
|
209 |
+
}}
|
210 |
+
className={searchInputClassName}
|
211 |
+
/>
|
212 |
+
{loading && options.length > 0 && (
|
213 |
+
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
214 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
215 |
+
</div>
|
216 |
+
)}
|
217 |
+
</div>
|
218 |
+
<CommandList>
|
219 |
+
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
220 |
+
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
221 |
+
{!loading &&
|
222 |
+
!error &&
|
223 |
+
options.length === 0 &&
|
224 |
+
(notFound || (
|
225 |
+
<CommandEmpty>
|
226 |
+
{noResultsMessage ?? `No ${label.toLowerCase()} found.`}
|
227 |
+
</CommandEmpty>
|
228 |
+
))}
|
229 |
+
<CommandGroup>
|
230 |
+
{options.map((option) => (
|
231 |
<CommandItem
|
232 |
key={getOptionValue(option)}
|
233 |
value={getOptionValue(option)}
|
234 |
onSelect={handleSelect}
|
235 |
+
className="truncate"
|
236 |
>
|
237 |
{renderOption(option)}
|
238 |
+
<Check
|
239 |
+
className={cn(
|
240 |
+
'ml-auto h-3 w-3',
|
241 |
+
selectedValue === getOptionValue(option) ? 'opacity-100' : 'opacity-0'
|
242 |
+
)}
|
243 |
+
/>
|
244 |
</CommandItem>
|
245 |
+
))}
|
246 |
+
</CommandGroup>
|
247 |
+
</CommandList>
|
248 |
+
</Command>
|
249 |
+
</PopoverContent>
|
250 |
+
</Popover>
|
|
|
251 |
)
|
252 |
}
|
253 |
|
lightrag/api/graph_viewer_webui/src/hooks/useLightragGraph.tsx
CHANGED
@@ -1,10 +1,13 @@
|
|
1 |
import Graph, { DirectedGraph } from 'graphology'
|
2 |
-
import { useCallback, useEffect
|
3 |
-
import { randomColor } 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 |
|
9 |
const validateGraph = (graph: RawGraph) => {
|
10 |
if (!graph) {
|
@@ -53,9 +56,7 @@ const fetchGraph = async (label: string) => {
|
|
53 |
try {
|
54 |
rawData = await queryGraphs(label)
|
55 |
} catch (e) {
|
56 |
-
useBackendState
|
57 |
-
.getState()
|
58 |
-
.setErrorMessage(e instanceof Error ? e.message : `${e}`, 'Query Graphs Error!')
|
59 |
return null
|
60 |
}
|
61 |
|
@@ -69,9 +70,11 @@ const fetchGraph = async (label: string) => {
|
|
69 |
const node = rawData.nodes[i]
|
70 |
nodeIdMap[node.id] = i
|
71 |
|
|
|
|
|
|
|
72 |
node.x = Math.random()
|
73 |
node.y = Math.random()
|
74 |
-
node.color = randomColor()
|
75 |
node.degree = 0
|
76 |
node.size = 10
|
77 |
}
|
@@ -150,8 +153,10 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
|
150 |
return graph
|
151 |
}
|
152 |
|
|
|
|
|
153 |
const useLightrangeGraph = () => {
|
154 |
-
const
|
155 |
const rawGraph = useGraphStore.use.rawGraph()
|
156 |
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
157 |
|
@@ -170,12 +175,13 @@ const useLightrangeGraph = () => {
|
|
170 |
)
|
171 |
|
172 |
useEffect(() => {
|
173 |
-
if (
|
174 |
-
|
175 |
-
|
|
|
176 |
state.reset()
|
177 |
-
fetchGraph(
|
178 |
-
|
179 |
state.setSigmaGraph(createSigmaGraph(data))
|
180 |
data?.buildDynamicMap()
|
181 |
state.setRawGraph(data)
|
@@ -186,7 +192,7 @@ const useLightrangeGraph = () => {
|
|
186 |
state.reset()
|
187 |
state.setSigmaGraph(new DirectedGraph())
|
188 |
}
|
189 |
-
}, [
|
190 |
|
191 |
const lightrageGraph = useCallback(() => {
|
192 |
if (sigmaGraph) {
|
@@ -197,7 +203,7 @@ const useLightrangeGraph = () => {
|
|
197 |
return graph as Graph<NodeType, EdgeType>
|
198 |
}, [sigmaGraph])
|
199 |
|
200 |
-
return { lightrageGraph,
|
201 |
}
|
202 |
|
203 |
export default useLightrangeGraph
|
|
|
1 |
import Graph, { DirectedGraph } from 'graphology'
|
2 |
+
import { useCallback, useEffect } 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'
|
9 |
+
|
10 |
+
import seedrandom from 'seedrandom'
|
11 |
|
12 |
const validateGraph = (graph: RawGraph) => {
|
13 |
if (!graph) {
|
|
|
56 |
try {
|
57 |
rawData = await queryGraphs(label)
|
58 |
} catch (e) {
|
59 |
+
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!')
|
|
|
|
|
60 |
return null
|
61 |
}
|
62 |
|
|
|
70 |
const node = rawData.nodes[i]
|
71 |
nodeIdMap[node.id] = i
|
72 |
|
73 |
+
// const seed = node.labels.length > 0 ? node.labels[0] : node.id
|
74 |
+
seedrandom(node.id, { global: true })
|
75 |
+
node.color = randomColor()
|
76 |
node.x = Math.random()
|
77 |
node.y = Math.random()
|
|
|
78 |
node.degree = 0
|
79 |
node.size = 10
|
80 |
}
|
|
|
153 |
return graph
|
154 |
}
|
155 |
|
156 |
+
const lastQueryLabel = { label: '' }
|
157 |
+
|
158 |
const useLightrangeGraph = () => {
|
159 |
+
const queryLabel = useSettingsStore.use.queryLabel()
|
160 |
const rawGraph = useGraphStore.use.rawGraph()
|
161 |
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
162 |
|
|
|
175 |
)
|
176 |
|
177 |
useEffect(() => {
|
178 |
+
if (queryLabel) {
|
179 |
+
if (lastQueryLabel.label !== queryLabel) {
|
180 |
+
lastQueryLabel.label = queryLabel
|
181 |
+
const state = useGraphStore.getState()
|
182 |
state.reset()
|
183 |
+
fetchGraph(queryLabel).then((data) => {
|
184 |
+
// console.debug('Query label: ' + queryLabel)
|
185 |
state.setSigmaGraph(createSigmaGraph(data))
|
186 |
data?.buildDynamicMap()
|
187 |
state.setRawGraph(data)
|
|
|
192 |
state.reset()
|
193 |
state.setSigmaGraph(new DirectedGraph())
|
194 |
}
|
195 |
+
}, [queryLabel])
|
196 |
|
197 |
const lightrageGraph = useCallback(() => {
|
198 |
if (sigmaGraph) {
|
|
|
203 |
return graph as Graph<NodeType, EdgeType>
|
204 |
}, [sigmaGraph])
|
205 |
|
206 |
+
return { lightrageGraph, getNode, getEdge }
|
207 |
}
|
208 |
|
209 |
export default useLightrangeGraph
|
lightrag/api/graph_viewer_webui/src/lib/constants.ts
CHANGED
@@ -19,3 +19,7 @@ export const searchResultLimit = 20
|
|
19 |
|
20 |
export const minNodeSize = 4
|
21 |
export const maxNodeSize = 20
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
export const minNodeSize = 4
|
21 |
export const maxNodeSize = 20
|
22 |
+
|
23 |
+
export const healthCheckInterval = 15 // seconds
|
24 |
+
|
25 |
+
export const defaultQueryLabel = '*'
|
lightrag/api/graph_viewer_webui/src/lib/utils.ts
CHANGED
@@ -15,6 +15,10 @@ export function randomColor() {
|
|
15 |
return code
|
16 |
}
|
17 |
|
|
|
|
|
|
|
|
|
18 |
type WithSelectors<S> = S extends { getState: () => infer T }
|
19 |
? S & { use: { [K in keyof T]: () => T[K] } }
|
20 |
: never
|
|
|
15 |
return code
|
16 |
}
|
17 |
|
18 |
+
export function errorMessage(error: any) {
|
19 |
+
return error instanceof Error ? error.message : `${error}`
|
20 |
+
}
|
21 |
+
|
22 |
type WithSelectors<S> = S extends { getState: () => infer T }
|
23 |
? S & { use: { [K in keyof T]: () => T[K] } }
|
24 |
: never
|
lightrag/api/graph_viewer_webui/src/stores/graph.ts
CHANGED
@@ -63,7 +63,6 @@ interface GraphState {
|
|
63 |
selectedEdge: string | null
|
64 |
focusedEdge: string | null
|
65 |
|
66 |
-
queryLabel: string | null
|
67 |
rawGraph: RawGraph | null
|
68 |
sigmaGraph: DirectedGraph | null
|
69 |
|
@@ -78,7 +77,6 @@ interface GraphState {
|
|
78 |
|
79 |
setMoveToSelectedNode: (moveToSelectedNode: boolean) => void
|
80 |
|
81 |
-
setQueryLabel: (queryLabel: string | null) => void
|
82 |
setRawGraph: (rawGraph: RawGraph | null) => void
|
83 |
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
84 |
}
|
@@ -91,7 +89,6 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
|
|
91 |
|
92 |
moveToSelectedNode: false,
|
93 |
|
94 |
-
queryLabel: null,
|
95 |
rawGraph: null,
|
96 |
sigmaGraph: null,
|
97 |
|
@@ -113,17 +110,11 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
|
|
113 |
focusedNode: null,
|
114 |
selectedEdge: null,
|
115 |
focusedEdge: null,
|
116 |
-
queryLabel: null,
|
117 |
rawGraph: null,
|
118 |
sigmaGraph: null,
|
119 |
moveToSelectedNode: false
|
120 |
}),
|
121 |
|
122 |
-
setQueryLabel: (queryLabel: string | null) =>
|
123 |
-
set({
|
124 |
-
queryLabel
|
125 |
-
}),
|
126 |
-
|
127 |
setRawGraph: (rawGraph: RawGraph | null) =>
|
128 |
set({
|
129 |
rawGraph
|
|
|
63 |
selectedEdge: string | null
|
64 |
focusedEdge: string | null
|
65 |
|
|
|
66 |
rawGraph: RawGraph | null
|
67 |
sigmaGraph: DirectedGraph | null
|
68 |
|
|
|
77 |
|
78 |
setMoveToSelectedNode: (moveToSelectedNode: boolean) => void
|
79 |
|
|
|
80 |
setRawGraph: (rawGraph: RawGraph | null) => void
|
81 |
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
82 |
}
|
|
|
89 |
|
90 |
moveToSelectedNode: false,
|
91 |
|
|
|
92 |
rawGraph: null,
|
93 |
sigmaGraph: null,
|
94 |
|
|
|
110 |
focusedNode: null,
|
111 |
selectedEdge: null,
|
112 |
focusedEdge: null,
|
|
|
113 |
rawGraph: null,
|
114 |
sigmaGraph: null,
|
115 |
moveToSelectedNode: false
|
116 |
}),
|
117 |
|
|
|
|
|
|
|
|
|
|
|
118 |
setRawGraph: (rawGraph: RawGraph | null) =>
|
119 |
set({
|
120 |
rawGraph
|
lightrag/api/graph_viewer_webui/src/stores/settings.ts
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
import { create } from 'zustand'
|
2 |
import { persist, createJSONStorage } from 'zustand/middleware'
|
3 |
import { createSelectors } from '@/lib/utils'
|
|
|
4 |
|
5 |
type Theme = 'dark' | 'light' | 'system'
|
6 |
|
@@ -10,7 +11,11 @@ interface SettingsState {
|
|
10 |
enableEdgeEvents: boolean
|
11 |
enableHideUnselectedEdges: boolean
|
12 |
showEdgeLabel: boolean
|
|
|
13 |
setTheme: (theme: Theme) => void
|
|
|
|
|
|
|
14 |
}
|
15 |
|
16 |
const useSettingsStoreBase = create<SettingsState>()(
|
@@ -22,16 +27,26 @@ const useSettingsStoreBase = create<SettingsState>()(
|
|
22 |
enableHideUnselectedEdges: true,
|
23 |
showEdgeLabel: false,
|
24 |
|
25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
}),
|
27 |
{
|
28 |
name: 'settings-storage',
|
29 |
storage: createJSONStorage(() => localStorage),
|
30 |
-
version:
|
31 |
migrate: (state: any, version: number) => {
|
32 |
if (version < 2) {
|
33 |
state.showEdgeLabel = false
|
34 |
}
|
|
|
|
|
|
|
35 |
}
|
36 |
}
|
37 |
)
|
|
|
1 |
import { create } from 'zustand'
|
2 |
import { persist, createJSONStorage } from 'zustand/middleware'
|
3 |
import { createSelectors } from '@/lib/utils'
|
4 |
+
import { defaultQueryLabel } from '@/lib/constants'
|
5 |
|
6 |
type Theme = 'dark' | 'light' | 'system'
|
7 |
|
|
|
11 |
enableEdgeEvents: boolean
|
12 |
enableHideUnselectedEdges: boolean
|
13 |
showEdgeLabel: boolean
|
14 |
+
|
15 |
setTheme: (theme: Theme) => void
|
16 |
+
|
17 |
+
queryLabel: string
|
18 |
+
setQueryLabel: (queryLabel: string) => void
|
19 |
}
|
20 |
|
21 |
const useSettingsStoreBase = create<SettingsState>()(
|
|
|
27 |
enableHideUnselectedEdges: true,
|
28 |
showEdgeLabel: false,
|
29 |
|
30 |
+
queryLabel: defaultQueryLabel,
|
31 |
+
|
32 |
+
setTheme: (theme: Theme) => set({ theme }),
|
33 |
+
|
34 |
+
setQueryLabel: (queryLabel: string) =>
|
35 |
+
set({
|
36 |
+
queryLabel
|
37 |
+
})
|
38 |
}),
|
39 |
{
|
40 |
name: 'settings-storage',
|
41 |
storage: createJSONStorage(() => localStorage),
|
42 |
+
version: 3,
|
43 |
migrate: (state: any, version: number) => {
|
44 |
if (version < 2) {
|
45 |
state.showEdgeLabel = false
|
46 |
}
|
47 |
+
if (version < 3) {
|
48 |
+
state.queryLabel = defaultQueryLabel
|
49 |
+
}
|
50 |
}
|
51 |
}
|
52 |
)
|