Merge pull request #946 from ArnoChenFx/dev-webui

Enhance Graph Visualization with Configurable Depth and Layout Iterations
This commit is contained in:
Yannick Stephan
2025-02-25 19:40:17 +01:00
committed by GitHub
15 changed files with 368 additions and 221 deletions

View File

@@ -20,8 +20,8 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
return await rag.get_graph_labels() return await rag.get_graph_labels()
@router.get("/graphs", dependencies=[Depends(optional_api_key)]) @router.get("/graphs", dependencies=[Depends(optional_api_key)])
async def get_knowledge_graph(label: str): async def get_knowledge_graph(label: str, max_depth: int = 3):
"""Get knowledge graph for a specific label""" """Get knowledge graph for a specific label"""
return await rag.get_knowledge_graph(node_label=label, max_depth=3) return await rag.get_knowledge_graph(node_label=label, max_depth=max_depth)
return router return router

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="./logo.png" /> <link rel="icon" type="image/svg+xml" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
<script type="module" crossorigin src="./assets/index-DJ_PHzHf.js"></script> <script type="module" crossorigin src="./assets/index-DbuMPJAD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DrMerhud.css"> <link rel="stylesheet" crossorigin href="./assets/index-rP-YlyR1.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -161,8 +161,8 @@ axiosInstance.interceptors.response.use(
) )
// API methods // API methods
export const queryGraphs = async (label: string): Promise<LightragGraphType> => { export const queryGraphs = async (label: string, maxDepth: number): Promise<LightragGraphType> => {
const response = await axiosInstance.get(`/graphs?label=${label}`) const response = await axiosInstance.get(`/graphs?label=${label}&max_depth=${maxDepth}`)
return response.data return response.data
} }

View File

@@ -26,8 +26,10 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const registerEvents = useRegisterEvents<NodeType, EdgeType>() const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>() const setSettings = useSetSettings<NodeType, EdgeType>()
const loadGraph = useLoadGraph<NodeType, EdgeType>() const loadGraph = useLoadGraph<NodeType, EdgeType>()
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const { assign: assignLayout } = useLayoutForceAtlas2({ const { assign: assignLayout } = useLayoutForceAtlas2({
iterations: 20 iterations: maxIterations
}) })
const { theme } = useTheme() const { theme } = useTheme()

View File

