implement API key alert

This commit is contained in:
ArnoChen
2025-02-17 01:59:36 +08:00
parent f5ee1f2cdb
commit c5f380986d
23 changed files with 229 additions and 18 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>
)
}

View 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

View 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

View 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 }

View 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>
)
}

View 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