Merge branch 'improve-property-tooltip' into loginPage
This commit is contained in:
66
lightrag_webui/src/components/AppSettings.tsx
Normal file
66
lightrag_webui/src/components/AppSettings.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { PaletteIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AppSettings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const language = useSettingsStore.use.language()
|
||||
const setLanguage = useSettingsStore.use.setLanguage()
|
||||
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const setTheme = useSettingsStore.use.setTheme()
|
||||
|
||||
const handleLanguageChange = useCallback((value: string) => {
|
||||
setLanguage(value as 'en' | 'zh')
|
||||
}, [setLanguage])
|
||||
|
||||
const handleThemeChange = useCallback((value: string) => {
|
||||
setTheme(value as 'light' | 'dark' | 'system')
|
||||
}, [setTheme])
|
||||
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<PaletteIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-56">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('settings.language')}</label>
|
||||
<Select value={language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('settings.theme')}</label>
|
||||
<Select value={theme} onValueChange={handleThemeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">{t('settings.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
|
||||
<SelectItem value="system">{t('settings.system')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
24
lightrag_webui/src/components/Root.tsx
Normal file
24
lightrag_webui/src/components/Root.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { StrictMode, useEffect, useState } from 'react'
|
||||
import { initializeI18n } from '@/i18n'
|
||||
import App from '@/App'
|
||||
|
||||
export const Root = () => {
|
||||
const [isI18nInitialized, setIsI18nInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize i18n immediately with persisted language
|
||||
initializeI18n().then(() => {
|
||||
setIsI18nInitialized(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!isI18nInitialized) {
|
||||
return null // or a loading spinner
|
||||
}
|
||||
|
||||
return (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
import { createContext, useEffect } from 'react'
|
||||
import { Theme, useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
@@ -21,30 +21,32 @@ 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)
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const setTheme = useSettingsStore.use.setTheme()
|
||||
|
||||
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
|
||||
}
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
useSettingsStore.getState().setTheme(theme)
|
||||
setTheme(theme)
|
||||
}
|
||||
setTheme
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -13,15 +13,24 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
|
||||
* 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)
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
gotoNode(node)
|
||||
} else {
|
||||
// If no node is selected but move is true, reset to default view
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false)
|
||||
} else if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
}
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import Graph from 'graphology'
|
||||
// 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 { EdgeType, NodeType } from '@/hooks/useLightragGraph'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import * as Constants from '@/lib/constants'
|
||||
|
||||
@@ -21,7 +22,6 @@ const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
|
||||
}
|
||||
|
||||
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
|
||||
const { lightrageGraph } = useLightragGraph()
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
@@ -34,21 +34,25 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
|
||||
const { theme } = useTheme()
|
||||
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
|
||||
const renderLabels = useSettingsStore.use.showNodeLabel()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
|
||||
/**
|
||||
* When component mount or maxIterations changes
|
||||
* => load the graph and apply layout
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Create & load the graph
|
||||
const graph = lightrageGraph()
|
||||
loadGraph(graph)
|
||||
assignLayout()
|
||||
}, [assignLayout, loadGraph, lightrageGraph, maxIterations])
|
||||
if (sigmaGraph) {
|
||||
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
|
||||
assignLayout()
|
||||
}
|
||||
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
|
||||
|
||||
/**
|
||||
* When component mount
|
||||
@@ -58,39 +62,52 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
|
||||
useGraphStore.getState()
|
||||
|
||||
// Register the events
|
||||
registerEvents({
|
||||
enterNode: (event) => {
|
||||
// Define event types
|
||||
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
|
||||
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
|
||||
|
||||
// Register all events, but edge events will only be processed if enableEdgeEvents is true
|
||||
const events: Record<string, any> = {
|
||||
enterNode: (event: NodeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(event.node)
|
||||
}
|
||||
},
|
||||
leaveNode: (event) => {
|
||||
leaveNode: (event: NodeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(null)
|
||||
}
|
||||
},
|
||||
clickNode: (event) => {
|
||||
clickNode: (event: NodeEvent) => {
|
||||
setSelectedNode(event.node)
|
||||
setSelectedEdge(null)
|
||||
},
|
||||
clickEdge: (event) => {
|
||||
clickStage: () => clearSelection()
|
||||
}
|
||||
|
||||
// Only add edge event handlers if enableEdgeEvents is true
|
||||
if (enableEdgeEvents) {
|
||||
events.clickEdge = (event: EdgeEvent) => {
|
||||
setSelectedEdge(event.edge)
|
||||
setSelectedNode(null)
|
||||
},
|
||||
enterEdge: (event) => {
|
||||
}
|
||||
|
||||
events.enterEdge = (event: EdgeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(event.edge)
|
||||
}
|
||||
},
|
||||
leaveEdge: (event) => {
|
||||
}
|
||||
|
||||
events.leaveEdge = (event: EdgeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(null)
|
||||
}
|
||||
},
|
||||
clickStage: () => clearSelection()
|
||||
})
|
||||
}, [registerEvents])
|
||||
}
|
||||
}
|
||||
|
||||
// Register the events
|
||||
registerEvents(events)
|
||||
}, [registerEvents, enableEdgeEvents])
|
||||
|
||||
/**
|
||||
* When component mount or hovered node change
|
||||
@@ -101,7 +118,14 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
|
||||
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
|
||||
|
||||
// Update all dynamic settings directly without recreating the sigma container
|
||||
setSettings({
|
||||
// Update display settings
|
||||
enableEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels,
|
||||
|
||||
// Node reducer for node appearance
|
||||
nodeReducer: (node, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData: NodeType & {
|
||||
@@ -140,6 +164,8 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
}
|
||||
return newData
|
||||
},
|
||||
|
||||
// Edge reducer for edge appearance
|
||||
edgeReducer: (edge, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
|
||||
@@ -181,7 +207,10 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
sigma,
|
||||
disableHoverEffect,
|
||||
theme,
|
||||
hideUnselectedEdges
|
||||
hideUnselectedEdges,
|
||||
enableEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels
|
||||
])
|
||||
|
||||
return null
|
||||
|
@@ -1,37 +1,48 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||
import { getGraphLabels } from '@/api/lightrag'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { labelListLimit } from '@/lib/constants'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const lastGraph: any = {
|
||||
graph: null,
|
||||
searchEngine: null,
|
||||
labels: []
|
||||
}
|
||||
|
||||
const GraphLabels = () => {
|
||||
const { t } = useTranslation()
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
|
||||
const labelsLoadedRef = useRef(false)
|
||||
|
||||
const getSearchEngine = useCallback(async () => {
|
||||
if (lastGraph.graph == graph) {
|
||||
return {
|
||||
labels: lastGraph.labels,
|
||||
searchEngine: lastGraph.searchEngine
|
||||
}
|
||||
}
|
||||
const labels = ['*'].concat(await getGraphLabels())
|
||||
|
||||
// Ensure query label exists
|
||||
if (!labels.includes(useSettingsStore.getState().queryLabel)) {
|
||||
useSettingsStore.getState().setQueryLabel(labels[0])
|
||||
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
||||
const fetchInProgressRef = useRef(false)
|
||||
|
||||
// Fetch labels once on component mount, using global flag to prevent duplicates
|
||||
useEffect(() => {
|
||||
// Check if we've already attempted to fetch labels in this session
|
||||
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
|
||||
|
||||
// Only fetch if we haven't attempted in this session and no fetch is in progress
|
||||
if (!labelsFetchAttempted && !fetchInProgressRef.current) {
|
||||
fetchInProgressRef.current = true
|
||||
// Set global flag to indicate we've attempted to fetch in this session
|
||||
useGraphStore.getState().setLabelsFetchAttempted(true)
|
||||
|
||||
console.log('Fetching graph labels (once per session)...')
|
||||
|
||||
useGraphStore.getState().fetchAllDatabaseLabels()
|
||||
.then(() => {
|
||||
labelsLoadedRef.current = true
|
||||
fetchInProgressRef.current = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch labels:', error)
|
||||
fetchInProgressRef.current = false
|
||||
// Reset global flag to allow retry
|
||||
useGraphStore.getState().setLabelsFetchAttempted(false)
|
||||
})
|
||||
}
|
||||
}, []) // Empty dependency array ensures this only runs once on mount
|
||||
|
||||
const getSearchEngine = useCallback(() => {
|
||||
// Create search engine
|
||||
const searchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
@@ -46,41 +57,32 @@ const GraphLabels = () => {
|
||||
})
|
||||
|
||||
// Add documents
|
||||
const documents = labels.map((str, index) => ({ id: index, value: str }))
|
||||
const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
|
||||
searchEngine.addAll(documents)
|
||||
|
||||
lastGraph.graph = graph
|
||||
lastGraph.searchEngine = searchEngine
|
||||
lastGraph.labels = labels
|
||||
|
||||
return {
|
||||
labels,
|
||||
labels: allDatabaseLabels,
|
||||
searchEngine
|
||||
}
|
||||
}, [graph])
|
||||
}, [allDatabaseLabels])
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (query?: string): Promise<string[]> => {
|
||||
const { labels, searchEngine } = await getSearchEngine()
|
||||
const { labels, searchEngine } = getSearchEngine()
|
||||
|
||||
let result: string[] = labels
|
||||
if (query) {
|
||||
// Search labels
|
||||
result = searchEngine.search(query).map((r) => labels[r.id])
|
||||
result = searchEngine.search(query).map((r: { id: number }) => labels[r.id])
|
||||
}
|
||||
|
||||
return result.length <= labelListLimit
|
||||
? result
|
||||
: [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })]
|
||||
: [...result.slice(0, labelListLimit), '...']
|
||||
},
|
||||
[getSearchEngine]
|
||||
)
|
||||
|
||||
const setQueryLabel = useCallback((label: string) => {
|
||||
if (label.startsWith('And ') && label.endsWith(' others')) return
|
||||
useSettingsStore.getState().setQueryLabel(label)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
@@ -94,8 +96,38 @@ const GraphLabels = () => {
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
value={label !== null ? label : ''}
|
||||
onChange={setQueryLabel}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*'
|
||||
}
|
||||
|
||||
// Reset the fetch attempted flag to force a new data fetch
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
|
||||
// Clear current graph data to ensure complete reload when label changes
|
||||
if (newLabel !== currentLabel) {
|
||||
const graphStore = useGraphStore.getState();
|
||||
graphStore.clearSelection();
|
||||
|
||||
// Reset the graph state but preserve the instance
|
||||
if (graphStore.sigmaGraph) {
|
||||
const nodes = Array.from(graphStore.sigmaGraph.nodes());
|
||||
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
|
||||
}
|
||||
}
|
||||
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
// reselect the same itme means qery all
|
||||
useSettingsStore.getState().setQueryLabel('*')
|
||||
} else {
|
||||
useSettingsStore.getState().setQueryLabel(newLabel)
|
||||
}
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { FC, useCallback, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
NodeById,
|
||||
@@ -28,6 +28,7 @@ function OptionComponent(item: OptionItem) {
|
||||
}
|
||||
|
||||
const messageId = '__message_item'
|
||||
// Reset this cache when graph changes to ensure fresh search results
|
||||
const lastGraph: any = {
|
||||
graph: null,
|
||||
searchEngine: null
|
||||
@@ -48,6 +49,15 @@ export const GraphSearchInput = ({
|
||||
const { t } = useTranslation()
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
|
||||
// Force reset the cache when graph changes
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
// Reset cache to ensure fresh search results with new graph data
|
||||
lastGraph.graph = null;
|
||||
lastGraph.searchEngine = null;
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
if (lastGraph.graph == graph) {
|
||||
return lastGraph.searchEngine
|
||||
@@ -85,8 +95,19 @@ export const GraphSearchInput = ({
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
if (!query || !searchEngine) return []
|
||||
const result: OptionItem[] = searchEngine.search(query).map((r) => ({
|
||||
if (!graph || !searchEngine) return []
|
||||
|
||||
// If no query, return first searchResultLimit nodes
|
||||
if (!query) {
|
||||
const nodeIds = graph.nodes().slice(0, searchResultLimit)
|
||||
return nodeIds.map(id => ({
|
||||
id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
}
|
||||
|
||||
// If has query, search nodes
|
||||
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
|
||||
id: r.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
@@ -103,7 +124,7 @@ export const GraphSearchInput = ({
|
||||
}
|
||||
]
|
||||
},
|
||||
[searchEngine, onFocus]
|
||||
[graph, searchEngine, onFocus, t]
|
||||
)
|
||||
|
||||
return (
|
||||
|
@@ -96,9 +96,9 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: isTarget ? 'Target' : 'Source',
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.labels.join(', ')
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -132,14 +132,22 @@ const PropertyRow = ({
|
||||
onClick?: () => void
|
||||
tooltip?: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getPropertyNameTranslation = (name: string) => {
|
||||
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
|
||||
const translation = t(translationKey)
|
||||
return translation === translationKey ? name : translation
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-primary/60 tracking-wide">{name}</label>:
|
||||
<label className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</label>:
|
||||
<Text
|
||||
className="hover:bg-primary/20 rounded p-1 text-ellipsis"
|
||||
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
|
||||
tooltipClassName="max-w-80"
|
||||
text={value}
|
||||
tooltip={tooltip || value}
|
||||
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
|
||||
side="left"
|
||||
onClick={onClick}
|
||||
/>
|
||||
@@ -174,7 +182,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
|
||||
{t('graphPanel.propertiesView.node.relationships')}
|
||||
{t('graphPanel.propertiesView.node.relationships')}
|
||||
</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{node.relationships.map(({ type, id, label }) => {
|
||||
|
@@ -8,9 +8,10 @@ import Input from '@/components/ui/Input'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Component that displays a checkbox with a label.
|
||||
@@ -114,6 +115,7 @@ const LabeledNumberInput = ({
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const [tempApiKey, setTempApiKey] = useState<string>('')
|
||||
const refreshLayout = useGraphStore.use.refreshLayout()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
@@ -208,116 +210,126 @@ export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip={t("graphPanel.sideBar.settings.settings")} size="icon">
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="mb-2 p-2"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
<>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
|
||||
size="icon"
|
||||
onClick={refreshLayout}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LabeledCheckBox
|
||||
checked={enableHealthCheck}
|
||||
onCheckedChange={setEnableHealthCheck}
|
||||
label={t("graphPanel.sideBar.settings.healthCheck")}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showPropertyPanel}
|
||||
onCheckedChange={setShowPropertyPanel}
|
||||
label={t("graphPanel.sideBar.settings.showPropertyPanel")}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={showNodeSearchBar}
|
||||
onCheckedChange={setShowNodeSearchBar}
|
||||
label={t("graphPanel.sideBar.settings.showSearchBar")}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showNodeLabel}
|
||||
onCheckedChange={setShowNodeLabel}
|
||||
label={t("graphPanel.sideBar.settings.showNodeLabel")}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableNodeDrag}
|
||||
onCheckedChange={setEnableNodeDrag}
|
||||
label={t("graphPanel.sideBar.settings.nodeDraggable")}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showEdgeLabel}
|
||||
onCheckedChange={setShowEdgeLabel}
|
||||
label={t("graphPanel.sideBar.settings.showEdgeLabel")}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableHideUnselectedEdges}
|
||||
onCheckedChange={setEnableHideUnselectedEdges}
|
||||
label={t("graphPanel.sideBar.settings.hideUnselectedEdges")}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableEdgeEvents}
|
||||
onCheckedChange={setEnableEdgeEvents}
|
||||
label={t("graphPanel.sideBar.settings.edgeEvents")}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<LabeledNumberInput
|
||||
label={t("graphPanel.sideBar.settings.maxQueryDepth")}
|
||||
min={1}
|
||||
value={graphQueryMaxDepth}
|
||||
onEditFinished={setGraphQueryMaxDepth}
|
||||
/>
|
||||
<LabeledNumberInput
|
||||
label={t("graphPanel.sideBar.settings.minDegree")}
|
||||
min={0}
|
||||
value={graphMinDegree}
|
||||
onEditFinished={setGraphMinDegree}
|
||||
/>
|
||||
<LabeledNumberInput
|
||||
label={t("graphPanel.sideBar.settings.maxLayoutIterations")}
|
||||
min={1}
|
||||
max={20}
|
||||
value={graphLayoutMaxIterations}
|
||||
onEditFinished={setGraphLayoutMaxIterations}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<RefreshCwIcon />
|
||||
</Button>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.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">
|
||||
<label className="text-sm font-medium">{t("graphPanel.sideBar.settings.apiKey")}</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={t("graphPanel.sideBar.settings.enterYourAPIkey")}
|
||||
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"
|
||||
>
|
||||
{t("graphPanel.sideBar.settings.save")}
|
||||
</Button>
|
||||
</form>
|
||||
<LabeledCheckBox
|
||||
checked={enableHealthCheck}
|
||||
onCheckedChange={setEnableHealthCheck}
|
||||
label={t('graphPanel.sideBar.settings.healthCheck')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showPropertyPanel}
|
||||
onCheckedChange={setShowPropertyPanel}
|
||||
label={t('graphPanel.sideBar.settings.showPropertyPanel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={showNodeSearchBar}
|
||||
onCheckedChange={setShowNodeSearchBar}
|
||||
label={t('graphPanel.sideBar.settings.showSearchBar')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showNodeLabel}
|
||||
onCheckedChange={setShowNodeLabel}
|
||||
label={t('graphPanel.sideBar.settings.showNodeLabel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableNodeDrag}
|
||||
onCheckedChange={setEnableNodeDrag}
|
||||
label={t('graphPanel.sideBar.settings.nodeDraggable')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showEdgeLabel}
|
||||
onCheckedChange={setShowEdgeLabel}
|
||||
label={t('graphPanel.sideBar.settings.showEdgeLabel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableHideUnselectedEdges}
|
||||
onCheckedChange={setEnableHideUnselectedEdges}
|
||||
label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableEdgeEvents}
|
||||
onCheckedChange={setEnableEdgeEvents}
|
||||
label={t('graphPanel.sideBar.settings.edgeEvents')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<LabeledNumberInput
|
||||
label={t('graphPanel.sideBar.settings.maxQueryDepth')}
|
||||
min={1}
|
||||
value={graphQueryMaxDepth}
|
||||
onEditFinished={setGraphQueryMaxDepth}
|
||||
/>
|
||||
<LabeledNumberInput
|
||||
label={t('graphPanel.sideBar.settings.minDegree')}
|
||||
min={0}
|
||||
value={graphMinDegree}
|
||||
onEditFinished={setGraphMinDegree}
|
||||
/>
|
||||
<LabeledNumberInput
|
||||
label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
|
||||
min={1}
|
||||
max={30}
|
||||
value={graphLayoutMaxIterations}
|
||||
onEditFinished={setGraphLayoutMaxIterations}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</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={t('graphPanel.sideBar.settings.enterYourAPIkey')}
|
||||
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"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
21
lightrag_webui/src/components/graph/SettingsDisplay.tsx
Normal file
21
lightrag_webui/src/components/graph/SettingsDisplay.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Component that displays current values of important graph settings
|
||||
* Positioned to the right of the toolbar at the bottom-left corner
|
||||
*/
|
||||
const SettingsDisplay = () => {
|
||||
const { t } = useTranslation()
|
||||
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
|
||||
const graphMinDegree = useSettingsStore.use.graphMinDegree()
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
||||
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsDisplay
|
@@ -25,7 +25,7 @@ export default function QuerySettings() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="flex shrink-0 flex-col">
|
||||
<Card className="flex shrink-0 flex-col min-w-[180px]">
|
||||
<CardHeader className="px-4 pt-4 pb-2">
|
||||
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
|
||||
<CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
@@ -193,7 +193,7 @@ export function AsyncSearch<T>({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList hidden={!open || debouncedSearchTerm.length === 0}>
|
||||
<CommandList hidden={!open}>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
@@ -204,7 +204,7 @@ export function AsyncSearch<T>({
|
||||
))}
|
||||
<CommandGroup>
|
||||
{options.map((option, idx) => (
|
||||
<>
|
||||
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
|
||||
<CommandItem
|
||||
key={getOptionValue(option) + `${idx}`}
|
||||
value={getOptionValue(option)}
|
||||
@@ -215,9 +215,9 @@ export function AsyncSearch<T>({
|
||||
{renderOption(option)}
|
||||
</CommandItem>
|
||||
{idx !== options.length - 1 && (
|
||||
<div key={idx} className="bg-foreground/10 h-[1px]" />
|
||||
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
|
||||
)}
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
37
lightrag_webui/src/components/ui/TabContent.tsx
Normal file
37
lightrag_webui/src/components/ui/TabContent.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility';
|
||||
|
||||
interface TabContentProps {
|
||||
tabId: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabContent component that manages visibility based on tab selection
|
||||
* Works with the TabVisibilityContext to show/hide content based on active tab
|
||||
*/
|
||||
const TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {
|
||||
const { isTabVisible, setTabVisibility } = useTabVisibility();
|
||||
const isVisible = isTabVisible(tabId);
|
||||
|
||||
// Register this tab with the context when mounted
|
||||
useEffect(() => {
|
||||
setTabVisibility(tabId, true);
|
||||
|
||||
// Cleanup when unmounted
|
||||
return () => {
|
||||
setTabVisibility(tabId, false);
|
||||
};
|
||||
}, [tabId, setTabVisibility]);
|
||||
|
||||
// Use CSS to hide content instead of not rendering it
|
||||
// This prevents components from unmounting when tabs are switched
|
||||
return (
|
||||
<div className={`${className} ${isVisible ? '' : 'hidden'}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabContent;
|
@@ -42,9 +42,13 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'data-[state=inactive]:invisible data-[state=active]:visible',
|
||||
'h-full w-full',
|
||||
className
|
||||
)}
|
||||
// Force mounting of inactive tabs to preserve WebGL contexts
|
||||
forceMount
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
@@ -10,30 +10,43 @@ const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const processTooltipContent = (content: string) => {
|
||||
if (typeof content !== 'string') return content
|
||||
return content.split('\\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < content.split('\\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))
|
||||
return (
|
||||
<div className="relative top-0 pt-1 whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, children, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1 max-w-sm overflow-hidden rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === 'string' ? processTooltipContent(children) : children}
|
||||
</TooltipPrimitive.Content>
|
||||
))
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
side={side}
|
||||
align={align}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === 'string' ? processTooltipContent(children) : children}
|
||||
</TooltipPrimitive.Content>
|
||||
);
|
||||
})
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
Reference in New Issue
Block a user