@@ -1,35 +1,37 @@
import { useCallback, useState } from 'react' import { useCallback } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect' import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { getGraphLabels } from '@/api/lightrag' import { getGraphLabels } from '@/api/lightrag'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
const lastGraph: any = {
graph: null,
searchEngine: null,
labels: []
}
const GraphLabels = () => { const GraphLabels = () => {
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const [labels, setLabels] = useState<{ const graph = useGraphStore.use.sigmaGraph()
labels: string[]
searchEngine: MiniSearch | null
}>({
labels: [],
searchEngine: null
})
const [fetched, setFetched] = useState(false)
const fetchData = useCallback( const getSearchEngine = useCallback(async () => {
async (query?: string): Promise<string[]> => { if (lastGraph.graph == graph) {
let _labels = labels.labels return {
let _searchEngine = labels.searchEngine labels: lastGraph.labels,
searchEngine: lastGraph.searchEngine
if (!fetched || !_searchEngine) { }
_labels = ['*'].concat(await getGraphLabels()) }
const labels = ['*'].concat(await getGraphLabels())
// Ensure query label exists // Ensure query label exists
if (!_labels.includes(useSettingsStore.getState().queryLabel)) { if (!labels.includes(useSettingsStore.getState().queryLabel)) {
useSettingsStore.getState().setQueryLabel(_labels[0]) useSettingsStore.getState().setQueryLabel(labels[0])
} }
// Create search engine // Create search engine
_searchEngine = new MiniSearch({ const searchEngine = new MiniSearch({
idField: 'id', idField: 'id',
fields: ['value'], fields: ['value'],
searchOptions: { searchOptions: {
@@ -42,26 +44,38 @@ const GraphLabels = () => {
}) })
// Add documents // Add documents
const documents = _labels.map((str, index) => ({ id: index, value: str })) const documents = labels.map((str, index) => ({ id: index, value: str }))
_searchEngine.addAll(documents) searchEngine.addAll(documents)
setLabels({ lastGraph.graph = graph
labels: _labels, lastGraph.searchEngine = searchEngine
searchEngine: _searchEngine lastGraph.labels = labels
})
setFetched(true)
}
if (!query) {
return _labels
}
return {
labels,
searchEngine
}
}, [graph])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
const { labels, searchEngine } = await getSearchEngine()
let result: string[] = labels
if (query) {
// Search labels // Search labels
return _searchEngine.search(query).map((result) => _labels[result.id]) result = searchEngine.search(query).map((r) => labels[r.id])
}
return result.length <= labelListLimit
? result
: [...result.slice(0, labelListLimit), `And ${result.length - labelListLimit} others`]
}, },
[labels, fetched, setLabels, setFetched] [getSearchEngine]
) )
const setQueryLabel = useCallback((label: string) => { const setQueryLabel = useCallback((label: string) => {
if (label.startsWith('And ') && label.endsWith(' others')) return
useSettingsStore.getState().setQueryLabel(label) useSettingsStore.getState().setQueryLabel(label)
}, []) }, [])

View File

@@ -46,7 +46,7 @@ export const GraphSearchInput = ({
}) => { }) => {
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
const search = useMemo(() => { const searchEngine = useMemo(() => {
if (lastGraph.graph == graph) { if (lastGraph.graph == graph) {
return lastGraph.searchEngine return lastGraph.searchEngine
} }
@@ -83,9 +83,9 @@ 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 (!query || !search) return [] if (!query || !searchEngine) return []
const result: OptionItem[] = search.search(query).map((result) => ({ const result: OptionItem[] = searchEngine.search(query).map((r) => ({
id: result.id, id: r.id,
type: 'nodes' type: 'nodes'
})) }))
@@ -101,7 +101,7 @@ export const GraphSearchInput = ({
} }
] ]
}, },
[search, onFocus] [searchEngine, onFocus]
) )
return ( return (

View File

@@ -13,6 +13,7 @@ import Button from '@/components/ui/Button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react' import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
@@ -76,12 +77,14 @@ const LayoutsControl = () => {
const [layout, setLayout] = useState<LayoutName>('Circular') const [layout, setLayout] = useState<LayoutName>('Circular')
const [opened, setOpened] = useState<boolean>(false) const [opened, setOpened] = useState<boolean>(false)
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const layoutCircular = useLayoutCircular() const layoutCircular = useLayoutCircular()
const layoutCirclepack = useLayoutCirclepack() const layoutCirclepack = useLayoutCirclepack()
const layoutRandom = useLayoutRandom() const layoutRandom = useLayoutRandom()
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } }) const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
const layoutForce = useLayoutForce({ maxIterations: 20 }) const layoutForce = useLayoutForce({ maxIterations: maxIterations })
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: 20 }) const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
const workerNoverlap = useWorkerLayoutNoverlap() const workerNoverlap = useWorkerLayoutNoverlap()
const workerForce = useWorkerLayoutForce() const workerForce = useWorkerLayoutForce()
const workerForceAtlas2 = useWorkerLayoutForceAtlas2() const workerForceAtlas2 = useWorkerLayoutForceAtlas2()

View File

