diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx
index 90330d1a29e8e1a3cc32988f367af6b9095ae5d9..c0b130fe4a14a3f9e0f0b47273f66150d0300ca5 100644
--- a/lightrag_webui/src/features/SiteHeader.tsx
+++ b/lightrag_webui/src/features/SiteHeader.tsx
@@ -1,12 +1,13 @@
import Button from '@/components/ui/Button'
-import { SiteInfo } from '@/lib/constants'
+import { SiteInfo, webuiPrefix } from '@/lib/constants'
import AppSettings from '@/components/AppSettings'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings'
+import { useAuthStore } from '@/stores/state'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
-
-import { ZapIcon, GithubIcon } from 'lucide-react'
+import { navigationService } from '@/services/navigation'
+import { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react'
interface NavigationTabProps {
value: string
@@ -54,9 +55,15 @@ function TabsNavigation() {
export default function SiteHeader() {
const { t } = useTranslation()
+ const { isGuestMode } = useAuthStore()
+
+ const handleLogout = () => {
+ navigationService.navigateToLogin();
+ }
+
return (
diff --git a/lightrag_webui/src/hooks/useLightragGraph.tsx b/lightrag_webui/src/hooks/useLightragGraph.tsx
index efed5bac316df2ae0c0eec86d330ab43cdda993d..f83d396d8f16a7d11f16c5053b0447d8e3ca1918 100644
--- a/lightrag_webui/src/hooks/useLightragGraph.tsx
+++ b/lightrag_webui/src/hooks/useLightragGraph.tsx
@@ -1,12 +1,13 @@
import Graph, { DirectedGraph } from 'graphology'
import { useCallback, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
import { randomColor, errorMessage } from '@/lib/utils'
import * as Constants from '@/lib/constants'
-import { useGraphStore, RawGraph } from '@/stores/graph'
+import { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph'
+import { toast } from 'sonner'
import { queryGraphs } from '@/api/lightrag'
import { useBackendState } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings'
-import { useTabVisibility } from '@/contexts/useTabVisibility'
import seedrandom from 'seedrandom'
@@ -139,7 +140,13 @@ const fetchGraph = async (label: string, maxDepth: number, minDegree: number) =>
// Create a new graph instance with the raw graph data
const createSigmaGraph = (rawGraph: RawGraph | null) => {
- // Always create a new graph instance
+ // Skip graph creation if no data or empty nodes
+ if (!rawGraph || !rawGraph.nodes.length) {
+ console.log('No graph data available, skipping sigma graph creation');
+ return null;
+ }
+
+ // Create new graph instance
const graph = new DirectedGraph()
// Add nodes from raw graph data
@@ -172,30 +179,20 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
}
const useLightrangeGraph = () => {
+ const { t } = useTranslation()
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 })
+ const nodeToExpand = useGraphStore.use.nodeToExpand()
+ const nodeToPrune = useGraphStore.use.nodeToPrune()
// 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) => {
return rawGraph?.getNode(nodeId) || null
@@ -213,43 +210,33 @@ const useLightrangeGraph = () => {
// 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
+ // Reset graph when query label is cleared
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()
- state.setGraphDataFetchAttempted(false)
- state.setLabelsFetchAttempted(false)
- }
+ if (!queryLabel && (rawGraph !== null || sigmaGraph !== null)) {
+ const state = useGraphStore.getState()
+ state.reset()
+ state.setGraphDataFetchAttempted(false)
+ state.setLabelsFetchAttempted(false)
dataLoadedRef.current = false
initialLoadRef.current = false
- return
}
+ }, [queryLabel, rawGraph, sigmaGraph])
- // 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;
- }
+ // Data fetching logic
+ useEffect(() => {
+ // Skip if fetch is already in progress or no query label
+ if (fetchInProgressRef.current || !queryLabel) {
+ return
+ }
+ // Only fetch data when graphDataFetchAttempted is false (avoids re-fetching on vite dev mode)
+ if (!isFetching && !useGraphStore.getState().graphDataFetchAttempted) {
// 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()
@@ -259,9 +246,6 @@ const useLightrangeGraph = () => {
})
}
- // Update parameter reference
- prevParamsRef.current = { queryLabel, maxQueryDepth, minDegree }
-
console.log('Fetching graph data...')
// Use a local copy of the parameters
@@ -284,8 +268,6 @@ const useLightrangeGraph = () => {
state.setSigmaGraph(newSigmaGraph)
state.setRawGraph(data)
- // No longer need to extract labels from graph data
-
// Update flags
dataLoadedRef.current = true
initialLoadRef.current = true
@@ -294,8 +276,6 @@ const useLightrangeGraph = () => {
// 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)
@@ -303,29 +283,425 @@ const useLightrangeGraph = () => {
// 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, isFetching, paramsChanged, isGraphTabVisible, rawGraph, sigmaGraph])
+ }, [queryLabel, maxQueryDepth, minDegree, isFetching])
- // Update rendering state and handle tab visibility changes
+ // Handle node expansion
useEffect(() => {
- // When tab becomes visible
- if (isGraphTabVisible) {
- // If we have data, enable rendering
- if (rawGraph) {
- useGraphStore.getState().setShouldRender(true)
+ const handleNodeExpand = async (nodeId: string | null) => {
+ if (!nodeId || !sigmaGraph || !rawGraph) return;
+
+ try {
+ // Get the node to expand
+ const nodeToExpand = rawGraph.getNode(nodeId);
+ if (!nodeToExpand) {
+ console.error('Node not found:', nodeId);
+ return;
+ }
+
+ // Get the label of the node to expand
+ const label = nodeToExpand.labels[0];
+ if (!label) {
+ console.error('Node has no label:', nodeId);
+ return;
+ }
+
+ // Fetch the extended subgraph with depth 2
+ const extendedGraph = await queryGraphs(label, 2, 0);
+
+ if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
+ console.error('Failed to fetch extended graph');
+ return;
+ }
+
+ // Process nodes to add required properties for RawNodeType
+ const processedNodes: RawNodeType[] = [];
+ for (const node of extendedGraph.nodes) {
+ // Generate random color values
+ seedrandom(node.id, { global: true });
+ const color = randomColor();
+
+ // Create a properly typed RawNodeType
+ processedNodes.push({
+ id: node.id,
+ labels: node.labels,
+ properties: node.properties,
+ size: 10, // Default size, will be calculated later
+ x: Math.random(), // Random position, will be adjusted later
+ y: Math.random(), // Random position, will be adjusted later
+ color: color, // Random color
+ degree: 0 // Initial degree, will be calculated later
+ });
+ }
+
+ // Process edges to add required properties for RawEdgeType
+ const processedEdges: RawEdgeType[] = [];
+ for (const edge of extendedGraph.edges) {
+ // Create a properly typed RawEdgeType
+ processedEdges.push({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ type: edge.type,
+ properties: edge.properties,
+ dynamicId: '' // Will be set when adding to sigma graph
+ });
+ }
+
+ // Store current node positions
+ const nodePositions: Record
= {};
+ sigmaGraph.forEachNode((node) => {
+ nodePositions[node] = {
+ x: sigmaGraph.getNodeAttribute(node, 'x'),
+ y: sigmaGraph.getNodeAttribute(node, 'y')
+ };
+ });
+
+ // Get existing node IDs
+ const existingNodeIds = new Set(sigmaGraph.nodes());
+
+ // Identify nodes and edges to keep
+ const nodesToAdd = new Set();
+ const edgesToAdd = new Set();
+
+ // Get degree range from existing graph for size calculations
+ const minDegree = 1;
+ let maxDegree = 0;
+ sigmaGraph.forEachNode(node => {
+ const degree = sigmaGraph.degree(node);
+ maxDegree = Math.max(maxDegree, degree);
+ });
+
+ // Calculate size formula parameters
+ const range = maxDegree - minDegree || 1; // Avoid division by zero
+ const scale = Constants.maxNodeSize - Constants.minNodeSize;
+
+ // First identify connectable nodes (nodes connected to the expanded node)
+ for (const node of processedNodes) {
+ // Skip if node already exists
+ if (existingNodeIds.has(node.id)) {
+ continue;
+ }
+
+ // Check if this node is connected to the selected node
+ const isConnected = processedEdges.some(
+ edge => (edge.source === nodeId && edge.target === node.id) ||
+ (edge.target === nodeId && edge.source === node.id)
+ );
+
+ if (isConnected) {
+ nodesToAdd.add(node.id);
+ }
+ }
+
+ // Calculate node degrees and track discarded edges in one pass
+ const nodeDegrees = new Map();
+ const nodesWithDiscardedEdges = new Set();
+
+ for (const edge of processedEdges) {
+ const sourceExists = existingNodeIds.has(edge.source) || nodesToAdd.has(edge.source);
+ const targetExists = existingNodeIds.has(edge.target) || nodesToAdd.has(edge.target);
+
+ if (sourceExists && targetExists) {
+ edgesToAdd.add(edge.id);
+ // Add degrees for valid edges
+ if (nodesToAdd.has(edge.source)) {
+ nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1);
+ }
+ if (nodesToAdd.has(edge.target)) {
+ nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1);
+ }
+ } else {
+ // Track discarded edges for both new and existing nodes
+ if (sigmaGraph.hasNode(edge.source)) {
+ nodesWithDiscardedEdges.add(edge.source);
+ } else if (nodesToAdd.has(edge.source)) {
+ nodesWithDiscardedEdges.add(edge.source);
+ nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); // +1 for discarded edge
+ }
+ if (sigmaGraph.hasNode(edge.target)) {
+ nodesWithDiscardedEdges.add(edge.target);
+ } else if (nodesToAdd.has(edge.target)) {
+ nodesWithDiscardedEdges.add(edge.target);
+ nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); // +1 for discarded edge
+ }
+ }
+ }
+
+ // Helper function to update node sizes
+ const updateNodeSizes = (
+ sigmaGraph: DirectedGraph,
+ nodesWithDiscardedEdges: Set,
+ minDegree: number,
+ range: number,
+ scale: number
+ ) => {
+ for (const nodeId of nodesWithDiscardedEdges) {
+ if (sigmaGraph.hasNode(nodeId)) {
+ let newDegree = sigmaGraph.degree(nodeId);
+ newDegree += 1; // Add +1 for discarded edges
+
+ const newSize = Math.round(
+ Constants.minNodeSize + scale * Math.pow((newDegree - minDegree) / range, 0.5)
+ );
+
+ const currentSize = sigmaGraph.getNodeAttribute(nodeId, 'size');
+
+ if (newSize > currentSize) {
+ sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);
+ }
+ }
+ }
+ };
+
+ // If no new connectable nodes found, show toast and return
+ if (nodesToAdd.size === 0) {
+ updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, range, scale);
+ toast.info(t('graphPanel.propertiesView.node.noNewNodes'));
+ return;
+ }
+
+ // Update maxDegree with new node degrees
+ for (const [, degree] of nodeDegrees.entries()) {
+ maxDegree = Math.max(maxDegree, degree);
+ }
+
+ // SAdd nodes and edges to the graph
+ // Calculate camera ratio and spread factor once before the loop
+ const cameraRatio = useGraphStore.getState().sigmaInstance?.getCamera().ratio || 1;
+ const spreadFactor = Math.max(
+ Math.sqrt(nodeToExpand.size) * 4, // Base on node size
+ Math.sqrt(nodesToAdd.size) * 3 // Scale with number of nodes
+ ) / cameraRatio; // Adjust for zoom level
+ seedrandom(Date.now().toString(), { global: true });
+ const randomAngle = Math.random() * 2 * Math.PI
+
+ console.log('nodeSize:', nodeToExpand.size, 'nodesToAdd:', nodesToAdd.size);
+ console.log('cameraRatio:', Math.round(cameraRatio*100)/100, 'spreadFactor:', Math.round(spreadFactor*100)/100);
+
+ // Add new nodes
+ for (const nodeId of nodesToAdd) {
+ const newNode = processedNodes.find(n => n.id === nodeId)!;
+ const nodeDegree = nodeDegrees.get(nodeId) || 0;
+
+ // Calculate node size
+ const nodeSize = Math.round(
+ Constants.minNodeSize + scale * Math.pow((nodeDegree - minDegree) / range, 0.5)
+ );
+
+ // Calculate angle for polar coordinates
+ const angle = 2 * Math.PI * (Array.from(nodesToAdd).indexOf(nodeId) / nodesToAdd.size);
+
+ // Calculate final position
+ const x = nodePositions[nodeId]?.x ||
+ (nodePositions[nodeToExpand.id].x + Math.cos(randomAngle + angle) * spreadFactor);
+ const y = nodePositions[nodeId]?.y ||
+ (nodePositions[nodeToExpand.id].y + Math.sin(randomAngle + angle) * spreadFactor);
+
+ // Add the new node to the sigma graph with calculated position
+ sigmaGraph.addNode(nodeId, {
+ label: newNode.labels.join(', '),
+ color: newNode.color,
+ x: x,
+ y: y,
+ size: nodeSize,
+ borderColor: Constants.nodeBorderColor,
+ borderSize: 0.2
+ });
+
+ // Add the node to the raw graph
+ if (!rawGraph.getNode(nodeId)) {
+ // Update node properties
+ newNode.size = nodeSize;
+ newNode.x = x;
+ newNode.y = y;
+ newNode.degree = nodeDegree;
+
+ // Add to nodes array
+ rawGraph.nodes.push(newNode);
+ // Update nodeIdMap
+ rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
+ }
+ }
+
+ // Add new edges
+ for (const edgeId of edgesToAdd) {
+ const newEdge = processedEdges.find(e => e.id === edgeId)!;
+
+ // Skip if edge already exists
+ if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {
+ continue;
+ }
+ if (sigmaGraph.hasEdge(newEdge.target, newEdge.source)) {
+ continue;
+ }
+
+ // Add the edge to the sigma graph
+ newEdge.dynamicId = sigmaGraph.addDirectedEdge(newEdge.source, newEdge.target, {
+ label: newEdge.type || undefined
+ });
+
+ // Add the edge to the raw graph
+ if (!rawGraph.getEdge(newEdge.id, false)) {
+ // Add to edges array
+ rawGraph.edges.push(newEdge);
+ // Update edgeIdMap
+ rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1;
+ // Update dynamic edge map
+ rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1;
+ } else {
+ console.error('Edge already exists in rawGraph:', newEdge.id);
+ }
+ }
+
+ // Update the dynamic edge map and invalidate search cache
+ rawGraph.buildDynamicMap();
+
+ // Reset search engine to force rebuild
+ useGraphStore.getState().resetSearchEngine();
+
+ // Update sizes for all nodes with discarded edges
+ updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, range, scale);
+
+ } catch (error) {
+ console.error('Error expanding node:', error);
+ }
+ };
+
+ // If there's a node to expand, handle it
+ if (nodeToExpand) {
+ handleNodeExpand(nodeToExpand);
+ // Reset the nodeToExpand state after handling
+ window.setTimeout(() => {
+ useGraphStore.getState().triggerNodeExpand(null);
+ }, 0);
+ }
+ }, [nodeToExpand, sigmaGraph, rawGraph, t]);
+
+ // Helper function to get all nodes that will be deleted
+ const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: DirectedGraph) => {
+ const nodesToDelete = new Set([nodeId]);
+
+ // Find all nodes that would become isolated after deletion
+ graph.forEachNode((node) => {
+ if (node === nodeId) return; // Skip the node being deleted
+
+ // Get all neighbors of this node
+ const neighbors = graph.neighbors(node);
+
+ // If this node has only one neighbor and that neighbor is the node being deleted,
+ // this node will become isolated, so we should delete it too
+ if (neighbors.length === 1 && neighbors[0] === nodeId) {
+ nodesToDelete.add(node);
}
+ });
- // 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)
+ return nodesToDelete;
+ }, []);
+
+ // Handle node pruning
+ useEffect(() => {
+ const handleNodePrune = (nodeId: string | null) => {
+ if (!nodeId || !sigmaGraph || !rawGraph) return;
+
+ try {
+ const state = useGraphStore.getState();
+
+ // 1. 检查节点是否存在
+ if (!sigmaGraph.hasNode(nodeId)) {
+ console.error('Node not found:', nodeId);
+ return;
+ }
+
+ // 2. 获取要删除的节点
+ const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph);
+
+ // 3. 检查是否会删除所有节点
+ if (nodesToDelete.size === sigmaGraph.nodes().length) {
+ toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError'));
+ return;
+ }
+
+ // 4. 清除选中状态 - 这会导致PropertiesView立即关闭
+ state.clearSelection();
+
+ // 5. 删除节点和相关边
+ for (const nodeToDelete of nodesToDelete) {
+ // Remove the node from the sigma graph (this will also remove connected edges)
+ sigmaGraph.dropNode(nodeToDelete);
+
+ // Remove the node from the raw graph
+ const nodeIndex = rawGraph.nodeIdMap[nodeToDelete];
+ if (nodeIndex !== undefined) {
+ // Find all edges connected to this node
+ const edgesToRemove = rawGraph.edges.filter(
+ edge => edge.source === nodeToDelete || edge.target === nodeToDelete
+ );
+
+ // Remove edges from raw graph
+ for (const edge of edgesToRemove) {
+ const edgeIndex = rawGraph.edgeIdMap[edge.id];
+ if (edgeIndex !== undefined) {
+ // Remove from edges array
+ rawGraph.edges.splice(edgeIndex, 1);
+ // Update edgeIdMap for all edges after this one
+ for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) {
+ if (idx > edgeIndex) {
+ rawGraph.edgeIdMap[id] = idx - 1;
+ }
+ }
+ // Remove from edgeIdMap
+ delete rawGraph.edgeIdMap[edge.id];
+ // Remove from edgeDynamicIdMap
+ delete rawGraph.edgeDynamicIdMap[edge.dynamicId];
+ }
+ }
+
+ // Remove node from nodes array
+ rawGraph.nodes.splice(nodeIndex, 1);
+
+ // Update nodeIdMap for all nodes after this one
+ for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) {
+ if (idx > nodeIndex) {
+ rawGraph.nodeIdMap[id] = idx - 1;
+ }
+ }
+
+ // Remove from nodeIdMap
+ delete rawGraph.nodeIdMap[nodeToDelete];
+ }
+ }
+
+ // Rebuild the dynamic edge map and invalidate search cache
+ rawGraph.buildDynamicMap();
+
+ // Reset search engine to force rebuild
+ useGraphStore.getState().resetSearchEngine();
+
+ // Show notification if we deleted more than just the selected node
+ if (nodesToDelete.size > 1) {
+ toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
+ }
+
+
+ } catch (error) {
+ console.error('Error pruning node:', error);
+ }
+ };
+
+ // If there's a node to prune, handle it
+ if (nodeToPrune) {
+ handleNodePrune(nodeToPrune);
+ // Reset the nodeToPrune state after handling
+ window.setTimeout(() => {
+ useGraphStore.getState().triggerNodePrune(null);
+ }, 0);
}
- }, [isGraphTabVisible, rawGraph])
+ }, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);
const lightrageGraph = useCallback(() => {
// If we already have a graph instance, return it
diff --git a/lightrag_webui/src/i18n.js b/lightrag_webui/src/i18n.js
new file mode 100644
index 0000000000000000000000000000000000000000..be364b2ceb429df17ce1d252984208480586cd47
--- /dev/null
+++ b/lightrag_webui/src/i18n.js
@@ -0,0 +1,35 @@
+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";
+
+const getStoredLanguage = () => {
+ try {
+ const settingsString = localStorage.getItem('settings-storage');
+ if (settingsString) {
+ const settings = JSON.parse(settingsString);
+ return settings.state?.language || 'en';
+ }
+ } catch (e) {
+ console.error('Failed to get stored language:', e);
+ }
+ return 'en';
+};
+
+i18n
+ .use(initReactI18next)
+ .init({
+ resources: {
+ en: { translation: en },
+ zh: { translation: zh }
+ },
+ lng: getStoredLanguage(), // 使用存储的语言设置
+ fallbackLng: "en",
+ interpolation: {
+ escapeValue: false
+ }
+ });
+
+export default i18n;
diff --git a/lightrag_webui/src/lib/constants.ts b/lightrag_webui/src/lib/constants.ts
index b9198c1e834108a582fcc80a768417ecee890b35..048ae8f76e38b283ba8065045e9fca3b55b89a91 100644
--- a/lightrag_webui/src/lib/constants.ts
+++ b/lightrag_webui/src/lib/constants.ts
@@ -1,6 +1,7 @@
import { ButtonVariantType } from '@/components/ui/Button'
export const backendBaseUrl = ''
+export const webuiPrefix = '/webui/'
export const controlButtonVariant: ButtonVariantType = 'ghost'
diff --git a/lightrag_webui/src/locales/en.json b/lightrag_webui/src/locales/en.json
index 8d7de5fc68e7d811fded85da046789939a310f1e..ed92679a8c2e86853eb72b1f8bf979ea4deeb539 100644
--- a/lightrag_webui/src/locales/en.json
+++ b/lightrag_webui/src/locales/en.json
@@ -12,11 +12,26 @@
"retrieval": "Retrieval",
"api": "API",
"projectRepository": "Project Repository",
+ "logout": "Logout",
"themeToggle": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
}
},
+ "login": {
+ "description": "Please enter your account and password to log in to the system",
+ "username": "Username",
+ "usernamePlaceholder": "Please input a username",
+ "password": "Password",
+ "passwordPlaceholder": "Please input a password",
+ "loginButton": "Login",
+ "loggingIn": "Logging in...",
+ "successMessage": "Login succeeded",
+ "errorEmptyFields": "Please enter your username and password",
+ "errorInvalidCredentials": "Login failed, please check username and password",
+ "authDisabled": "Authentication is disabled. Using login free mode.",
+ "guestMode": "Login Free"
+ },
"documentPanel": {
"clearDocuments": {
"button": "Clear",
@@ -97,12 +112,14 @@
"zoomControl": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
- "resetZoom": "Reset Zoom"
+ "resetZoom": "Reset Zoom",
+ "rotateCamera": "Clockwise Rotate",
+ "rotateCameraCounterClockwise": "Counter-Clockwise Rotate"
},
"layoutsControl": {
- "startAnimation": "Start the layout animation",
- "stopAnimation": "Stop the layout animation",
+ "startAnimation": "Continue layout animation",
+ "stopAnimation": "Stop layout animation",
"layoutGraph": "Layout Graph",
"layouts": {
"Circular": "Circular",
@@ -151,6 +168,11 @@
"degree": "Degree",
"properties": "Properties",
"relationships": "Relationships",
+ "expandNode": "Expand Node",
+ "pruneNode": "Prune Node",
+ "deleteAllNodesError": "Refuse to delete all nodes in the graph",
+ "nodesRemoved": "{{count}} nodes removed, including orphan nodes",
+ "noNewNodes": "No expandable nodes found",
"propertyNames": {
"description": "Description",
"entity_id": "Name",
@@ -177,7 +199,8 @@
"noLabels": "No labels found",
"label": "Label",
"placeholder": "Search labels...",
- "andOthers": "And {count} others"
+ "andOthers": "And {count} others",
+ "refreshTooltip": "Reload graph data"
}
},
"retrievePanel": {
diff --git a/lightrag_webui/src/locales/zh.json b/lightrag_webui/src/locales/zh.json
index 55664397fc7e8820dd6c28b052264cb84ef204ae..19839ea6720d5fa9dae0972ac2d4a50fa941f2d2 100644
--- a/lightrag_webui/src/locales/zh.json
+++ b/lightrag_webui/src/locales/zh.json
@@ -12,11 +12,26 @@
"retrieval": "检索",
"api": "API",
"projectRepository": "项目仓库",
+ "logout": "退出登录",
"themeToggle": {
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
}
},
+ "login": {
+ "description": "请输入您的账号和密码登录系统",
+ "username": "用户名",
+ "usernamePlaceholder": "请输入用户名",
+ "password": "密码",
+ "passwordPlaceholder": "请输入密码",
+ "loginButton": "登录",
+ "loggingIn": "登录中...",
+ "successMessage": "登录成功",
+ "errorEmptyFields": "请输入您的用户名和密码",
+ "errorInvalidCredentials": "登录失败,请检查用户名和密码",
+ "authDisabled": "认证已禁用,使用无需登陆模式。",
+ "guestMode": "无需登陆"
+ },
"documentPanel": {
"clearDocuments": {
"button": "清空",
@@ -84,7 +99,7 @@
"hideUnselectedEdges": "隐藏未选中的边",
"edgeEvents": "边事件",
"maxQueryDepth": "最大查询深度",
- "minDegree": "最小度数",
+ "minDegree": "最小邻边数",
"maxLayoutIterations": "最大布局迭代次数",
"depth": "深度",
"degree": "邻边",
@@ -96,10 +111,12 @@
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "缩小",
- "resetZoom": "重置缩放"
+ "resetZoom": "重置缩放",
+ "rotateCamera": "顺时针旋转图形",
+ "rotateCameraCounterClockwise": "逆时针旋转图形"
},
"layoutsControl": {
- "startAnimation": "开始布局动画",
+ "startAnimation": "继续布局动画",
"stopAnimation": "停止布局动画",
"layoutGraph": "图布局",
"layouts": {
@@ -108,7 +125,7 @@
"Random": "随机",
"Noverlaps": "无重叠",
"Force Directed": "力导向",
- "Force Atlas": "力图"
+ "Force Atlas": "力地图"
}
},
"fullScreenControl": {
@@ -148,6 +165,11 @@
"degree": "度数",
"properties": "属性",
"relationships": "关系",
+ "expandNode": "扩展节点",
+ "pruneNode": "修剪节点",
+ "deleteAllNodesError": "拒绝删除图中的所有节点",
+ "nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点",
+ "noNewNodes": "没有发现可以扩展的节点",
"propertyNames": {
"description": "描述",
"entity_id": "名称",
@@ -174,7 +196,8 @@
"noLabels": "未找到标签",
"label": "标签",
"placeholder": "搜索标签...",
- "andOthers": "还有 {count} 个"
+ "andOthers": "还有 {count} 个",
+ "refreshTooltip": "重新加载图形数据"
}
},
"retrievePanel": {
diff --git a/lightrag_webui/src/main.tsx b/lightrag_webui/src/main.tsx
index fd8f90e19c75ce5aca0e4b597ae60828ef81b2e6..4c613602ff8a1b08e9b31d6c942aa253e497ad3c 100644
--- a/lightrag_webui/src/main.tsx
+++ b/lightrag_webui/src/main.tsx
@@ -1,5 +1,13 @@
+import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
-import { Root } from '@/components/Root'
+import AppRouter from './AppRouter'
+import './i18n';
-createRoot(document.getElementById('root')!).render()
+
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/lightrag_webui/src/services/navigation.ts b/lightrag_webui/src/services/navigation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d06c86c494774f3c7fe0483fec1ad18cb5200df1
--- /dev/null
+++ b/lightrag_webui/src/services/navigation.ts
@@ -0,0 +1,90 @@
+import { NavigateFunction } from 'react-router-dom';
+import { useAuthStore, useBackendState } from '@/stores/state';
+import { useGraphStore } from '@/stores/graph';
+import { useSettingsStore } from '@/stores/settings';
+
+class NavigationService {
+ private navigate: NavigateFunction | null = null;
+
+ setNavigate(navigate: NavigateFunction) {
+ this.navigate = navigate;
+ }
+
+ /**
+ * Reset all application state to ensure a clean environment.
+ * This function should be called when:
+ * 1. User logs out
+ * 2. Authentication token expires
+ * 3. Direct access to login page
+ */
+ resetAllApplicationState() {
+ console.log('Resetting all application state...');
+
+ // Reset graph state
+ const graphStore = useGraphStore.getState();
+ const sigma = graphStore.sigmaInstance;
+ graphStore.reset();
+ graphStore.setGraphDataFetchAttempted(false);
+ graphStore.setLabelsFetchAttempted(false);
+ graphStore.setSigmaInstance(null);
+ graphStore.setIsFetching(false); // Reset isFetching state to prevent data loading issues
+
+ // Reset backend state
+ useBackendState.getState().clear();
+
+ // Reset retrieval history while preserving other user preferences
+ useSettingsStore.getState().setRetrievalHistory([]);
+
+ // Clear authentication state
+ sessionStorage.clear();
+
+ if (sigma) {
+ sigma.getGraph().clear();
+ sigma.kill();
+ useGraphStore.getState().setSigmaInstance(null);
+ }
+ }
+
+ /**
+ * Handle direct access to login page
+ * @returns true if it's a direct access, false if navigated from another page
+ */
+ handleDirectLoginAccess() {
+ const isDirectAccess = !document.referrer;
+ if (isDirectAccess) {
+ this.resetAllApplicationState();
+ }
+ return isDirectAccess;
+ }
+
+ /**
+ * Navigate to login page and reset application state
+ * @param skipReset whether to skip state reset (used for direct access scenario where reset is already handled)
+ */
+ navigateToLogin() {
+ if (!this.navigate) {
+ console.error('Navigation function not set');
+ return;
+ }
+
+ // First navigate to login page
+ this.navigate('/login');
+
+ // Then reset state after navigation
+ setTimeout(() => {
+ this.resetAllApplicationState();
+ useAuthStore.getState().logout();
+ }, 0);
+ }
+
+ navigateToHome() {
+ if (!this.navigate) {
+ console.error('Navigation function not set');
+ return;
+ }
+
+ this.navigate('/');
+ }
+}
+
+export const navigationService = new NavigationService();
diff --git a/lightrag_webui/src/stores/graph.ts b/lightrag_webui/src/stores/graph.ts
index f04c8a0c6b35ece09f9bbba07698cf356d44af26..637a38457152e3b0b9ae0538ebe717383c5f5b1b 100644
--- a/lightrag_webui/src/stores/graph.ts
+++ b/lightrag_webui/src/stores/graph.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology'
import { getGraphLabels } from '@/api/lightrag'
+import MiniSearch from 'minisearch'
export type RawNodeType = {
id: string
@@ -66,17 +67,19 @@ interface GraphState {
rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null
+ sigmaInstance: any | null
allDatabaseLabels: string[]
+ searchEngine: MiniSearch | null
+
moveToSelectedNode: boolean
isFetching: boolean
- shouldRender: boolean
// Global flags to track data fetching attempts
graphDataFetchAttempted: boolean
labelsFetchAttempted: boolean
- refreshLayout: () => void
+ setSigmaInstance: (instance: any) => void
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
setFocusedNode: (nodeId: string | null) => void
setSelectedEdge: (edgeId: string | null) => void
@@ -91,14 +94,25 @@ interface GraphState {
setAllDatabaseLabels: (labels: string[]) => void
fetchAllDatabaseLabels: () => Promise
setIsFetching: (isFetching: boolean) => void
- setShouldRender: (shouldRender: boolean) => void
+
+ // 搜索引擎方法
+ setSearchEngine: (engine: MiniSearch | null) => void
+ resetSearchEngine: () => void
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => void
setLabelsFetchAttempted: (attempted: boolean) => void
+
+ // Event trigger methods for node operations
+ triggerNodeExpand: (nodeId: string | null) => void
+ triggerNodePrune: (nodeId: string | null) => void
+
+ // Node operation state
+ nodeToExpand: string | null
+ nodeToPrune: string | null
}
-const useGraphStoreBase = create()((set, get) => ({
+const useGraphStoreBase = create()((set) => ({
selectedNode: null,
focusedNode: null,
selectedEdge: null,
@@ -106,7 +120,6 @@ const useGraphStoreBase = create()((set, get) => ({
moveToSelectedNode: false,
isFetching: false,
- shouldRender: false,
// Initialize global flags
graphDataFetchAttempted: false,
@@ -114,21 +127,13 @@ const useGraphStoreBase = create()((set, get) => ({
rawGraph: null,
sigmaGraph: null,
+ sigmaInstance: null,
allDatabaseLabels: ['*'],
- refreshLayout: () => {
- const currentGraph = get().sigmaGraph;
- if (currentGraph) {
- get().clearSelection();
- get().setSigmaGraph(null);
- setTimeout(() => {
- get().setSigmaGraph(currentGraph);
- }, 10);
- }
- },
+ searchEngine: null,
+
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 }),
@@ -142,24 +147,15 @@ const useGraphStoreBase = create()((set, get) => ({
focusedEdge: null
}),
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,
- // Keep the existing graph instance but with cleared data
- moveToSelectedNode: false,
- shouldRender: false
+ sigmaGraph: null, // to avoid other components from acccessing graph objects
+ searchEngine: null,
+ moveToSelectedNode: false
});
},
@@ -190,9 +186,23 @@ const useGraphStoreBase = create()((set, get) => ({
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
+ setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),
+
+ setSearchEngine: (engine: MiniSearch | null) => set({ searchEngine: engine }),
+ resetSearchEngine: () => set({ searchEngine: null }),
+
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
- setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted })
+ setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted }),
+
+ // Node operation state
+ nodeToExpand: null,
+ nodeToPrune: null,
+
+ // Event trigger methods for node operations
+ triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
+ triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
+
}))
const useGraphStore = createSelectors(useGraphStoreBase)
diff --git a/lightrag_webui/src/stores/state.ts b/lightrag_webui/src/stores/state.ts
index 0e104e6d89161d481acf8450a05257993d599da7..5a24e72a9fe7b74737b4f8d55847103d5e394f65 100644
--- a/lightrag_webui/src/stores/state.ts
+++ b/lightrag_webui/src/stores/state.ts
@@ -16,6 +16,13 @@ interface BackendState {
setErrorMessage: (message: string, messageTitle: string) => void
}
+interface AuthState {
+ isAuthenticated: boolean;
+ isGuestMode: boolean; // Add guest mode flag
+ login: (token: string, isGuest?: boolean) => void;
+ logout: () => void;
+}
+
const useBackendStateStoreBase = create()((set) => ({
health: true,
message: null,
@@ -57,3 +64,60 @@ const useBackendStateStoreBase = create()((set) => ({
const useBackendState = createSelectors(useBackendStateStoreBase)
export { useBackendState }
+
+// Helper function to check if token is a guest token
+const isGuestToken = (token: string): boolean => {
+ try {
+ // JWT tokens are in the format: header.payload.signature
+ const parts = token.split('.');
+ if (parts.length !== 3) return false;
+
+ // Decode the payload (second part)
+ const payload = JSON.parse(atob(parts[1]));
+
+ // Check if the token has a role field with value "guest"
+ return payload.role === 'guest';
+ } catch (e) {
+ console.error('Error parsing token:', e);
+ return false;
+ }
+};
+
+// Initialize auth state from localStorage
+const initAuthState = (): { isAuthenticated: boolean; isGuestMode: boolean } => {
+ const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
+ if (!token) {
+ return { isAuthenticated: false, isGuestMode: false };
+ }
+
+ return {
+ isAuthenticated: true,
+ isGuestMode: isGuestToken(token)
+ };
+};
+
+export const useAuthStore = create(set => {
+ // Get initial state from localStorage
+ const initialState = initAuthState();
+
+ return {
+ isAuthenticated: initialState.isAuthenticated,
+ isGuestMode: initialState.isGuestMode,
+
+ login: (token, isGuest = false) => {
+ localStorage.setItem('LIGHTRAG-API-TOKEN', token);
+ set({
+ isAuthenticated: true,
+ isGuestMode: isGuest
+ });
+ },
+
+ logout: () => {
+ localStorage.removeItem('LIGHTRAG-API-TOKEN');
+ set({
+ isAuthenticated: false,
+ isGuestMode: false
+ });
+ }
+ };
+});
diff --git a/lightrag_webui/tsconfig.json b/lightrag_webui/tsconfig.json
index 2054809dd86c2c638495f0e96ae4b20bfda458f6..86006fc1ac15d4dd6bf86b403923158a1a0d197b 100644
--- a/lightrag_webui/tsconfig.json
+++ b/lightrag_webui/tsconfig.json
@@ -26,5 +26,5 @@
"@/*": ["./src/*"]
}
},
- "include": ["src", "vite.config.ts"]
+ "include": ["src", "vite.config.ts", "src/vite-env.d.ts"]
}
diff --git a/lightrag_webui/vite.config.ts b/lightrag_webui/vite.config.ts
index b05bf2fa0900bf5e7058e22276792aab90802a11..7419187b58d9831d75cf38a48635446a4be12202 100644
--- a/lightrag_webui/vite.config.ts
+++ b/lightrag_webui/vite.config.ts
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite'
import path from 'path'
-
+import { webuiPrefix } from '@/lib/constants'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
@@ -12,7 +12,8 @@ export default defineConfig({
'@': path.resolve(__dirname, './src')
}
},
- base: './',
+ // base: import.meta.env.VITE_BASE_URL || '/webui/',
+ base: webuiPrefix,
build: {
outDir: path.resolve(__dirname, '../lightrag/api/webui'),
emptyOutDir: true