Skip to content

Commit 9a45b64

Browse files
committed
Merge branch 'ui-speedup'
2 parents a4f6e2b + 8eb5bd4 commit 9a45b64

18 files changed

Lines changed: 214 additions & 32 deletions

File tree

lightrag_webui/src/components/graph/GraphControl.tsx

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,26 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
3737
const selectedEdge = useGraphStore.use.selectedEdge()
3838
const focusedEdge = useGraphStore.use.focusedEdge()
3939
const sigmaGraph = useGraphStore.use.sigmaGraph()
40+
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
41+
42+
// Mirror GraphViewer's gating: above EDGE_PERF_LIMIT the sigma instance is
43+
// (re)built without the edge picking buffer, so edge events cannot fire even
44+
// though the user setting may be on. Use this — not the raw setting — for
45+
// event registration and the reducers, so we never run the costly edge-focus
46+
// path against a graph that can't actually pick edges.
47+
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
4048

4149
// The graph the initial FA2 layout has already been run for. Used to run the
4250
// layout once PER GRAPH, not once per sigma instance (a theme change rebuilds
4351
// the instance and re-runs the bind effect with the SAME graph).
4452
const laidOutGraphRef = useRef<unknown>(null)
4553

54+
// Last (sigma instance, curved decision) the edge-type effect applied. A
55+
// rebuild (theme toggle / edge-events gating) creates a fresh sigma whose
56+
// defaultEdgeType reverts to its construction default ('rect'), so we must
57+
// re-apply when the INSTANCE changed too — not only when the decision flips.
58+
const edgeTypeRef = useRef<{ sigma: unknown; curved: boolean } | null>(null)
59+
4660
/**
4761
* When component mounts or the graph changes
4862
* => bind graph to sigma and run the initial layout
@@ -76,8 +90,13 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
7690
// effect with the SAME graph, which already carries settled positions —
7791
// re-running FA2 there would replay the relaxation animation on every theme
7892
// switch. Only (re)bind in that case; skip the layout.
93+
//
94+
// The ref is marked "laid out" only when the budget timer fires (layout
95+
// settled), NOT before start: if a rebuild interrupts the layout mid-run
96+
// (e.g. edge-events gating crosses the threshold during the first load),
97+
// the new instance re-runs FA2 from where it was instead of freezing on a
98+
// half-relaxed layout. Rebuilds after settling still match and skip.
7999
if (laidOutGraphRef.current === sigmaGraph) return
80-
laidOutGraphRef.current = sigmaGraph
81100

82101
let layout: { start: () => void; stop: () => void; kill: () => void } | null = null
83102
try {
@@ -100,6 +119,9 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
100119
const timer = window.setTimeout(() => {
101120
try {
102121
layout?.stop()
122+
// Mark this graph as laid out only now (settled), so a rebuild during
123+
// the budget window re-runs the layout rather than skipping it.
124+
laidOutGraphRef.current = sigmaGraph
103125
console.log('FA2 worker layout stopped after budget')
104126
// Release the shared slot so the store invariant "activeLayoutSupervisor
105127
// != null => a layout is running" holds (the budget just stopped this
@@ -129,7 +151,13 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
129151
useEffect(() => {
130152
if (sigma) {
131153
const currentInstance = useGraphStore.getState().sigmaInstance
132-
if (!currentInstance) {
154+
// Update when the instance CHANGED, not only when it's unset. A theme
155+
// toggle, an effectiveEdgeEvents flip, or crossing the edge threshold
156+
// rebuilds the SigmaContainer (old instance killed, new one created); if
157+
// we only wrote on an empty store the killed instance would linger there
158+
// and consumers (e.g. expand reading sigmaInstance.getCamera()) would act
159+
// on a dead Sigma.
160+
if (currentInstance !== sigma) {
133161
console.log('Setting sigma instance from GraphControl')
134162
useGraphStore.getState().setSigmaInstance(sigma)
135163
}
@@ -182,6 +210,45 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
182210
}
183211
}, [sigma])
184212

213+
/**
214+
* Adapt edge geometry to graph size: curves for small graphs (nicer to read),
215+
* straight rectangles above EDGE_PERF_LIMIT (curve tessellation is costly).
216+
*
217+
* Edges carry no per-edge `type`, so switching `defaultEdgeType` + a full
218+
* `refresh()` (which re-adds edges through applyEdgeDefaults) re-selects the
219+
* program for the whole graph without touching attributes or rebuilding.
220+
*
221+
* The ref tracks BOTH the sigma instance and the decision: re-apply when the
222+
* instance changed (a rebuild resets defaultEdgeType to 'rect') OR when the
223+
* curved/straight decision flipped; skip otherwise so routine expand/prune
224+
* within one band don't trigger a full refresh.
225+
*/
226+
useEffect(() => {
227+
if (!sigma) return
228+
const curved = graphEdgeCount > 0 && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
229+
const prev = edgeTypeRef.current
230+
if (prev && prev.sigma === sigma && prev.curved === curved) return
231+
edgeTypeRef.current = { sigma, curved }
232+
setSettings({ defaultEdgeType: curved ? 'curvedNoArrow' : 'rect' })
233+
try {
234+
sigma.refresh()
235+
} catch {
236+
/* sigma instance already killed */
237+
}
238+
}, [sigma, graphEdgeCount, setSettings])
239+
240+
/**
241+
* When edge events become gated off (count crossed above EDGE_PERF_LIMIT),
242+
* drop any residual edge focus/selection so the UI (property panel, reducers)
243+
* doesn't keep a now-unpickable edge highlighted. Node selection is untouched.
244+
*/
245+
useEffect(() => {
246+
if (effectiveEdgeEvents) return
247+
const { selectedEdge, focusedEdge, setSelectedEdge, setFocusedEdge } = useGraphStore.getState()
248+
if (selectedEdge !== null) setSelectedEdge(null)
249+
if (focusedEdge !== null) setFocusedEdge(null)
250+
}, [effectiveEdgeEvents])
251+
185252
/**
186253
* When component mounts
187254
* => register events
@@ -217,7 +284,7 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
217284
clickStage: () => clearSelection()
218285
}
219286

220-
if (enableEdgeEvents) {
287+
if (effectiveEdgeEvents) {
221288
events.clickEdge = (event: EdgeEvent) => {
222289
setSelectedEdge(event.edge)
223290
setSelectedNode(null)
@@ -235,7 +302,7 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
235302
}
236303

237304
registerEvents(events)
238-
}, [registerEvents, enableEdgeEvents, sigma])
305+
}, [registerEvents, effectiveEdgeEvents, sigma])
239306

240307
/**
241308
* When edge size settings change, recalculate edge sizes.
@@ -297,11 +364,16 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
297364
const effectiveRenderLabels = renderLabels && graphOrder <= Constants.LABEL_RENDER_LIMIT
298365

299366
const _focusedNode = focusedNode || selectedNode
300-
const _focusedEdge = focusedEdge || selectedEdge
367+
// Ignore any residual edge focus/selection when edge events are gated off
368+
// (e.g. an edge was selected on a small graph, then expand pushed it past
369+
// EDGE_PERF_LIMIT): otherwise the edge-focused reducer branch below would
370+
// still run the per-edge highlight pass on a large graph — exactly the cost
371+
// we disabled edge events to avoid.
372+
const _focusedEdge = effectiveEdgeEvents ? focusedEdge || selectedEdge : null
301373

302374
if (disableHoverEffect || (!_focusedNode && !_focusedEdge)) {
303375
setSettings({
304-
enableEdgeEvents,
376+
enableEdgeEvents: effectiveEdgeEvents,
305377
renderEdgeLabels,
306378
renderLabels: effectiveRenderLabels,
307379
nodeReducer: null,
@@ -331,7 +403,7 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
331403
: Constants.edgeColorHighlightedLightTheme
332404

333405
setSettings({
334-
enableEdgeEvents,
406+
enableEdgeEvents: effectiveEdgeEvents,
335407
renderEdgeLabels,
336408
renderLabels: effectiveRenderLabels,
337409

@@ -403,7 +475,7 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
403475
disableHoverEffect,
404476
isDarkTheme,
405477
hideUnselectedEdges,
406-
enableEdgeEvents,
478+
effectiveEdgeEvents,
407479
renderEdgeLabels,
408480
renderLabels
409481
])

lightrag_webui/src/components/graph/Settings.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Button from '@/components/ui/Button'
55
import Separator from '@/components/ui/Separator'
66
import Input from '@/components/ui/Input'
77

8-
import { controlButtonVariant } from '@/lib/constants'
8+
import { controlButtonVariant, EDGE_PERF_LIMIT } from '@/lib/constants'
99
import { useSettingsStore } from '@/stores/settings'
1010
import { useGraphStore } from '@/stores/graph'
1111
import useRandomGraph from '@/hooks/useRandomGraph'
@@ -19,18 +19,27 @@ import { useTranslation } from 'react-i18next';
1919
const LabeledCheckBox = ({
2020
checked,
2121
onCheckedChange,
22-
label
22+
label,
23+
disabled,
24+
title
2325
}: {
2426
checked: boolean
2527
onCheckedChange: () => void
2628
label: string
29+
disabled?: boolean
30+
title?: string
2731
}) => {
2832
// Create unique ID using the label text converted to lowercase with spaces removed
2933
const id = `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
3034

35+
// The label's peer-disabled:* classes don't fire — Checkbox carries no `peer`
36+
// class — so grey the WHOLE row explicitly when disabled, not just the box.
3137
return (
32-
<div className="flex items-center gap-2">
33-
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} />
38+
<div
39+
className={`flex items-center gap-2${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
40+
title={title}
41+
>
42+
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
3443
<label
3544
htmlFor={id}
3645
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@@ -177,6 +186,7 @@ export default function Settings() {
177186
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
178187
const showNodeLabel = useSettingsStore.use.showNodeLabel()
179188
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
189+
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
180190
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
181191
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
182192
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
@@ -325,6 +335,12 @@ export default function Settings() {
325335
checked={enableEdgeEvents}
326336
onCheckedChange={setEnableEdgeEvents}
327337
label={t('graphPanel.sideBar.settings.edgeEvents')}
338+
disabled={graphEdgeCount > EDGE_PERF_LIMIT}
339+
title={
340+
graphEdgeCount > EDGE_PERF_LIMIT
341+
? t('graphPanel.sideBar.settings.edgeEventsDisabledHint', { count: EDGE_PERF_LIMIT })
342+
: undefined
343+
}
328344
/>
329345

330346
<div className="flex flex-col gap-2">

lightrag_webui/src/components/graph/SettingsDisplay.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useSettingsStore } from '@/stores/settings'
2+
import { useGraphStore } from '@/stores/graph'
23
import { useTranslation } from 'react-i18next'
34

45
/**
@@ -8,12 +9,17 @@ import { useTranslation } from 'react-i18next'
89
const SettingsDisplay = () => {
910
const { t } = useTranslation()
1011
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
11-
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
12+
// Live counts of the rendered graph (reactive: updated on build/expand/prune).
13+
// Reading sigmaGraph.order/.size directly would not re-render, since
14+
// expand/prune mutate the graph in place.
15+
const graphNodeCount = useGraphStore.use.graphNodeCount()
16+
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
1217

1318
return (
1419
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
1520
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
16-
<div>{t('graphPanel.sideBar.settings.max')}: {graphMaxNodes}</div>
21+
<div>{t('graphPanel.sideBar.settings.node')}: {graphNodeCount}</div>
22+
<div>{t('graphPanel.sideBar.settings.edge')}: {graphEdgeCount}</div>
1723
</div>
1824
)
1925
}

lightrag_webui/src/features/GraphViewer.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import LegendButton from '@/components/graph/LegendButton'
3030
import { useSettingsStore } from '@/stores/settings'
3131
import { useGraphStore } from '@/stores/graph'
3232
import useIsDarkMode from '@/hooks/useIsDarkMode'
33-
import { labelColorDarkTheme, labelColorLightTheme, edgeColorDarkTheme } from '@/lib/constants'
33+
import { labelColorDarkTheme, labelColorLightTheme, edgeColorDarkTheme, EDGE_PERF_LIMIT } from '@/lib/constants'
3434

3535
import '@react-sigma/core/lib/style.css'
3636
import '@react-sigma/graph-search/lib/style.css'
@@ -158,6 +158,14 @@ const GraphViewer = () => {
158158
const showLegend = useSettingsStore.use.showLegend()
159159
const theme = useSettingsStore.use.theme()
160160
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
161+
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
162+
163+
// Edge events are disabled above EDGE_PERF_LIMIT regardless of the user
164+
// setting: the picking buffer renders edges to an extra frame buffer every
165+
// frame, which is too costly on large graphs. The user setting is preserved
166+
// (Settings greys the menu item but keeps its value), so dropping back below
167+
// the limit auto-restores edge events.
168+
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= EDGE_PERF_LIMIT
161169

162170
const [isThemeSwitching, setIsThemeSwitching] = useState(false)
163171

@@ -172,8 +180,8 @@ const GraphViewer = () => {
172180
// enableEdgeEvents is in the deps because it must be baked in at construction
173181
// (the picking buffer can't be added later); toggling it rebuilds the instance.
174182
const memoizedSigmaSettings = useMemo(
175-
() => createSigmaSettings(isDarkMode, enableEdgeEvents),
176-
[isDarkMode, enableEdgeEvents]
183+
() => createSigmaSettings(isDarkMode, effectiveEdgeEvents),
184+
[isDarkMode, effectiveEdgeEvents]
177185
)
178186

179187
// Detect theme changes and briefly show a loading overlay to avoid flash of

lightrag_webui/src/hooks/useLightragGraph.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,14 @@ const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) =>
184184
console.log(`Fetching graph label: ${queryLabel}, depth: ${maxDepth}, nodes: ${maxNodes}`)
185185
rawData = await queryGraphs(queryLabel, maxDepth, maxNodes)
186186
} catch (e) {
187+
// Record the backend error, then RETHROW so the caller's .catch() runs its
188+
// bounded-retry path. Returning null here would resolve the promise
189+
// "successfully" and the .then() handler would treat a transient
190+
// network/API failure as an empty graph (reset state, render the "Graph Is
191+
// Empty" placeholder, stamp the signature as succeeded) — and retries would
192+
// never fire. A genuinely empty result still resolves normally below.
187193
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!')
188-
return null
194+
throw e
189195
}
190196

191197
let rawGraph = null
@@ -521,6 +527,9 @@ const useLightrangeGraph = () => {
521527
// Set graph to store
522528
state.setSigmaGraph(emptyGraph)
523529
state.setRawGraph(null)
530+
// The placeholder carries one synthetic node; the graph is logically
531+
// empty, so override the reactive counts back to 0/0.
532+
state.setGraphCounts(0, 0)
524533

525534
// Still mark graph as empty for other logic
526535
state.setGraphIsEmpty(true)
@@ -1059,6 +1068,11 @@ const useLightrangeGraph = () => {
10591068
// Reset search engine to force rebuild
10601069
useGraphStore.getState().resetSearchEngine()
10611070

1071+
// The graph was mutated in place (addNode/addEdge), which does NOT
1072+
// re-notify the store. Refresh the reactive counts so the status bar and
1073+
// the edge-count adaptive behavior react to the new size.
1074+
useGraphStore.getState().setGraphCounts(sigmaGraph.order, sigmaGraph.size)
1075+
10621076
// Update sizes for all nodes and edges
10631077
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree)
10641078
updateEdgeSizes(sigmaGraph, minWeight, maxWeight)
@@ -1166,6 +1180,12 @@ const useLightrangeGraph = () => {
11661180
// Reset search engine to force rebuild
11671181
useGraphStore.getState().resetSearchEngine()
11681182

1183+
// In-place dropNode/dropEdge does NOT re-notify the store; refresh the
1184+
// reactive counts so the status bar and edge-count adaptive behavior
1185+
// react to the smaller graph (and auto-recover curves/edge events when
1186+
// the edge count drops back to <= EDGE_PERF_LIMIT).
1187+
useGraphStore.getState().setGraphCounts(sigmaGraph.order, sigmaGraph.size)
1188+
11691189
// Show notification if we deleted more than just the selected node
11701190
if (nodesToDelete.size > 1) {
11711191
toast.info(

lightrag_webui/src/lib/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ export const LABEL_RENDER_LIMIT = 2000
107107
// animating: animateNodes interpolates every node per frame on the main thread.
108108
export const ANIMATE_NODE_LIMIT = 5000
109109

110+
// Edge-count threshold that switches the graph between "small-graph experience"
111+
// and "large-graph performance". At or below it edges render as curves and edge
112+
// events (hover/click picking) follow the user setting; above it edges render
113+
// straight and edge events are fully disabled (no picking buffer allocated).
114+
// Shared by GraphControl (defaultEdgeType), GraphViewer (enableEdgeEvents
115+
// gating) and Settings (greying the Edge Events menu item) so they cannot drift.
116+
export const EDGE_PERF_LIMIT = 5000
117+
110118
// Time budget (ms) a relaxing worker layout runs before it is stopped. Scales
111119
// with graph size, capped so huge graphs don't run unbounded.
112120
export const workerBudgetMs = (order: number): number => Math.min(1500 + order / 10, 10000)

lightrag_webui/src/locales/ar.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,14 @@
251251
"showEdgeLabel": "إظهار تسمية الحافة",
252252
"hideUnselectedEdges": "إخفاء الحواف غير المحددة",
253253
"edgeEvents": "أحداث الحافة",
254+
"edgeEventsDisabledHint": "معطّل للرسوم البيانية التي تحتوي على أكثر من {{count}} حافة",
254255
"maxQueryDepth": "أقصى عمق للاستعلام",
255256
"maxNodes": "الحد الأقصى للعقد",
256257
"resetToDefault": "إعادة التعيين إلى الافتراضي",
257258
"edgeSizeRange": "نطاق حجم الحافة",
258259
"depth": "D",
259-
"max": "Max",
260+
"node": "عقدة",
261+
"edge": "حافة",
260262
"degree": "الدرجة",
261263
"apiKey": "مفتاح واجهة برمجة التطبيقات",
262264
"enterYourAPIkey": "أدخل مفتاح واجهة برمجة التطبيقات الخاص بك",

lightrag_webui/src/locales/de.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,14 @@
251251
"showEdgeLabel": "Kantenbezeichnung anzeigen",
252252
"hideUnselectedEdges": "Nicht ausgewählte Kanten ausblenden",
253253
"edgeEvents": "Kanten-Ereignisse",
254+
"edgeEventsDisabledHint": "Deaktiviert für Graphen mit mehr als {{count}} Kanten",
254255
"maxQueryDepth": "Maximale Abfragetiefe",
255256
"maxNodes": "Maximale Knotenanzahl",
256257
"resetToDefault": "Auf Standard zurücksetzen",
257258
"edgeSizeRange": "Kantengrößenbereich",
258259
"depth": "T",
259-
"max": "Max",
260+
"node": "Knoten",
261+
"edge": "Kante",
260262
"degree": "Grad",
261263
"apiKey": "API-Schlüssel",
262264
"enterYourAPIkey": "Geben Sie Ihren API-Schlüssel ein",

0 commit comments

Comments
 (0)