@@ -1,9 +1,10 @@
import { useState, useCallback, useEffect } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Checkbox from '@/components/ui/Checkbox' import Checkbox from '@/components/ui/Checkbox'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import Separator from '@/components/ui/Separator' import Separator from '@/components/ui/Separator'
import Input from '@/components/ui/Input' import Input from '@/components/ui/Input'
import { useState, useCallback, useEffect } from 'react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
@@ -35,6 +36,74 @@ const LabeledCheckBox = ({
) )
} }
/**
* Component that displays a number input with a label.
*/
const LabeledNumberInput = ({
value,
onEditFinished,
label,
min,
max
}: {
value: number
onEditFinished: (value: number) => void
label: string
min: number
max?: number
}) => {
const [currentValue, setCurrentValue] = useState<number | null>(value)
const onValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value.trim()
if (text.length === 0) {
setCurrentValue(null)
return
}
const newValue = Number.parseInt(text)
if (!isNaN(newValue) && newValue !== currentValue) {
if (min !== undefined && newValue < min) {
return
}
if (max !== undefined && newValue > max) {
return
}
setCurrentValue(newValue)
}
},
[currentValue, min, max]
)
const onBlur = useCallback(() => {
if (currentValue !== null && value !== currentValue) {
onEditFinished(currentValue)
}
}, [value, currentValue, onEditFinished])
return (
<div className="flex flex-col gap-2">
<label
htmlFor="terms"
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
<Input
value={currentValue || ''}
onChange={onValueChange}
className="h-6 w-full min-w-0"
onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onBlur()
}
}}
/>
</div>
)
}
/** /**
* Component that displays a popover with settings options. * Component that displays a popover with settings options.
*/ */
@@ -45,11 +114,12 @@ export default function Settings() {
const showPropertyPanel = useSettingsStore.use.showPropertyPanel() const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar() const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const showNodeLabel = useSettingsStore.use.showNodeLabel() const showNodeLabel = useSettingsStore.use.showNodeLabel()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents() const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag() const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges() const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const showEdgeLabel = useSettingsStore.use.showEdgeLabel() const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck() const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
const apiKey = useSettingsStore.use.apiKey() const apiKey = useSettingsStore.use.apiKey()
@@ -102,6 +172,16 @@ export default function Settings() {
[] []
) )
const setGraphQueryMaxDepth = useCallback((depth: number) => {
if (depth < 1) return
useSettingsStore.setState({ graphQueryMaxDepth: depth })
}, [])
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
if (iterations < 1) return
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
}, [])
const setApiKey = useCallback(async () => { const setApiKey = useCallback(async () => {
useSettingsStore.setState({ apiKey: tempApiKey || null }) useSettingsStore.setState({ apiKey: tempApiKey || null })
await useBackendState.getState().check() await useBackendState.getState().check()
@@ -129,6 +209,14 @@ export default function Settings() {
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<LabeledCheckBox
checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck}
label="Health Check"
/>
<Separator />
<LabeledCheckBox <LabeledCheckBox
checked={showPropertyPanel} checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel} onCheckedChange={setShowPropertyPanel}
@@ -172,11 +260,18 @@ export default function Settings() {
/> />
<Separator /> <Separator />
<LabeledNumberInput
<LabeledCheckBox label="Max Query Depth"
checked={enableHealthCheck} min={1}
onCheckedChange={setEnableHealthCheck} value={graphQueryMaxDepth}
label="Health Check" onEditFinished={setGraphQueryMaxDepth}
/>
<LabeledNumberInput
label="Max Layout Iterations"
min={1}
max={20}
value={graphLayoutMaxIterations}
onEditFinished={setGraphLayoutMaxIterations}
/> />
<Separator /> <Separator />

View File

@@ -2,6 +2,7 @@ import { ReactNode, useCallback } from 'react'
import { Message } from '@/api/lightrag' import { Message } from '@/api/lightrag'
import useTheme from '@/hooks/useTheme' import useTheme from '@/hooks/useTheme'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -101,7 +102,10 @@ const CodeHighlight = ({ className, children, node, ...props }: CodeHighlightPro
{String(children).replace(/\n$/, '')} {String(children).replace(/\n$/, '')}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<code className={className} {...props}> <code
className={cn(className, 'mx-1 rounded-xs bg-black/10 px-1 dark:bg-gray-100/20')}
{...props}
>
{children} {children}
</code> </code>
) )

View File

@@ -50,11 +50,11 @@ export type NodeType = {
} }
export type EdgeType = { label: string } export type EdgeType = { label: string }
const fetchGraph = async (label: string) => { const fetchGraph = async (label: string, maxDepth: number) => {
let rawData: any = null let rawData: any = null
try { try {
rawData = await queryGraphs(label) rawData = await queryGraphs(label, maxDepth)
} catch (e) { } catch (e) {
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!') useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!')
return null return null
@@ -161,12 +161,13 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
return graph return graph
} }
const lastQueryLabel = { label: '' } const lastQueryLabel = { label: '', maxQueryDepth: 0 }
const useLightrangeGraph = () => { const useLightrangeGraph = () => {
const queryLabel = useSettingsStore.use.queryLabel() const queryLabel = useSettingsStore.use.queryLabel()
const rawGraph = useGraphStore.use.rawGraph() const rawGraph = useGraphStore.use.rawGraph()
const sigmaGraph = useGraphStore.use.sigmaGraph() const sigmaGraph = useGraphStore.use.sigmaGraph()
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
const getNode = useCallback( const getNode = useCallback(
(nodeId: string) => { (nodeId: string) => {
@@ -184,11 +185,13 @@ const useLightrangeGraph = () => {
useEffect(() => { useEffect(() => {
if (queryLabel) { if (queryLabel) {
if (lastQueryLabel.label !== queryLabel) { if (lastQueryLabel.label !== queryLabel || lastQueryLabel.maxQueryDepth !== maxQueryDepth) {
lastQueryLabel.label = queryLabel lastQueryLabel.label = queryLabel
lastQueryLabel.maxQueryDepth = maxQueryDepth
const state = useGraphStore.getState() const state = useGraphStore.getState()
state.reset() state.reset()
fetchGraph(queryLabel).then((data) => { fetchGraph(queryLabel, maxQueryDepth).then((data) => {
// console.debug('Query label: ' + queryLabel) // console.debug('Query label: ' + queryLabel)
state.setSigmaGraph(createSigmaGraph(data)) state.setSigmaGraph(createSigmaGraph(data))
data?.buildDynamicMap() data?.buildDynamicMap()
@@ -200,7 +203,7 @@ const useLightrangeGraph = () => {
state.reset() state.reset()
state.setSigmaGraph(new DirectedGraph()) state.setSigmaGraph(new DirectedGraph())
} }
}, [queryLabel]) }, [queryLabel, maxQueryDepth])
const lightrageGraph = useCallback(() => { const lightrageGraph = useCallback(() => {
if (sigmaGraph) { if (sigmaGraph) {

View File

@@ -16,6 +16,7 @@ export const edgeColorSelected = '#F57F17'
export const edgeColorHighlighted = '#B2EBF2' export const edgeColorHighlighted = '#B2EBF2'
export const searchResultLimit = 20 export const searchResultLimit = 20
export const labelListLimit = 40
export const minNodeSize = 4 export const minNodeSize = 4
export const maxNodeSize = 20 export const maxNodeSize = 20

View File

@@ -8,9 +8,7 @@ type Theme = 'dark' | 'light' | 'system'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
interface SettingsState { interface SettingsState {
theme: Theme // Graph viewer settings
setTheme: (theme: Theme) => void
showPropertyPanel: boolean showPropertyPanel: boolean
showNodeSearchBar: boolean showNodeSearchBar: boolean
@@ -21,23 +19,35 @@ interface SettingsState {
enableHideUnselectedEdges: boolean enableHideUnselectedEdges: boolean
enableEdgeEvents: boolean enableEdgeEvents: boolean
graphQueryMaxDepth: number
setGraphQueryMaxDepth: (depth: number) => void
graphLayoutMaxIterations: number
setGraphLayoutMaxIterations: (iterations: number) => void
// Retrieval settings
queryLabel: string queryLabel: string
setQueryLabel: (queryLabel: string) => void setQueryLabel: (queryLabel: string) => void
enableHealthCheck: boolean
setEnableHealthCheck: (enable: boolean) => void
apiKey: string | null
setApiKey: (key: string | null) => void
currentTab: Tab
setCurrentTab: (tab: Tab) => void
retrievalHistory: Message[] retrievalHistory: Message[]
setRetrievalHistory: (history: Message[]) => void setRetrievalHistory: (history: Message[]) => void
querySettings: Omit<QueryRequest, 'query'> querySettings: Omit<QueryRequest, 'query'>
updateQuerySettings: (settings: Partial<QueryRequest>) => void updateQuerySettings: (settings: Partial<QueryRequest>) => void
// Auth settings
apiKey: string | null
setApiKey: (key: string | null) => void
// App settings
theme: Theme
setTheme: (theme: Theme) => void
enableHealthCheck: boolean
setEnableHealthCheck: (enable: boolean) => void
currentTab: Tab
setCurrentTab: (tab: Tab) => void
} }
const useSettingsStoreBase = create<SettingsState>()( const useSettingsStoreBase = create<SettingsState>()(
@@ -55,7 +65,11 @@ const useSettingsStoreBase = create<SettingsState>()(
enableHideUnselectedEdges: true, enableHideUnselectedEdges: true,
enableEdgeEvents: false, enableEdgeEvents: false,
graphQueryMaxDepth: 3,
graphLayoutMaxIterations: 10,
queryLabel: defaultQueryLabel, queryLabel: defaultQueryLabel,
enableHealthCheck: true, enableHealthCheck: true,
apiKey: null, apiKey: null,
@@ -81,11 +95,18 @@ const useSettingsStoreBase = create<SettingsState>()(
setTheme: (theme: Theme) => set({ theme }), setTheme: (theme: Theme) => set({ theme }),
setGraphLayoutMaxIterations: (iterations: number) =>
set({
graphLayoutMaxIterations: iterations
}),
setQueryLabel: (queryLabel: string) => setQueryLabel: (queryLabel: string) =>
set({ set({
queryLabel queryLabel
}), }),
setGraphQueryMaxDepth: (depth: number) => set({ graphQueryMaxDepth: depth }),
setEnableHealthCheck: (enable: boolean) => set({ enableHealthCheck: enable }), setEnableHealthCheck: (enable: boolean) => set({ enableHealthCheck: enable }),
setApiKey: (apiKey: string | null) => set({ apiKey }), setApiKey: (apiKey: string | null) => set({ apiKey }),
@@ -102,7 +123,7 @@ const useSettingsStoreBase = create<SettingsState>()(
{ {
name: 'settings-storage', name: 'settings-storage',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
version: 6, version: 7,
migrate: (state: any, version: number) => { migrate: (state: any, version: number) => {
if (version < 2) { if (version < 2) {
state.showEdgeLabel = false state.showEdgeLabel = false
@@ -137,6 +158,10 @@ const useSettingsStoreBase = create<SettingsState>()(
} }
state.retrievalHistory = [] state.retrievalHistory = []
} }
if (version < 7) {
state.graphQueryMaxDepth = 3
state.graphLayoutMaxIterations = 10
}
return state return state
} }
} }