implement API key alert
This commit is contained in:
31
lightrag_webui/src/components/graph/FocusOnNode.tsx
Normal file
31
lightrag_webui/src/components/graph/FocusOnNode.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useEffect } from 'react'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
/**
|
||||
* Component that highlights a node and centers the camera on it.
|
||||
*/
|
||||
const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {
|
||||
const sigma = useSigma()
|
||||
const { gotoNode } = useCamera()
|
||||
|
||||
/**
|
||||
* When the selected item changes, highlighted the node and center the camera on it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!node) return
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
if (move) {
|
||||
gotoNode(node)
|
||||
useGraphStore.getState().setMoveToSelectedNode(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FocusOnNode
|
27
lightrag_webui/src/components/graph/FullScreenControl.tsx
Normal file
27
lightrag_webui/src/components/graph/FullScreenControl.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useFullScreen } from '@react-sigma/core'
|
||||
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
/**
|
||||
* Component that toggles full screen mode.
|
||||
*/
|
||||
const FullScreenControl = () => {
|
||||
const { isFullScreen, toggle } = useFullScreen()
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFullScreen ? (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Windowed" size="icon">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Full Screen" size="icon">
|
||||
<MaximizeIcon />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenControl
|
185
lightrag_webui/src/components/graph/GraphControl.tsx
Normal file
185
lightrag_webui/src/components/graph/GraphControl.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
|
||||
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import * as Constants from '@/lib/constants'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
|
||||
if (ev.type.startsWith('mouse')) {
|
||||
if ((ev as MouseEvent).buttons !== 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
|
||||
const { lightrageGraph } = useLightragGraph()
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
||||
const { assign: assignLayout } = useLayoutForceAtlas2({
|
||||
iterations: 20
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
|
||||
/**
|
||||
* When component mount
|
||||
* => load the graph
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Create & load the graph
|
||||
const graph = lightrageGraph()
|
||||
loadGraph(graph)
|
||||
if (!(graph as any).__force_applied) {
|
||||
assignLayout()
|
||||
Object.assign(graph, { __force_applied: true })
|
||||
}
|
||||
|
||||
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
|
||||
useGraphStore.getState()
|
||||
|
||||
// Register the events
|
||||
registerEvents({
|
||||
enterNode: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(event.node)
|
||||
}
|
||||
},
|
||||
leaveNode: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(null)
|
||||
}
|
||||
},
|
||||
clickNode: (event) => {
|
||||
setSelectedNode(event.node)
|
||||
setSelectedEdge(null)
|
||||
},
|
||||
clickEdge: (event) => {
|
||||
setSelectedEdge(event.edge)
|
||||
setSelectedNode(null)
|
||||
},
|
||||
enterEdge: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(event.edge)
|
||||
}
|
||||
},
|
||||
leaveEdge: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(null)
|
||||
}
|
||||
},
|
||||
clickStage: () => clearSelection()
|
||||
})
|
||||
}, [assignLayout, loadGraph, registerEvents, lightrageGraph])
|
||||
|
||||
/**
|
||||
* When component mount or hovered node change
|
||||
* => Setting the sigma reducers
|
||||
*/
|
||||
useEffect(() => {
|
||||
const isDarkTheme = theme === 'dark'
|
||||
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
|
||||
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
|
||||
|
||||
setSettings({
|
||||
nodeReducer: (node, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData: NodeType & {
|
||||
labelColor?: string
|
||||
borderColor?: string
|
||||
} = { ...data, highlighted: data.highlighted || false, labelColor }
|
||||
|
||||
if (!disableHoverEffect) {
|
||||
newData.highlighted = false
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
const _focusedEdge = focusedEdge || selectedEdge
|
||||
|
||||
if (_focusedNode) {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
}
|
||||
}
|
||||
} else if (_focusedEdge) {
|
||||
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||
newData.highlighted = true
|
||||
newData.size = 3
|
||||
}
|
||||
} else {
|
||||
return newData
|
||||
}
|
||||
|
||||
if (newData.highlighted) {
|
||||
if (isDarkTheme) {
|
||||
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
|
||||
}
|
||||
} else {
|
||||
newData.color = Constants.nodeColorDisabled
|
||||
}
|
||||
}
|
||||
return newData
|
||||
},
|
||||
edgeReducer: (edge, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
|
||||
|
||||
if (!disableHoverEffect) {
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
|
||||
if (_focusedNode) {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (focusedEdge || selectedEdge) {
|
||||
if (edge === selectedEdge) {
|
||||
newData.color = Constants.edgeColorSelected
|
||||
} else if (edge === focusedEdge) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
} else if (hideUnselectedEdges) {
|
||||
newData.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newData
|
||||
}
|
||||
})
|
||||
}, [
|
||||
selectedNode,
|
||||
focusedNode,
|
||||
selectedEdge,
|
||||
focusedEdge,
|
||||
setSettings,
|
||||
sigma,
|
||||
disableHoverEffect,
|
||||
theme,
|
||||
hideUnselectedEdges
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default GraphControl
|
87
lightrag_webui/src/components/graph/GraphLabels.tsx
Normal file
87
lightrag_webui/src/components/graph/GraphLabels.tsx
Normal file
@@ -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<string[]> => {
|
||||
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 (
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip="Select query label"
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label="Label"
|
||||
placeholder="Search labels..."
|
||||
value={label !== null ? label : ''}
|
||||
onChange={setQueryLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphLabels
|
133
lightrag_webui/src/components/graph/GraphSearch.tsx
Normal file
133
lightrag_webui/src/components/graph/GraphSearch.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
NodeById,
|
||||
GraphSearchInputProps,
|
||||
GraphSearchContextProviderProps
|
||||
} from '@react-sigma/graph-search'
|
||||
import { AsyncSearch } from '@/components/ui/AsyncSearch'
|
||||
import { searchResultLimit } from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import MiniSearch from 'minisearch'
|
||||
|
||||
interface OptionItem {
|
||||
id: string
|
||||
type: 'nodes' | 'edges' | 'message'
|
||||
message?: string
|
||||
}
|
||||
|
||||
function OptionComponent(item: OptionItem) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === 'nodes' && <NodeById id={item.id} />}
|
||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||
{item.type === 'message' && <div>{item.message}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const messageId = '__message_item'
|
||||
const lastGraph: any = {
|
||||
graph: null,
|
||||
searchEngine: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Component thats display the search input.
|
||||
*/
|
||||
export const GraphSearchInput = ({
|
||||
onChange,
|
||||
onFocus,
|
||||
value
|
||||
}: {
|
||||
onChange: GraphSearchInputProps['onChange']
|
||||
onFocus?: GraphSearchInputProps['onFocus']
|
||||
value?: GraphSearchInputProps['value']
|
||||
}) => {
|
||||
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.
|
||||
*/
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
if (!query || !search) return []
|
||||
const result: OptionItem[] = search.search(query).map((result) => ({
|
||||
id: result.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
|
||||
// prettier-ignore
|
||||
return result.length <= searchResultLimit
|
||||
? result
|
||||
: [
|
||||
...result.slice(0, searchResultLimit),
|
||||
{
|
||||
type: 'message',
|
||||
id: messageId,
|
||||
message: `And ${result.length - searchResultLimit} others`
|
||||
}
|
||||
]
|
||||
},
|
||||
[search, onFocus]
|
||||
)
|
||||
|
||||
return (
|
||||
<AsyncSearch
|
||||
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100"
|
||||
fetcher={loadOptions}
|
||||
renderOption={OptionComponent}
|
||||
getOptionValue={(item) => item.id}
|
||||
value={value && value.type !== 'message' ? value.id : null}
|
||||
onChange={(id) => {
|
||||
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
onFocus={(id) => {
|
||||
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
label={'item'}
|
||||
placeholder="Search nodes..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that display the search.
|
||||
*/
|
||||
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
|
||||
return <GraphSearchInput {...props} />
|
||||
}
|
||||
|
||||
export default GraphSearch
|
179
lightrag_webui/src/components/graph/LayoutsControl.tsx
Normal file
179
lightrag_webui/src/components/graph/LayoutsControl.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useSigma } from '@react-sigma/core'
|
||||
import { animateNodes } from 'sigma/utils'
|
||||
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
|
||||
import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'
|
||||
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
||||
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
||||
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
|
||||
|
||||
type LayoutName =
|
||||
| 'Circular'
|
||||
| 'Circlepack'
|
||||
| 'Random'
|
||||
| 'Noverlaps'
|
||||
| 'Force Directed'
|
||||
| 'Force Atlas'
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
|
||||
const sigma = useSigma()
|
||||
const { stop, start, isRunning } = layout
|
||||
|
||||
/**
|
||||
* Init component when Sigma or component settings change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) {
|
||||
return
|
||||
}
|
||||
|
||||
// we run the algo
|
||||
let timeout: number | null = null
|
||||
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
||||
start()
|
||||
// set a timeout to stop it
|
||||
timeout =
|
||||
autoRunFor > 0
|
||||
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
||||
: null
|
||||
}
|
||||
|
||||
//cleaning
|
||||
return () => {
|
||||
stop()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [autoRunFor, start, stop, sigma])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => (isRunning ? stop() : start())}
|
||||
tooltip={isRunning ? 'Stop the layout animation' : 'Start the layout animation'}
|
||||
variant={controlButtonVariant}
|
||||
>
|
||||
{isRunning ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that controls the layout of the graph.
|
||||
*/
|
||||
const LayoutsControl = () => {
|
||||
const sigma = useSigma()
|
||||
const [layout, setLayout] = useState<LayoutName>('Circular')
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
|
||||
const layoutCircular = useLayoutCircular()
|
||||
const layoutCirclepack = useLayoutCirclepack()
|
||||
const layoutRandom = useLayoutRandom()
|
||||
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
|
||||
const layoutForce = useLayoutForce({ maxIterations: 20 })
|
||||
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: 20 })
|
||||
const workerNoverlap = useWorkerLayoutNoverlap()
|
||||
const workerForce = useWorkerLayoutForce()
|
||||
const workerForceAtlas2 = useWorkerLayoutForceAtlas2()
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
return {
|
||||
Circular: {
|
||||
layout: layoutCircular
|
||||
},
|
||||
Circlepack: {
|
||||
layout: layoutCirclepack
|
||||
},
|
||||
Random: {
|
||||
layout: layoutRandom
|
||||
},
|
||||
Noverlaps: {
|
||||
layout: layoutNoverlap,
|
||||
worker: workerNoverlap
|
||||
},
|
||||
'Force Directed': {
|
||||
layout: layoutForce,
|
||||
worker: workerForce
|
||||
},
|
||||
'Force Atlas': {
|
||||
layout: layoutForceAtlas2,
|
||||
worker: workerForceAtlas2
|
||||
}
|
||||
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }
|
||||
}, [
|
||||
layoutCirclepack,
|
||||
layoutCircular,
|
||||
layoutForce,
|
||||
layoutForceAtlas2,
|
||||
layoutNoverlap,
|
||||
layoutRandom,
|
||||
workerForce,
|
||||
workerNoverlap,
|
||||
workerForceAtlas2
|
||||
])
|
||||
|
||||
const runLayout = useCallback(
|
||||
(newLayout: LayoutName) => {
|
||||
console.debug(newLayout)
|
||||
const { positions } = layouts[newLayout].layout
|
||||
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
|
||||
setLayout(newLayout)
|
||||
},
|
||||
[layouts, sigma]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{layouts[layout] && 'worker' in layouts[layout] && (
|
||||
<WorkerLayoutControl layout={layouts[layout].worker!} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={() => setOpened((e: boolean) => !e)}
|
||||
tooltip="Layout Graph"
|
||||
>
|
||||
<GripIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="center" className="p-1">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{Object.keys(layouts).map((name) => (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
runLayout(name as LayoutName)
|
||||
}}
|
||||
key={name}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutsControl
|
231
lightrag_webui/src/components/graph/PropertiesView.tsx
Normal file
231
lightrag_webui/src/components/graph/PropertiesView.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import Text from '@/components/ui/Text'
|
||||
import useLightragGraph from '@/hooks/useLightragGraph'
|
||||
|
||||
/**
|
||||
* Component that view properties of elements in graph.
|
||||
*/
|
||||
const PropertiesView = () => {
|
||||
const { getNode, getEdge } = useLightragGraph()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
|
||||
const [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null)
|
||||
const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let type: 'node' | 'edge' | null = null
|
||||
let element: RawNodeType | RawEdgeType | null = null
|
||||
if (focusedNode) {
|
||||
type = 'node'
|
||||
element = getNode(focusedNode)
|
||||
} else if (selectedNode) {
|
||||
type = 'node'
|
||||
element = getNode(selectedNode)
|
||||
} else if (focusedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(focusedEdge, true)
|
||||
} else if (selectedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(selectedEdge, true)
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (type == 'node') {
|
||||
setCurrentElement(refineNodeProperties(element as any))
|
||||
} else {
|
||||
setCurrentElement(refineEdgeProperties(element as any))
|
||||
}
|
||||
setCurrentType(type)
|
||||
} else {
|
||||
setCurrentElement(null)
|
||||
setCurrentType(null)
|
||||
}
|
||||
}, [
|
||||
focusedNode,
|
||||
selectedNode,
|
||||
focusedEdge,
|
||||
selectedEdge,
|
||||
setCurrentElement,
|
||||
setCurrentType,
|
||||
getNode,
|
||||
getEdge
|
||||
])
|
||||
|
||||
if (!currentElement) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg">
|
||||
{currentType == 'node' ? (
|
||||
<NodePropertiesView node={currentElement as any} />
|
||||
) : (
|
||||
<EdgePropertiesView edge={currentElement as any} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type NodeType = RawNodeType & {
|
||||
relationships: {
|
||||
type: string
|
||||
id: string
|
||||
label: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type EdgeType = RawEdgeType & {
|
||||
sourceNode?: RawNodeType
|
||||
targetNode?: RawNodeType
|
||||
}
|
||||
|
||||
const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const relationships = []
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
for (const edgeId of state.sigmaGraph.edges(node.id)) {
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: isTarget ? 'Target' : 'Source',
|
||||
id: neighbourId,
|
||||
label: neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
relationships
|
||||
}
|
||||
}
|
||||
|
||||
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const sourceNode = state.rawGraph?.getNode(edge.source)
|
||||
const targetNode = state.rawGraph?.getNode(edge.target)
|
||||
return {
|
||||
...edge,
|
||||
sourceNode,
|
||||
targetNode
|
||||
}
|
||||
}
|
||||
|
||||
const PropertyRow = ({
|
||||
name,
|
||||
value,
|
||||
onClick,
|
||||
tooltip
|
||||
}: {
|
||||
name: string
|
||||
value: any
|
||||
onClick?: () => void
|
||||
tooltip?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-primary/60 tracking-wide">{name}</label>:
|
||||
<Text
|
||||
className="hover:bg-primary/20 rounded p-1 text-ellipsis"
|
||||
tooltipClassName="max-w-80"
|
||||
text={value}
|
||||
tooltip={tooltip || value}
|
||||
side="left"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">Node</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={'Id'} value={node.id} />
|
||||
<PropertyRow
|
||||
name={'Labels'}
|
||||
value={node.labels.join(', ')}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(node.id, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow name={'Degree'} value={node.degree} />
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(node.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
return <PropertyRow key={name} name={name} value={node.properties[name]} />
|
||||
})}
|
||||
</div>
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
|
||||
Relationships
|
||||
</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{node.relationships.map(({ type, id, label }) => {
|
||||
return (
|
||||
<PropertyRow
|
||||
key={id}
|
||||
name={type}
|
||||
value={label}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(id, true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">Relationship</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={'Id'} value={edge.id} />
|
||||
{edge.type && <PropertyRow name={'Type'} value={edge.type} />}
|
||||
<PropertyRow
|
||||
name={'Source'}
|
||||
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.source, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow
|
||||
name={'Target'}
|
||||
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.target, true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(edge.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
return <PropertyRow key={name} name={name} value={edge.properties[name]} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PropertiesView
|
211
lightrag_webui/src/components/graph/Settings.tsx
Normal file
211
lightrag_webui/src/components/graph/Settings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Separator from '@/components/ui/Separator'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Component that displays a checkbox with a label.
|
||||
*/
|
||||
const LabeledCheckBox = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: () => void
|
||||
label: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays a popover with settings options.
|
||||
*/
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const [tempApiKey, setTempApiKey] = useState<string>('')
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const showNodeLabel = useSettingsStore.use.showNodeLabel()
|
||||
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
|
||||
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const apiKey = useSettingsStore.use.apiKey()
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(apiKey || '')
|
||||
}, [apiKey, opened])
|
||||
|
||||
const setEnableNodeDrag = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||
[]
|
||||
)
|
||||
const setEnableEdgeEvents = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),
|
||||
[]
|
||||
)
|
||||
const setEnableHideUnselectedEdges = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
enableHideUnselectedEdges: !pre.enableHideUnselectedEdges
|
||||
})),
|
||||
[]
|
||||
)
|
||||
const setShowEdgeLabel = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
showEdgeLabel: !pre.showEdgeLabel
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
//
|
||||
const setShowPropertyPanel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeSearchBar = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeLabel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setEnableHealthCheck = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setApiKey = useCallback(async () => {
|
||||
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
||||
await useBackendState.getState().check()
|
||||
setOpened(false)
|
||||
}, [tempApiKey])
|
||||
|
||||
const handleTempApiKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTempApiKey(e.target.value)
|
||||
},
|
||||
[setTempApiKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip="Settings" size="icon">
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="mb-2 p-2"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LabeledCheckBox
|
||||
checked={showPropertyPanel}
|
||||
onCheckedChange={setShowPropertyPanel}
|
||||
label="Show Property Panel"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={showNodeSearchBar}
|
||||
onCheckedChange={setShowNodeSearchBar}
|
||||
label="Show Search Bar"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showNodeLabel}
|
||||
onCheckedChange={setShowNodeLabel}
|
||||
label="Show Node Label"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableNodeDrag}
|
||||
onCheckedChange={setEnableNodeDrag}
|
||||
label="Node Draggable"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showEdgeLabel}
|
||||
onCheckedChange={setShowEdgeLabel}
|
||||
label="Show Edge Label"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableHideUnselectedEdges}
|
||||
onCheckedChange={setEnableHideUnselectedEdges}
|
||||
label="Hide Unselected Edges"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableEdgeEvents}
|
||||
onCheckedChange={setEnableEdgeEvents}
|
||||
label="Edge Events"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={enableHealthCheck}
|
||||
onCheckedChange={setEnableHealthCheck}
|
||||
label="Health Check"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">API Key</label>
|
||||
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="w-0 flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
value={tempApiKey}
|
||||
onChange={handleTempApiKeyChange}
|
||||
placeholder="Enter your API key"
|
||||
className="max-h-full w-full min-w-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={setApiKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="max-h-full shrink-0"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
63
lightrag_webui/src/components/graph/StatusCard.tsx
Normal file
63
lightrag_webui/src/components/graph/StatusCard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { LightragStatus } from '@/api/lightrag'
|
||||
|
||||
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
|
||||
if (!status) {
|
||||
return <div className="text-muted-foreground text-sm">Status information unavailable</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[300px] space-y-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Storage Info</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>Working Directory:</span>
|
||||
<span className="truncate">{status.working_directory}</span>
|
||||
<span>Input Directory:</span>
|
||||
<span className="truncate">{status.input_directory}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">LLM Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>LLM Binding:</span>
|
||||
<span>{status.configuration.llm_binding}</span>
|
||||
<span>LLM Binding Host:</span>
|
||||
<span>{status.configuration.llm_binding_host}</span>
|
||||
<span>LLM Model:</span>
|
||||
<span>{status.configuration.llm_model}</span>
|
||||
<span>Max Tokens:</span>
|
||||
<span>{status.configuration.max_tokens}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Embedding Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>Embedding Binding:</span>
|
||||
<span>{status.configuration.embedding_binding}</span>
|
||||
<span>Embedding Binding Host:</span>
|
||||
<span>{status.configuration.embedding_binding_host}</span>
|
||||
<span>Embedding Model:</span>
|
||||
<span>{status.configuration.embedding_model}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Storage Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>KV Storage:</span>
|
||||
<span>{status.configuration.kv_storage}</span>
|
||||
<span>Doc Status Storage:</span>
|
||||
<span>{status.configuration.doc_status_storage}</span>
|
||||
<span>Graph Storage:</span>
|
||||
<span>{status.configuration.graph_storage}</span>
|
||||
<span>Vector Storage:</span>
|
||||
<span>{status.configuration.vector_storage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusCard
|
48
lightrag_webui/src/components/graph/StatusIndicator.tsx
Normal file
48
lightrag_webui/src/components/graph/StatusIndicator.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import StatusCard from '@/components/graph/StatusCard'
|
||||
|
||||
const StatusIndicator = () => {
|
||||
const health = useBackendState.use.health()
|
||||
const lastCheckTime = useBackendState.use.lastCheckTime()
|
||||
const status = useBackendState.use.status()
|
||||
const [animate, setAnimate] = useState(false)
|
||||
|
||||
// listen to health change
|
||||
useEffect(() => {
|
||||
setAnimate(true)
|
||||
const timer = setTimeout(() => setAnimate(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [lastCheckTime])
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex cursor-help items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 rounded-full transition-all duration-300',
|
||||
'shadow-[0_0_8px_rgba(0,0,0,0.2)]',
|
||||
health ? 'bg-green-500' : 'bg-red-500',
|
||||
animate && 'scale-125',
|
||||
animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',
|
||||
animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{health ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto" side="top" align="end">
|
||||
<StatusCard status={status} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusIndicator
|
57
lightrag_webui/src/components/graph/ThemeProvider.tsx
Normal file
57
lightrag_webui/src/components/graph/ThemeProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
import { Theme, useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
/**
|
||||
* Component that provides the theme state and setter function to its children.
|
||||
*/
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
root.classList.add(systemTheme)
|
||||
setTheme(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
useSettingsStore.getState().setTheme(theme)
|
||||
setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ThemeProviderContext }
|
39
lightrag_webui/src/components/graph/ThemeToggle.tsx
Normal file
39
lightrag_webui/src/components/graph/ThemeToggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import { MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Component that toggles the theme between light and dark.
|
||||
*/
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const setLight = useCallback(() => setTheme('light'), [setTheme])
|
||||
const setDark = useCallback(() => setTheme('dark'), [setTheme])
|
||||
|
||||
if (theme === 'dark') {
|
||||
return (
|
||||
<Button
|
||||
onClick={setLight}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to light theme"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
<MoonIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setDark}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to dark theme"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
<SunIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
37
lightrag_webui/src/components/graph/ZoomControl.tsx
Normal file
37
lightrag_webui/src/components/graph/ZoomControl.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCamera } from '@react-sigma/core'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Component that provides zoom controls for the graph viewer.
|
||||
*/
|
||||
const ZoomControl = () => {
|
||||
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||
|
||||
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||
const handleResetZoom = useCallback(() => reset(), [reset])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip="Zoom In" size="icon">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip="Zoom Out" size="icon">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleResetZoom}
|
||||
tooltip="Reset Zoom"
|
||||
size="icon"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZoomControl
|
Reference in New Issue
Block a user