Merge branch 'improve-property-tooltip' into loginPage
This commit is contained in:
@@ -63,6 +63,7 @@
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-i18next": "^8.1.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
@@ -446,6 +447,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="],
|
||||
|
||||
"@types/react-i18next": ["@types/react-i18next@8.1.0", "", { "dependencies": { "react-i18next": "*" } }, "sha512-d4xhcjX5b3roNMObRNMfb1HinHQlQLPo8xlDj60dnHeeAw2bBymR2cy/l1giJpHzo/ZFgSvgVUvIWr4kCrenCg=="],
|
||||
|
||||
"@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
|
||||
|
||||
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
|
||||
|
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="icon" type="image/svg+xml" href="logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag</title>
|
||||
|
@@ -72,6 +72,7 @@
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-i18next": "^8.1.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
|
||||
import MessageAlert from '@/components/MessageAlert'
|
||||
import ApiKeyAlert from '@/components/ApiKeyAlert'
|
||||
import StatusIndicator from '@/components/graph/StatusIndicator'
|
||||
@@ -19,7 +21,7 @@ import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
||||
function App() {
|
||||
const message = useBackendState.use.message()
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const [currentTab] = useState(() => useSettingsStore.getState().currentTab)
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const [apiKeyInvalid, setApiKeyInvalid] = useState(false)
|
||||
|
||||
// Health check
|
||||
@@ -51,32 +53,36 @@ function App() {
|
||||
}, [message, setApiKeyInvalid])
|
||||
|
||||
return (
|
||||
<main className="flex h-screen w-screen overflow-x-hidden">
|
||||
<Tabs
|
||||
defaultValue={currentTab}
|
||||
className="!m-0 flex grow flex-col !p-0"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<SiteHeader />
|
||||
<div className="relative grow">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<RetrievalTesting />
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<ApiSite />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
||||
{apiKeyInvalid && <ApiKeyAlert />}
|
||||
</main>
|
||||
<ThemeProvider>
|
||||
<TabVisibilityProvider>
|
||||
<main className="flex h-screen w-screen overflow-x-hidden">
|
||||
<Tabs
|
||||
defaultValue={currentTab}
|
||||
className="!m-0 flex grow flex-col !p-0"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<SiteHeader />
|
||||
<div className="relative grow">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<RetrievalTesting />
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<ApiSite />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
||||
{apiKeyInvalid && <ApiKeyAlert />}
|
||||
</main>
|
||||
</TabVisibilityProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
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 }
|
||||
|
53
lightrag_webui/src/contexts/TabVisibilityProvider.tsx
Normal file
53
lightrag_webui/src/contexts/TabVisibilityProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { TabVisibilityContext } from './context';
|
||||
import { TabVisibilityContextType } from './types';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
interface TabVisibilityProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component for the TabVisibility context
|
||||
* Manages the visibility state of tabs throughout the application
|
||||
*/
|
||||
export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ children }) => {
|
||||
// Get current tab from settings store
|
||||
const currentTab = useSettingsStore.use.currentTab();
|
||||
|
||||
// Initialize visibility state with current tab as visible
|
||||
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
||||
[currentTab]: true
|
||||
}));
|
||||
|
||||
// Update visibility when current tab changes
|
||||
useEffect(() => {
|
||||
setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
[currentTab]: true
|
||||
}));
|
||||
}, [currentTab]);
|
||||
|
||||
// Create the context value with memoization to prevent unnecessary re-renders
|
||||
const contextValue = useMemo<TabVisibilityContextType>(
|
||||
() => ({
|
||||
visibleTabs,
|
||||
setTabVisibility: (tabId: string, isVisible: boolean) => {
|
||||
setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
[tabId]: isVisible,
|
||||
}));
|
||||
},
|
||||
isTabVisible: (tabId: string) => !!visibleTabs[tabId],
|
||||
}),
|
||||
[visibleTabs]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabVisibilityContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TabVisibilityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabVisibilityProvider;
|
12
lightrag_webui/src/contexts/context.ts
Normal file
12
lightrag_webui/src/contexts/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
import { TabVisibilityContextType } from './types';
|
||||
|
||||
// Default context value
|
||||
const defaultContext: TabVisibilityContextType = {
|
||||
visibleTabs: {},
|
||||
setTabVisibility: () => {},
|
||||
isTabVisible: () => false,
|
||||
};
|
||||
|
||||
// Create the context
|
||||
export const TabVisibilityContext = createContext<TabVisibilityContextType>(defaultContext);
|
5
lightrag_webui/src/contexts/types.ts
Normal file
5
lightrag_webui/src/contexts/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface TabVisibilityContextType {
|
||||
visibleTabs: Record<string, boolean>;
|
||||
setTabVisibility: (tabId: string, isVisible: boolean) => void;
|
||||
isTabVisible: (tabId: string) => boolean;
|
||||
}
|
17
lightrag_webui/src/contexts/useTabVisibility.ts
Normal file
17
lightrag_webui/src/contexts/useTabVisibility.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContext } from 'react';
|
||||
import { TabVisibilityContext } from './context';
|
||||
import { TabVisibilityContextType } from './types';
|
||||
|
||||
/**
|
||||
* Custom hook to access the tab visibility context
|
||||
* @returns The tab visibility context
|
||||
*/
|
||||
export const useTabVisibility = (): TabVisibilityContextType => {
|
||||
const context = useContext(TabVisibilityContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTabVisibility must be used within a TabVisibilityProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
@@ -1,5 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ApiSite() {
|
||||
return <iframe src={backendBaseUrl + '/docs'} className="size-full" />
|
||||
const { t } = useTranslation()
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isApiTabVisible = isTabVisible('api')
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
|
||||
// Load the iframe once on component mount
|
||||
useEffect(() => {
|
||||
if (!iframeLoaded) {
|
||||
setIframeLoaded(true)
|
||||
}
|
||||
}, [iframeLoaded])
|
||||
|
||||
// Use CSS to hide content when tab is not visible
|
||||
return (
|
||||
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
|
||||
{iframeLoaded ? (
|
||||
<iframe
|
||||
src={backendBaseUrl + '/docs'}
|
||||
className="size-full w-full h-full"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
// Use key to ensure iframe doesn't reload
|
||||
key="api-docs-iframe"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p>{t('apiSite.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Table,
|
||||
@@ -26,6 +27,9 @@ export default function DocumentManager() {
|
||||
const { t } = useTranslation()
|
||||
const health = useBackendState.use.health()
|
||||
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isDocumentsTabVisible = isTabVisible('documents')
|
||||
const initialLoadRef = useRef(false)
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -48,11 +52,15 @@ export default function DocumentManager() {
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }))
|
||||
}
|
||||
}, [setDocs])
|
||||
}, [setDocs, t])
|
||||
|
||||
// Only fetch documents when the tab becomes visible for the first time
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (isDocumentsTabVisible && !initialLoadRef.current) {
|
||||
fetchDocuments()
|
||||
initialLoadRef.current = true
|
||||
}
|
||||
}, [isDocumentsTabVisible, fetchDocuments])
|
||||
|
||||
const scanDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -61,21 +69,24 @@ export default function DocumentManager() {
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }))
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
// Only set up polling when the tab is visible and health is good
|
||||
useEffect(() => {
|
||||
if (!isDocumentsTabVisible || !health) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (!health) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetchDocuments()
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) }))
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [health, fetchDocuments])
|
||||
}, [health, fetchDocuments, t, isDocumentsTabVisible])
|
||||
|
||||
return (
|
||||
<Card className="!size-full !rounded-none !border-none">
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
@@ -17,6 +18,7 @@ import Settings from '@/components/graph/Settings'
|
||||
import GraphSearch from '@/components/graph/GraphSearch'
|
||||
import GraphLabels from '@/components/graph/GraphLabels'
|
||||
import PropertiesView from '@/components/graph/PropertiesView'
|
||||
import SettingsDisplay from '@/components/graph/SettingsDisplay'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
@@ -90,8 +92,12 @@ const GraphEvents = () => {
|
||||
}
|
||||
},
|
||||
// Disable the autoscale at the first down interaction
|
||||
mousedown: () => {
|
||||
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox())
|
||||
mousedown: (e) => {
|
||||
// Only set custom BBox if it's a drag operation (mouse button is pressed)
|
||||
const mouseEvent = e.original as MouseEvent;
|
||||
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
|
||||
sigma.setCustomBBox(sigma.getBBox())
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [registerEvents, sigma, draggedNode])
|
||||
@@ -101,27 +107,46 @@ const GraphEvents = () => {
|
||||
|
||||
const GraphViewer = () => {
|
||||
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
||||
const sigmaRef = useRef<any>(null)
|
||||
const initAttemptedRef = useRef(false)
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
const shouldRender = useGraphStore.use.shouldRender() // Rendering control state
|
||||
|
||||
// Get tab visibility
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const renderLabels = useSettingsStore.use.showNodeLabel()
|
||||
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
|
||||
|
||||
// Handle component mount/unmount and tab visibility
|
||||
useEffect(() => {
|
||||
setSigmaSettings({
|
||||
...defaultSigmaSettings,
|
||||
enableEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels
|
||||
})
|
||||
}, [renderLabels, enableEdgeEvents, renderEdgeLabels])
|
||||
// When component mounts or tab becomes visible
|
||||
if (isGraphTabVisible && !shouldRender && !isFetching && !initAttemptedRef.current) {
|
||||
// If tab is visible but graph is not rendering, try to enable rendering
|
||||
useGraphStore.getState().setShouldRender(true)
|
||||
initAttemptedRef.current = true
|
||||
console.log('Graph viewer initialized')
|
||||
}
|
||||
|
||||
// Cleanup function when component unmounts
|
||||
return () => {
|
||||
// Only log cleanup, don't actually clean up the WebGL context
|
||||
// This allows the WebGL context to persist across tab switches
|
||||
console.log('Graph viewer cleanup')
|
||||
}
|
||||
}, [isGraphTabVisible, shouldRender, isFetching])
|
||||
|
||||
// Initialize sigma settings once on component mount
|
||||
// All dynamic settings will be updated in GraphControl using useSetSettings
|
||||
useEffect(() => {
|
||||
setSigmaSettings(defaultSigmaSettings)
|
||||
}, [])
|
||||
|
||||
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
||||
@@ -142,43 +167,73 @@ const GraphViewer = () => {
|
||||
[selectedNode]
|
||||
)
|
||||
|
||||
// Since TabsContent now forces mounting of all tabs, we need to conditionally render
|
||||
// the SigmaContainer based on visibility to avoid unnecessary rendering
|
||||
return (
|
||||
<SigmaContainer settings={sigmaSettings} className="!bg-background !size-full overflow-hidden">
|
||||
<GraphControl />
|
||||
<div className="relative h-full w-full">
|
||||
{/* Only render the SigmaContainer when the tab is visible */}
|
||||
{isGraphTabVisible ? (
|
||||
<SigmaContainer
|
||||
settings={sigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<Settings />
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<Settings />
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
) : (
|
||||
// Placeholder when tab is not visible
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{/* Placeholder content */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
</SigmaContainer>
|
||||
{/* Loading overlay - shown when data is loading */}
|
||||
{isFetching && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p>Loading Graph Data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SiteInfo } from '@/lib/constants'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
import LanguageToggle from '@/components/LanguageToggle'
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
@@ -77,23 +77,15 @@ export default function SiteHeader() {
|
||||
<TabsNavigation />
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
|
||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||
<GithubIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
tooltip="Log Out"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<nav className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
|
||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||
<GithubIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<AppSettings />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import Graph, { DirectedGraph } from 'graphology'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { randomColor, errorMessage } from '@/lib/utils'
|
||||
import * as Constants from '@/lib/constants'
|
||||
import { useGraphStore, RawGraph } from '@/stores/graph'
|
||||
import { queryGraphs } from '@/api/lightrag'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
|
||||
import seedrandom from 'seedrandom'
|
||||
|
||||
@@ -136,15 +137,23 @@ const fetchGraph = async (label: string, maxDepth: number, minDegree: number) =>
|
||||
return rawGraph
|
||||
}
|
||||
|
||||
// Create a new graph instance with the raw graph data
|
||||
const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
// Always create a new graph instance
|
||||
const graph = new DirectedGraph()
|
||||
|
||||
// Add nodes from raw graph data
|
||||
for (const rawNode of rawGraph?.nodes ?? []) {
|
||||
// Ensure we have fresh random positions for nodes
|
||||
seedrandom(rawNode.id + Date.now().toString(), { global: true })
|
||||
const x = Math.random()
|
||||
const y = Math.random()
|
||||
|
||||
graph.addNode(rawNode.id, {
|
||||
label: rawNode.labels.join(', '),
|
||||
color: rawNode.color,
|
||||
x: rawNode.x,
|
||||
y: rawNode.y,
|
||||
x: x,
|
||||
y: y,
|
||||
size: rawNode.size,
|
||||
// for node-border
|
||||
borderColor: Constants.nodeBorderColor,
|
||||
@@ -152,6 +161,7 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Add edges from raw graph data
|
||||
for (const rawEdge of rawGraph?.edges ?? []) {
|
||||
rawEdge.dynamicId = graph.addDirectedEdge(rawEdge.source, rawEdge.target, {
|
||||
label: rawEdge.type || undefined
|
||||
@@ -161,14 +171,30 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
return graph
|
||||
}
|
||||
|
||||
const lastQueryLabel = { label: '', maxQueryDepth: 0, minDegree: 0 }
|
||||
|
||||
const useLightrangeGraph = () => {
|
||||
const queryLabel = useSettingsStore.use.queryLabel()
|
||||
const rawGraph = useGraphStore.use.rawGraph()
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
|
||||
const minDegree = useSettingsStore.use.graphMinDegree()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
|
||||
// Get tab visibility
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
||||
|
||||
// Track previous parameters to detect actual changes
|
||||
const prevParamsRef = useRef({ queryLabel, maxQueryDepth, minDegree })
|
||||
|
||||
// Use ref to track if data has been loaded and initial load
|
||||
const dataLoadedRef = useRef(false)
|
||||
const initialLoadRef = useRef(false)
|
||||
|
||||
// Check if parameters have changed
|
||||
const paramsChanged =
|
||||
prevParamsRef.current.queryLabel !== queryLabel ||
|
||||
prevParamsRef.current.maxQueryDepth !== maxQueryDepth ||
|
||||
prevParamsRef.current.minDegree !== minDegree
|
||||
|
||||
const getNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
@@ -184,35 +210,131 @@ const useLightrangeGraph = () => {
|
||||
[rawGraph]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLabel) {
|
||||
if (lastQueryLabel.label !== queryLabel ||
|
||||
lastQueryLabel.maxQueryDepth !== maxQueryDepth ||
|
||||
lastQueryLabel.minDegree !== minDegree) {
|
||||
lastQueryLabel.label = queryLabel
|
||||
lastQueryLabel.maxQueryDepth = maxQueryDepth
|
||||
lastQueryLabel.minDegree = minDegree
|
||||
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
||||
const fetchInProgressRef = useRef(false)
|
||||
|
||||
// Data fetching logic - simplified but preserving TAB visibility check
|
||||
useEffect(() => {
|
||||
// Skip if fetch is already in progress
|
||||
if (fetchInProgressRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// If there's no query label, reset the graph
|
||||
if (!queryLabel) {
|
||||
if (rawGraph !== null || sigmaGraph !== null) {
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
fetchGraph(queryLabel, maxQueryDepth, minDegree).then((data) => {
|
||||
// console.debug('Query label: ' + queryLabel)
|
||||
state.setSigmaGraph(createSigmaGraph(data))
|
||||
data?.buildDynamicMap()
|
||||
state.setRawGraph(data)
|
||||
state.setGraphDataFetchAttempted(false)
|
||||
state.setLabelsFetchAttempted(false)
|
||||
}
|
||||
dataLoadedRef.current = false
|
||||
initialLoadRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parameters have changed
|
||||
if (!isFetching && !fetchInProgressRef.current &&
|
||||
(paramsChanged || !useGraphStore.getState().graphDataFetchAttempted)) {
|
||||
|
||||
// Only fetch data if the Graph tab is visible
|
||||
if (!isGraphTabVisible) {
|
||||
console.log('Graph tab not visible, skipping data fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flags
|
||||
fetchInProgressRef.current = true
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(true)
|
||||
|
||||
const state = useGraphStore.getState()
|
||||
state.setIsFetching(true)
|
||||
state.setShouldRender(false) // Disable rendering during data loading
|
||||
|
||||
// Clear selection and highlighted nodes before fetching new graph
|
||||
state.clearSelection()
|
||||
if (state.sigmaGraph) {
|
||||
state.sigmaGraph.forEachNode((node) => {
|
||||
state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
state.setSigmaGraph(new DirectedGraph())
|
||||
|
||||
// Update parameter reference
|
||||
prevParamsRef.current = { queryLabel, maxQueryDepth, minDegree }
|
||||
|
||||
console.log('Fetching graph data...')
|
||||
|
||||
// Use a local copy of the parameters
|
||||
const currentQueryLabel = queryLabel
|
||||
const currentMaxQueryDepth = maxQueryDepth
|
||||
const currentMinDegree = minDegree
|
||||
|
||||
// Fetch graph data
|
||||
fetchGraph(currentQueryLabel, currentMaxQueryDepth, currentMinDegree).then((data) => {
|
||||
const state = useGraphStore.getState()
|
||||
|
||||
// Reset state
|
||||
state.reset()
|
||||
|
||||
// Create and set new graph directly
|
||||
const newSigmaGraph = createSigmaGraph(data)
|
||||
data?.buildDynamicMap()
|
||||
|
||||
// Set new graph data
|
||||
state.setSigmaGraph(newSigmaGraph)
|
||||
state.setRawGraph(data)
|
||||
|
||||
// No longer need to extract labels from graph data
|
||||
|
||||
// Update flags
|
||||
dataLoadedRef.current = true
|
||||
initialLoadRef.current = true
|
||||
fetchInProgressRef.current = false
|
||||
|
||||
// Reset camera view
|
||||
state.setMoveToSelectedNode(true)
|
||||
|
||||
// Enable rendering if the tab is visible
|
||||
state.setShouldRender(isGraphTabVisible)
|
||||
state.setIsFetching(false)
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching graph data:', error)
|
||||
|
||||
// Reset state on error
|
||||
const state = useGraphStore.getState()
|
||||
state.setIsFetching(false)
|
||||
state.setShouldRender(isGraphTabVisible)
|
||||
dataLoadedRef.current = false
|
||||
fetchInProgressRef.current = false
|
||||
state.setGraphDataFetchAttempted(false)
|
||||
})
|
||||
}
|
||||
}, [queryLabel, maxQueryDepth, minDegree])
|
||||
}, [queryLabel, maxQueryDepth, minDegree, isFetching, paramsChanged, isGraphTabVisible, rawGraph, sigmaGraph])
|
||||
|
||||
// Update rendering state and handle tab visibility changes
|
||||
useEffect(() => {
|
||||
// When tab becomes visible
|
||||
if (isGraphTabVisible) {
|
||||
// If we have data, enable rendering
|
||||
if (rawGraph) {
|
||||
useGraphStore.getState().setShouldRender(true)
|
||||
}
|
||||
|
||||
// We no longer reset the fetch attempted flag here to prevent continuous API calls
|
||||
} else {
|
||||
// When tab becomes invisible, disable rendering
|
||||
useGraphStore.getState().setShouldRender(false)
|
||||
}
|
||||
}, [isGraphTabVisible, rawGraph])
|
||||
|
||||
const lightrageGraph = useCallback(() => {
|
||||
// If we already have a graph instance, return it
|
||||
if (sigmaGraph) {
|
||||
return sigmaGraph as Graph<NodeType, EdgeType>
|
||||
}
|
||||
|
||||
// If no graph exists yet, create a new one and store it
|
||||
console.log('Creating new Sigma graph instance')
|
||||
const graph = new DirectedGraph()
|
||||
useGraphStore.getState().setSigmaGraph(graph)
|
||||
return graph as Graph<NodeType, EdgeType>
|
||||
|
37
lightrag_webui/src/i18n.ts
Normal file
37
lightrag_webui/src/i18n.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
// Function to sync i18n with store state
|
||||
export const initializeI18n = async (): Promise<typeof i18n> => {
|
||||
// Get initial language from store
|
||||
const initialLanguage = useSettingsStore.getState().language
|
||||
|
||||
// Initialize with store language
|
||||
await i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh }
|
||||
},
|
||||
lng: initialLanguage,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to language changes
|
||||
useSettingsStore.subscribe((state) => {
|
||||
const currentLanguage = state.language
|
||||
if (i18n.language !== currentLanguage) {
|
||||
i18n.changeLanguage(currentLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
return i18n
|
||||
}
|
||||
|
||||
export default i18n
|
@@ -1,7 +1,7 @@
|
||||
import { ButtonVariantType } from '@/components/ui/Button'
|
||||
|
||||
export const backendBaseUrl = 'http://localhost:9621/'
|
||||
export const webuiPrefix = '/webui'
|
||||
export const webuiPrefix = ''
|
||||
|
||||
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||
|
||||
@@ -16,8 +16,8 @@ export const edgeColorDarkTheme = '#969696'
|
||||
export const edgeColorSelected = '#F57F17'
|
||||
export const edgeColorHighlighted = '#B2EBF2'
|
||||
|
||||
export const searchResultLimit = 20
|
||||
export const labelListLimit = 40
|
||||
export const searchResultLimit = 50
|
||||
export const labelListLimit = 100
|
||||
|
||||
export const minNodeSize = 4
|
||||
export const maxNodeSize = 20
|
||||
|
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"settings": {
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"header": {
|
||||
"documents": "Documents",
|
||||
"knowledgeGraph": "Knowledge Graph",
|
||||
@@ -91,9 +98,12 @@
|
||||
"maxQueryDepth": "Max Query Depth",
|
||||
"minDegree": "Minimum Degree",
|
||||
"maxLayoutIterations": "Max Layout Iterations",
|
||||
"depth": "Depth",
|
||||
"degree": "Degree",
|
||||
"apiKey": "API Key",
|
||||
"enterYourAPIkey": "Enter your API key",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"refreshLayout": "Refresh Layout"
|
||||
},
|
||||
|
||||
"zoomControl": {
|
||||
@@ -152,7 +162,14 @@
|
||||
"labels": "Labels",
|
||||
"degree": "Degree",
|
||||
"properties": "Properties",
|
||||
"relationships": "Relationships"
|
||||
"relationships": "Relationships",
|
||||
"propertyNames": {
|
||||
"description": "Description",
|
||||
"entity_id": "Name",
|
||||
"entity_type": "Type",
|
||||
"source_id": "SrcID",
|
||||
"Neighbour": "Neigh"
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"title": "Relationship",
|
||||
@@ -242,5 +259,8 @@
|
||||
"streamResponse": "Stream Response",
|
||||
"streamResponseTooltip": "If True, enables streaming output for real-time responses"
|
||||
}
|
||||
},
|
||||
"apiSite": {
|
||||
"loading": "Loading API Documentation..."
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"settings": {
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统"
|
||||
},
|
||||
"header": {
|
||||
"documents": "文档",
|
||||
"knowledgeGraph": "知识图谱",
|
||||
@@ -6,8 +13,8 @@
|
||||
"api": "API",
|
||||
"projectRepository": "项目仓库",
|
||||
"themeToggle": {
|
||||
"switchToLight": "切换到亮色主题",
|
||||
"switchToDark": "切换到暗色主题"
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -24,35 +31,35 @@
|
||||
},
|
||||
"documentPanel": {
|
||||
"clearDocuments": {
|
||||
"button": "清除",
|
||||
"tooltip": "清除文档",
|
||||
"title": "清除文档",
|
||||
"confirm": "您确定要清除所有文档吗?",
|
||||
"button": "清空",
|
||||
"tooltip": "清空文档",
|
||||
"title": "清空文档",
|
||||
"confirm": "确定要清空所有文档吗?",
|
||||
"confirmButton": "确定",
|
||||
"success": "文档已成功清除",
|
||||
"failed": "清除文档失败:\n{{message}}",
|
||||
"error": "清除文档失败:\n{{error}}"
|
||||
"success": "文档清空成功",
|
||||
"failed": "清空文档失败:\n{{message}}",
|
||||
"error": "清空文档失败:\n{{error}}"
|
||||
},
|
||||
"uploadDocuments": {
|
||||
"button": "上传",
|
||||
"tooltip": "上传文档",
|
||||
"title": "上传文档",
|
||||
"description": "拖放文档到此处或点击浏览。",
|
||||
"uploading": "正在上传 {{name}}: {{percent}}%",
|
||||
"success": "上传成功:\n{{name}} 上传成功",
|
||||
"failed": "上传失败:\n{{name}}\n{{message}}",
|
||||
"error": "上传失败:\n{{name}}\n{{error}}",
|
||||
"description": "拖拽文件到此处或点击浏览",
|
||||
"uploading": "正在上传 {{name}}:{{percent}}%",
|
||||
"success": "上传成功:\n{{name}} 上传完成",
|
||||
"failed": "上传失败:\n{{name}}\n{{message}}",
|
||||
"error": "上传失败:\n{{name}}\n{{error}}",
|
||||
"generalError": "上传失败\n{{error}}",
|
||||
"fileTypes": "支持的文件类型: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
|
||||
"fileTypes": "支持的文件类型:TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
|
||||
},
|
||||
"documentManager": {
|
||||
"title": "文档管理",
|
||||
"scanButton": "扫描",
|
||||
"scanTooltip": "扫描文档",
|
||||
"uploadedTitle": "已上传文档",
|
||||
"uploadedDescription": "已上传文档及其状态列表。",
|
||||
"emptyTitle": "暂无文档",
|
||||
"emptyDescription": "尚未上传任何文档。",
|
||||
"uploadedDescription": "已上传文档列表及其状态",
|
||||
"emptyTitle": "无文档",
|
||||
"emptyDescription": "还没有上传任何文档",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"summary": "摘要",
|
||||
@@ -66,7 +73,7 @@
|
||||
"status": {
|
||||
"completed": "已完成",
|
||||
"processing": "处理中",
|
||||
"pending": "待处理",
|
||||
"pending": "等待中",
|
||||
"failed": "失败"
|
||||
},
|
||||
"errors": {
|
||||
@@ -86,39 +93,39 @@
|
||||
"showNodeLabel": "显示节点标签",
|
||||
"nodeDraggable": "节点可拖动",
|
||||
"showEdgeLabel": "显示边标签",
|
||||
"hideUnselectedEdges": "隐藏未选中边",
|
||||
"hideUnselectedEdges": "隐藏未选中的边",
|
||||
"edgeEvents": "边事件",
|
||||
"maxQueryDepth": "最大查询深度",
|
||||
"minDegree": "最小度数",
|
||||
"maxLayoutIterations": "最大布局迭代次数",
|
||||
"apiKey": "API 密钥",
|
||||
"enterYourAPIkey": "输入您的 API 密钥",
|
||||
"save": "保存"
|
||||
"depth": "深度",
|
||||
"degree": "邻边",
|
||||
"apiKey": "API密钥",
|
||||
"enterYourAPIkey": "输入您的API密钥",
|
||||
"save": "保存",
|
||||
"refreshLayout": "刷新布局"
|
||||
},
|
||||
|
||||
"zoomControl": {
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"resetZoom": "重置缩放"
|
||||
},
|
||||
|
||||
"layoutsControl": {
|
||||
"startAnimation": "开始布局动画",
|
||||
"stopAnimation": "停止布局动画",
|
||||
"layoutGraph": "布局图",
|
||||
"layoutGraph": "图布局",
|
||||
"layouts": {
|
||||
"Circular": "环形布局",
|
||||
"Circlepack": "圆形打包布局",
|
||||
"Random": "随机布局",
|
||||
"Noverlaps": "无重叠布局",
|
||||
"Force Directed": "力导向布局",
|
||||
"Force Atlas": "力导向图谱布局"
|
||||
"Circular": "环形",
|
||||
"Circlepack": "圆形打包",
|
||||
"Random": "随机",
|
||||
"Noverlaps": "无重叠",
|
||||
"Force Directed": "力导向",
|
||||
"Force Atlas": "力图"
|
||||
}
|
||||
},
|
||||
|
||||
"fullScreenControl": {
|
||||
"fullScreen": "全屏",
|
||||
"windowed": "窗口模式"
|
||||
"windowed": "窗口"
|
||||
}
|
||||
},
|
||||
"statusIndicator": {
|
||||
@@ -130,17 +137,17 @@
|
||||
"storageInfo": "存储信息",
|
||||
"workingDirectory": "工作目录",
|
||||
"inputDirectory": "输入目录",
|
||||
"llmConfig": "LLM 配置",
|
||||
"llmBinding": "LLM 绑定",
|
||||
"llmBindingHost": "LLM 绑定主机",
|
||||
"llmModel": "LLM 模型",
|
||||
"maxTokens": "最大 Token 数",
|
||||
"llmConfig": "LLM配置",
|
||||
"llmBinding": "LLM绑定",
|
||||
"llmBindingHost": "LLM绑定主机",
|
||||
"llmModel": "LLM模型",
|
||||
"maxTokens": "最大令牌数",
|
||||
"embeddingConfig": "嵌入配置",
|
||||
"embeddingBinding": "嵌入绑定",
|
||||
"embeddingBindingHost": "嵌入绑定主机",
|
||||
"embeddingModel": "嵌入模型",
|
||||
"storageConfig": "存储配置",
|
||||
"kvStorage": "KV 存储",
|
||||
"kvStorage": "KV存储",
|
||||
"docStatusStorage": "文档状态存储",
|
||||
"graphStorage": "图存储",
|
||||
"vectorStorage": "向量存储"
|
||||
@@ -152,96 +159,93 @@
|
||||
"labels": "标签",
|
||||
"degree": "度数",
|
||||
"properties": "属性",
|
||||
"relationships": "关系"
|
||||
"relationships": "关系",
|
||||
"propertyNames": {
|
||||
"description": "描述",
|
||||
"entity_id": "名称",
|
||||
"entity_type": "类型",
|
||||
"source_id": "信源ID",
|
||||
"Neighbour": "邻接"
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"title": "关系",
|
||||
"id": "ID",
|
||||
"type": "类型",
|
||||
"source": "源",
|
||||
"target": "目标",
|
||||
"source": "源节点",
|
||||
"target": "目标节点",
|
||||
"properties": "属性"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索节点...",
|
||||
"message": "以及其它 {count} 项"
|
||||
"message": "还有 {count} 个"
|
||||
},
|
||||
"graphLabels": {
|
||||
"selectTooltip": "选择查询标签",
|
||||
"noLabels": "未找到标签",
|
||||
"label": "标签",
|
||||
"placeholder": "搜索标签...",
|
||||
"andOthers": "以及其它 {count} 个"
|
||||
"andOthers": "还有 {count} 个"
|
||||
}
|
||||
},
|
||||
"retrievePanel": {
|
||||
"chatMessage": {
|
||||
"copyTooltip": "复制到剪贴板",
|
||||
"copyError": "无法复制文本到剪贴板"
|
||||
"copyError": "复制文本到剪贴板失败"
|
||||
},
|
||||
|
||||
"retrieval": {
|
||||
"startPrompt": "在下面输入您的查询以开始检索",
|
||||
"clear": "清除",
|
||||
"startPrompt": "输入查询开始检索",
|
||||
"clear": "清空",
|
||||
"send": "发送",
|
||||
"placeholder": "输入您的查询...",
|
||||
"error": "错误:无法获取响应"
|
||||
"placeholder": "输入查询...",
|
||||
"error": "错误:获取响应失败"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "参数设置",
|
||||
"parametersTitle": "参数",
|
||||
"parametersDescription": "配置查询参数",
|
||||
|
||||
"queryMode": "查询模式",
|
||||
"queryModeTooltip": "选择检索策略:\n• 朴素:不使用高级技术的基本搜索\n• 本地:基于上下文的信息检索\n• 全局:利用全局知识库\n• 混合:结合本地和全局检索\n• 综合:集成知识图谱与向量检索",
|
||||
"queryModeTooltip": "选择检索策略:\n• Naive:基础搜索,无高级技术\n• Local:上下文相关信息检索\n• Global:利用全局知识库\n• Hybrid:结合本地和全局检索\n• Mix:整合知识图谱和向量检索",
|
||||
"queryModeOptions": {
|
||||
"naive": "朴素",
|
||||
"local": "本地",
|
||||
"global": "全局",
|
||||
"hybrid": "混合",
|
||||
"mix": "综合"
|
||||
"mix": "混合"
|
||||
},
|
||||
|
||||
"responseFormat": "响应格式",
|
||||
"responseFormatTooltip": "定义响应格式。例如:\n• 多个段落\n• 单个段落\n• 项目符号",
|
||||
"responseFormatTooltip": "定义响应格式。例如:\n• 多段落\n• 单段落\n• 要点",
|
||||
"responseFormatOptions": {
|
||||
"multipleParagraphs": "多个段落",
|
||||
"singleParagraph": "单个段落",
|
||||
"bulletPoints": "项目符号"
|
||||
"multipleParagraphs": "多段落",
|
||||
"singleParagraph": "单段落",
|
||||
"bulletPoints": "要点"
|
||||
},
|
||||
|
||||
"topK": "Top K 结果数",
|
||||
"topKTooltip": "要检索的前 K 个项目数量。在“本地”模式下表示实体,在“全局”模式下表示关系",
|
||||
"topKPlaceholder": "结果数",
|
||||
|
||||
"maxTokensTextUnit": "文本单元最大 Token 数",
|
||||
"maxTokensTextUnitTooltip": "每个检索到的文本块允许的最大 Token 数",
|
||||
|
||||
"maxTokensGlobalContext": "全局上下文最大 Token 数",
|
||||
"maxTokensGlobalContextTooltip": "在全局检索中为关系描述分配的最大 Token 数",
|
||||
|
||||
"maxTokensLocalContext": "本地上下文最大 Token 数",
|
||||
"maxTokensLocalContextTooltip": "在本地检索中为实体描述分配的最大 Token 数",
|
||||
|
||||
"topK": "Top K结果",
|
||||
"topKTooltip": "检索的顶部项目数。在'local'模式下表示实体,在'global'模式下表示关系",
|
||||
"topKPlaceholder": "结果数量",
|
||||
"maxTokensTextUnit": "文本单元最大令牌数",
|
||||
"maxTokensTextUnitTooltip": "每个检索文本块允许的最大令牌数",
|
||||
"maxTokensGlobalContext": "全局上下文最大令牌数",
|
||||
"maxTokensGlobalContextTooltip": "全局检索中关系描述的最大令牌数",
|
||||
"maxTokensLocalContext": "本地上下文最大令牌数",
|
||||
"maxTokensLocalContextTooltip": "本地检索中实体描述的最大令牌数",
|
||||
"historyTurns": "历史轮次",
|
||||
"historyTurnsTooltip": "在响应上下文中考虑的完整对话轮次(用户-助手对)",
|
||||
"historyTurnsPlaceholder": "历史轮次的数量",
|
||||
|
||||
"historyTurnsTooltip": "响应上下文中考虑的完整对话轮次(用户-助手对)数量",
|
||||
"historyTurnsPlaceholder": "历史轮次数",
|
||||
"hlKeywords": "高级关键词",
|
||||
"hlKeywordsTooltip": "检索时优先考虑的高级关键词。请用逗号分隔",
|
||||
"hlKeywordsTooltip": "检索中优先考虑的高级关键词列表。用逗号分隔",
|
||||
"hlkeywordsPlaceHolder": "输入关键词",
|
||||
|
||||
"llKeywords": "低级关键词",
|
||||
"llKeywordsTooltip": "用于优化检索焦点的低级关键词。请用逗号分隔",
|
||||
|
||||
"onlyNeedContext": "仅需要上下文",
|
||||
"onlyNeedContextTooltip": "如果为 True,则仅返回检索到的上下文,而不会生成回复",
|
||||
|
||||
"onlyNeedPrompt": "仅需要提示",
|
||||
"onlyNeedPromptTooltip": "如果为 True,则仅返回生成的提示,而不会生成回复",
|
||||
|
||||
"llKeywordsTooltip": "用于细化检索重点的低级关键词列表。用逗号分隔",
|
||||
"onlyNeedContext": "仅需上下文",
|
||||
"onlyNeedContextTooltip": "如果为True,仅返回检索到的上下文而不生成响应",
|
||||
"onlyNeedPrompt": "仅需提示",
|
||||
"onlyNeedPromptTooltip": "如果为True,仅返回生成的提示而不产生响应",
|
||||
"streamResponse": "流式响应",
|
||||
"streamResponseTooltip": "如果为 True,则启用流式输出以获得实时响应"
|
||||
"streamResponseTooltip": "如果为True,启用实时流式输出响应"
|
||||
}
|
||||
},
|
||||
"apiSite": {
|
||||
"loading": "正在加载 API 文档..."
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import AppRouter from './AppRouter'
|
||||
import "./i18n";
|
||||
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppRouter />
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { DirectedGraph } from 'graphology'
|
||||
import { getGraphLabels } from '@/api/lightrag'
|
||||
|
||||
export type RawNodeType = {
|
||||
id: string
|
||||
@@ -65,9 +66,17 @@ interface GraphState {
|
||||
|
||||
rawGraph: RawGraph | null
|
||||
sigmaGraph: DirectedGraph | null
|
||||
allDatabaseLabels: string[]
|
||||
|
||||
moveToSelectedNode: boolean
|
||||
isFetching: boolean
|
||||
shouldRender: boolean
|
||||
|
||||
// Global flags to track data fetching attempts
|
||||
graphDataFetchAttempted: boolean
|
||||
labelsFetchAttempted: boolean
|
||||
|
||||
refreshLayout: () => void
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
|
||||
setFocusedNode: (nodeId: string | null) => void
|
||||
setSelectedEdge: (edgeId: string | null) => void
|
||||
@@ -79,19 +88,47 @@ interface GraphState {
|
||||
|
||||
setRawGraph: (rawGraph: RawGraph | null) => void
|
||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
||||
setAllDatabaseLabels: (labels: string[]) => void
|
||||
fetchAllDatabaseLabels: () => Promise<void>
|
||||
setIsFetching: (isFetching: boolean) => void
|
||||
setShouldRender: (shouldRender: boolean) => void
|
||||
|
||||
// Methods to set global flags
|
||||
setGraphDataFetchAttempted: (attempted: boolean) => void
|
||||
setLabelsFetchAttempted: (attempted: boolean) => void
|
||||
}
|
||||
|
||||
const useGraphStoreBase = create<GraphState>()((set) => ({
|
||||
const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null,
|
||||
|
||||
moveToSelectedNode: false,
|
||||
isFetching: false,
|
||||
shouldRender: false,
|
||||
|
||||
// Initialize global flags
|
||||
graphDataFetchAttempted: false,
|
||||
labelsFetchAttempted: false,
|
||||
|
||||
rawGraph: null,
|
||||
sigmaGraph: null,
|
||||
allDatabaseLabels: ['*'],
|
||||
|
||||
refreshLayout: () => {
|
||||
const currentGraph = get().sigmaGraph;
|
||||
if (currentGraph) {
|
||||
get().clearSelection();
|
||||
get().setSigmaGraph(null);
|
||||
setTimeout(() => {
|
||||
get().setSigmaGraph(currentGraph);
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
|
||||
setIsFetching: (isFetching: boolean) => set({ isFetching }),
|
||||
setShouldRender: (shouldRender: boolean) => set({ shouldRender }),
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
|
||||
set({ selectedNode: nodeId, moveToSelectedNode }),
|
||||
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
|
||||
@@ -104,25 +141,58 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
|
||||
selectedEdge: null,
|
||||
focusedEdge: null
|
||||
}),
|
||||
reset: () =>
|
||||
reset: () => {
|
||||
// Get the existing graph
|
||||
const existingGraph = get().sigmaGraph;
|
||||
|
||||
// If we have an existing graph, clear it by removing all nodes
|
||||
if (existingGraph) {
|
||||
const nodes = Array.from(existingGraph.nodes());
|
||||
nodes.forEach(node => existingGraph.dropNode(node));
|
||||
}
|
||||
|
||||
set({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null,
|
||||
rawGraph: null,
|
||||
sigmaGraph: null,
|
||||
moveToSelectedNode: false
|
||||
}),
|
||||
// Keep the existing graph instance but with cleared data
|
||||
moveToSelectedNode: false,
|
||||
shouldRender: false
|
||||
});
|
||||
},
|
||||
|
||||
setRawGraph: (rawGraph: RawGraph | null) =>
|
||||
set({
|
||||
rawGraph
|
||||
}),
|
||||
|
||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => set({ sigmaGraph }),
|
||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => {
|
||||
// Replace graph instance, no need to keep WebGL context
|
||||
set({ sigmaGraph });
|
||||
},
|
||||
|
||||
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode })
|
||||
setAllDatabaseLabels: (labels: string[]) => set({ allDatabaseLabels: labels }),
|
||||
|
||||
fetchAllDatabaseLabels: async () => {
|
||||
try {
|
||||
console.log('Fetching all database labels...');
|
||||
const labels = await getGraphLabels();
|
||||
set({ allDatabaseLabels: ['*', ...labels] });
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all database labels:', error);
|
||||
set({ allDatabaseLabels: ['*'] });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
|
||||
|
||||
// Methods to set global flags
|
||||
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
|
||||
setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted })
|
||||
}))
|
||||
|
||||
const useGraphStore = createSelectors(useGraphStoreBase)
|
||||
|
@@ -5,6 +5,7 @@ import { defaultQueryLabel } from '@/lib/constants'
|
||||
import { Message, QueryRequest } from '@/api/lightrag'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
type Language = 'en' | 'zh'
|
||||
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
|
||||
type Language = 'en' | 'zh'
|
||||
|
||||
@@ -48,7 +49,7 @@ interface SettingsState {
|
||||
setTheme: (theme: Theme) => void
|
||||
|
||||
language: Language
|
||||
setLanguage: (language: Language) => void
|
||||
setLanguage: (lang: Language) => void
|
||||
|
||||
enableHealthCheck: boolean
|
||||
setEnableHealthCheck: (enable: boolean) => void
|
||||
@@ -62,7 +63,6 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
language: 'en',
|
||||
|
||||
showPropertyPanel: true,
|
||||
showNodeSearchBar: true,
|
||||
|
||||
@@ -75,7 +75,7 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||
|
||||
graphQueryMaxDepth: 3,
|
||||
graphMinDegree: 0,
|
||||
graphLayoutMaxIterations: 10,
|
||||
graphLayoutMaxIterations: 15,
|
||||
|
||||
queryLabel: defaultQueryLabel,
|
||||
|
||||
@@ -104,7 +104,15 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||
|
||||
setTheme: (theme: Theme) => set({ theme }),
|
||||
|
||||
setLanguage: (language: Language) => set({ language }),
|
||||
setLanguage: (language: Language) => {
|
||||
set({ language })
|
||||
// Update i18n after state is updated
|
||||
import('i18next').then(({ default: i18n }) => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setGraphLayoutMaxIterations: (iterations: number) =>
|
||||
set({
|
||||
@@ -136,7 +144,7 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||
{
|
||||
name: 'settings-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 7,
|
||||
version: 8,
|
||||
migrate: (state: any, version: number) => {
|
||||
if (version < 2) {
|
||||
state.showEdgeLabel = false
|
||||
@@ -173,7 +181,11 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||
}
|
||||
if (version < 7) {
|
||||
state.graphQueryMaxDepth = 3
|
||||
state.graphLayoutMaxIterations = 10
|
||||
state.graphLayoutMaxIterations = 15
|
||||
}
|
||||
if (version < 8) {
|
||||
state.graphMinDegree = 0
|
||||
state.language = 'en'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
@@ -27,7 +27,9 @@ export default defineConfig({
|
||||
target: import.meta.env.VITE_BACKEND_URL || 'http://localhost:9621',
|
||||
changeOrigin: true,
|
||||
rewrite: endpoint === '/api' ?
|
||||
(path) => path.replace(/^\/api/, '') : undefined
|
||||
(path) => path.replace(/^\/api/, '') :
|
||||
endpoint === '/docs' || endpoint === '/openapi.json' ?
|
||||
(path) => path : undefined
|
||||
}
|
||||
])
|
||||
) : {}
|
||||
|
Reference in New Issue
Block a user