From 6b22e8065bdf3dafb1b1765e765f3b51131ee6e7 Mon Sep 17 00:00:00 2001 From: choizhang Date: Tue, 11 Mar 2025 14:48:19 +0800 Subject: [PATCH 01/67] Added loginPage --- lightrag/api/lightrag_server.py | 2 +- lightrag_webui/bun.lock | 13 +++ lightrag_webui/package.json | 1 + lightrag_webui/src/App.tsx | 2 - lightrag_webui/src/AppRouter.tsx | 40 +++++++++ lightrag_webui/src/api/lightrag.ts | 39 ++++++++ lightrag_webui/src/features/LoginPage.tsx | 100 +++++++++++++++++++++ lightrag_webui/src/features/SiteHeader.tsx | 23 ++++- lightrag_webui/src/main.tsx | 4 +- lightrag_webui/src/stores/state.ts | 22 +++++ 10 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 lightrag_webui/src/AppRouter.tsx create mode 100644 lightrag_webui/src/features/LoginPage.tsx diff --git a/lightrag/api/lightrag_server.py b/lightrag/api/lightrag_server.py index fd09a691..5d223759 100644 --- a/lightrag/api/lightrag_server.py +++ b/lightrag/api/lightrag_server.py @@ -373,7 +373,7 @@ def create_app(args): ollama_api = OllamaAPI(rag, top_k=args.top_k) app.include_router(ollama_api.router, prefix="/api") - @app.post("/login") + @app.post("/login", dependencies=[Depends(optional_api_key)]) async def login(form_data: OAuth2PasswordRequestForm = Depends()): username = os.getenv("AUTH_USERNAME") password = os.getenv("AUTH_PASSWORD") diff --git a/lightrag_webui/bun.lock b/lightrag_webui/bun.lock index 6157e38c..7435e125 100644 --- a/lightrag_webui/bun.lock +++ b/lightrag_webui/bun.lock @@ -41,6 +41,7 @@ "react-dropzone": "^14.3.6", "react-markdown": "^9.1.0", "react-number-format": "^5.4.3", + "react-router-dom": "^7.3.0", "react-syntax-highlighter": "^15.6.1", "rehype-react": "^8.0.0", "remark-gfm": "^4.0.1", @@ -415,6 +416,8 @@ "@types/bun": ["@types/bun@1.2.3", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="], + "@types/cookie": ["@types/cookie@0.6.0", "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], @@ -561,6 +564,8 @@ "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "cookie": ["cookie@1.0.2", "https://registry.npmmirror.com/cookie/-/cookie-1.0.2.tgz", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1103,6 +1108,10 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-router": ["react-router@7.3.0", "https://registry.npmmirror.com/react-router/-/react-router-7.3.0.tgz", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw=="], + + "react-router-dom": ["react-router-dom@7.3.0", "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.3.0.tgz", { "dependencies": { "react-router": "7.3.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ=="], + "react-select": ["react-select@5.10.0", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-k96gw+i6N3ExgDwPIg0lUPmexl1ygPe6u5BdQFNBhkpbwroIgCNXdubtIzHfThYXYYTubwOBafoMnn7ruEP1xA=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -1153,6 +1162,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -1223,6 +1234,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turbo-stream": ["turbo-stream@2.4.0", "https://registry.npmmirror.com/turbo-stream/-/turbo-stream-2.4.0.tgz", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], diff --git a/lightrag_webui/package.json b/lightrag_webui/package.json index 578ee36f..9b85f01f 100644 --- a/lightrag_webui/package.json +++ b/lightrag_webui/package.json @@ -50,6 +50,7 @@ "react-dropzone": "^14.3.6", "react-markdown": "^9.1.0", "react-number-format": "^5.4.3", + "react-router-dom": "^7.3.0", "react-syntax-highlighter": "^15.6.1", "rehype-react": "^8.0.0", "remark-gfm": "^4.0.1", diff --git a/lightrag_webui/src/App.tsx b/lightrag_webui/src/App.tsx index 1cf8c5e3..0a2ec50f 100644 --- a/lightrag_webui/src/App.tsx +++ b/lightrag_webui/src/App.tsx @@ -7,7 +7,6 @@ import { healthCheckInterval } from '@/lib/constants' import { useBackendState } from '@/stores/state' import { useSettingsStore } from '@/stores/settings' import { useEffect } from 'react' -import { Toaster } from 'sonner' import SiteHeader from '@/features/SiteHeader' import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag' @@ -79,7 +78,6 @@ function App() { {enableHealthCheck && } {message !== null && !apiKeyInvalid && } {apiKeyInvalid && } - ) diff --git a/lightrag_webui/src/AppRouter.tsx b/lightrag_webui/src/AppRouter.tsx new file mode 100644 index 00000000..a28b57c9 --- /dev/null +++ b/lightrag_webui/src/AppRouter.tsx @@ -0,0 +1,40 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +// import { useAuthStore } from '@/stores/state' +import { Toaster } from 'sonner' +import App from './App' +import LoginPage from '@/features/LoginPage' + +interface ProtectedRouteProps { + children: React.ReactNode +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + // const { isAuthenticated } = useAuthStore() + + // if (!isAuthenticated) { + // return + // } + + return <>{children} +} + +const AppRouter = () => { + return ( + + + } /> + + + + } + /> + + + + ) +} + +export default AppRouter diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index cba9c964..a4cd86fb 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios' import { backendBaseUrl } from '@/lib/constants' import { errorMessage } from '@/lib/utils' import { useSettingsStore } from '@/stores/settings' +import { useAuthStore } from '@/stores/state' // Types export type LightragNodeType = { @@ -125,6 +126,11 @@ export type DocsStatusesResponse = { statuses: Record } +export type LoginResponse = { + access_token: string + token_type: string +} + export const InvalidApiKeyError = 'Invalid API Key' export const RequireApiKeError = 'API Key required' @@ -139,9 +145,13 @@ const axiosInstance = axios.create({ // Interceptor:add api key axiosInstance.interceptors.request.use((config) => { const apiKey = useSettingsStore.getState().apiKey + const token = localStorage.getItem('LIGHTRAG-API-TOKEN'); if (apiKey) { config.headers['X-API-Key'] = apiKey } + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } return config }) @@ -150,6 +160,21 @@ axiosInstance.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response) { + interface ErrorResponse { + detail: string; + } + + if (error.response?.status === 401) { + localStorage.removeItem('LIGHTRAG-API-TOKEN'); + sessionStorage.clear(); + useAuthStore.getState().logout(); + + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + + return Promise.reject(error); + } throw new Error( `${error.response.status} ${error.response.statusText}\n${JSON.stringify( error.response.data @@ -324,3 +349,17 @@ export const clearDocuments = async (): Promise => { const response = await axiosInstance.delete('/documents') return response.data } + +export const loginToServer = async (username: string, password: string): Promise => { + const formData = new FormData(); + formData.append('username', username); + formData.append('password', password); + + const response = await axiosInstance.post('/login', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + return response.data; +} diff --git a/lightrag_webui/src/features/LoginPage.tsx b/lightrag_webui/src/features/LoginPage.tsx new file mode 100644 index 00000000..ad0e6227 --- /dev/null +++ b/lightrag_webui/src/features/LoginPage.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/state' +import { loginToServer } from '@/api/lightrag' +import { toast } from 'sonner' + +import { Card, CardContent, CardHeader } from '@/components/ui/Card' +import Input from '@/components/ui/Input' +import Button from '@/components/ui/Button' +import { ZapIcon } from 'lucide-react' + +const LoginPage = () => { + const navigate = useNavigate() + const { login } = useAuthStore() + const [loading, setLoading] = useState(false) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!username || !password) { + toast.error('Please enter your username and password') + return + } + + try { + setLoading(true) + const response = await loginToServer(username, password) + login(response.access_token) + navigate('/') + toast.success('Login succeeded') + } catch (error) { + console.error('Login failed...', error) + toast.error('Login failed, please check username and password') + } finally { + setLoading(false) + } + } + + return ( +
+ + +
+
+ LightRAG Logo +
+
+

LightRAG

+

+ Please enter your account and password to log in to the system +

+
+
+
+ +
+
+ + setUsername(e.target.value)} + required + className="h-11 flex-1" + /> +
+
+ + setPassword(e.target.value)} + required + className="h-11 flex-1" + /> +
+ +
+
+
+
+ ) +} + +export default LoginPage diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index c09ce089..b92e260e 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -3,9 +3,11 @@ import { SiteInfo } from '@/lib/constants' import ThemeToggle from '@/components/ThemeToggle' import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { useSettingsStore } from '@/stores/settings' +import { useAuthStore } from '@/stores/state' import { cn } from '@/lib/utils' +import { useNavigate } from 'react-router-dom' -import { ZapIcon, GithubIcon } from 'lucide-react' +import { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react' interface NavigationTabProps { value: string @@ -51,6 +53,14 @@ function TabsNavigation() { } export default function SiteHeader() { + const navigate = useNavigate() + const { logout } = useAuthStore() + + const handleLogout = () => { + logout() + navigate('/login') + } + return (
@@ -63,13 +73,22 @@ export default function SiteHeader() { -
) diff --git a/lightrag_webui/src/main.tsx b/lightrag_webui/src/main.tsx index 2caec890..215e0118 100644 --- a/lightrag_webui/src/main.tsx +++ b/lightrag_webui/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import AppRouter from './AppRouter' createRoot(document.getElementById('root')!).render( - + ) diff --git a/lightrag_webui/src/stores/state.ts b/lightrag_webui/src/stores/state.ts index 0e104e6d..0ccf4297 100644 --- a/lightrag_webui/src/stores/state.ts +++ b/lightrag_webui/src/stores/state.ts @@ -16,6 +16,14 @@ interface BackendState { setErrorMessage: (message: string, messageTitle: string) => void } +interface AuthState { + isAuthenticated: boolean; + showLoginModal: boolean; + login: (token: string) => void; + logout: () => void; + setShowLoginModal: (show: boolean) => void; +} + const useBackendStateStoreBase = create()((set) => ({ health: true, message: null, @@ -57,3 +65,17 @@ const useBackendStateStoreBase = create()((set) => ({ const useBackendState = createSelectors(useBackendStateStoreBase) export { useBackendState } + +export const useAuthStore = create(set => ({ + isAuthenticated: !!localStorage.getItem('LIGHTRAG-API-TOKEN'), + showLoginModal: false, + login: (token) => { + localStorage.setItem('LIGHTRAG-API-TOKEN', token); + set({ isAuthenticated: true, showLoginModal: false }); + }, + logout: () => { + localStorage.removeItem('LIGHTRAG-API-TOKEN'); + set({ isAuthenticated: false }); + }, + setShowLoginModal: (show) => set({ showLoginModal: show }) +})); From 7bf2d51bd01017ec3cc24b0f381e7b5f35719e5f Mon Sep 17 00:00:00 2001 From: choizhang Date: Wed, 12 Mar 2025 00:42:12 +0800 Subject: [PATCH 02/67] Added language and theme switching function to login page and homepage --- lightrag_webui/src/App.tsx | 3 -- lightrag_webui/src/AppRouter.tsx | 31 ++++++------ .../src/components/LanguageToggle.tsx | 49 +++++++++++++++++++ lightrag_webui/src/features/LoginPage.tsx | 26 ++++++---- lightrag_webui/src/features/SiteHeader.tsx | 2 + lightrag_webui/src/i18n.js | 16 +++++- lightrag_webui/src/locales/en.json | 12 +++++ lightrag_webui/src/locales/zh.json | 12 +++++ lightrag_webui/src/stores/settings.ts | 7 +++ 9 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 lightrag_webui/src/components/LanguageToggle.tsx diff --git a/lightrag_webui/src/App.tsx b/lightrag_webui/src/App.tsx index 0a2ec50f..80dd57a5 100644 --- a/lightrag_webui/src/App.tsx +++ b/lightrag_webui/src/App.tsx @@ -1,5 +1,4 @@ import { useState, useCallback } from 'react' -import ThemeProvider from '@/components/ThemeProvider' import MessageAlert from '@/components/MessageAlert' import ApiKeyAlert from '@/components/ApiKeyAlert' import StatusIndicator from '@/components/graph/StatusIndicator' @@ -52,7 +51,6 @@ function App() { }, [message, setApiKeyInvalid]) return ( -
} {apiKeyInvalid && }
-
) } diff --git a/lightrag_webui/src/AppRouter.tsx b/lightrag_webui/src/AppRouter.tsx index a28b57c9..0dfc9918 100644 --- a/lightrag_webui/src/AppRouter.tsx +++ b/lightrag_webui/src/AppRouter.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Toaster } from 'sonner' import App from './App' import LoginPage from '@/features/LoginPage' +import ThemeProvider from '@/components/ThemeProvider' interface ProtectedRouteProps { children: React.ReactNode @@ -20,20 +21,22 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const AppRouter = () => { return ( - - - } /> - - - - } - /> - - - + + + + } /> + + + + } + /> + + + + ) } diff --git a/lightrag_webui/src/components/LanguageToggle.tsx b/lightrag_webui/src/components/LanguageToggle.tsx new file mode 100644 index 00000000..0eab780e --- /dev/null +++ b/lightrag_webui/src/components/LanguageToggle.tsx @@ -0,0 +1,49 @@ +import Button from '@/components/ui/Button' +import { useCallback } from 'react' +import { controlButtonVariant } from '@/lib/constants' +import { useTranslation } from 'react-i18next' +import { useSettingsStore } from '@/stores/settings' + +/** + * Component that toggles the language between English and Chinese. + */ +export default function LanguageToggle() { + const { i18n } = useTranslation() + const currentLanguage = i18n.language + const setLanguage = useSettingsStore.use.setLanguage() + + const setEnglish = useCallback(() => { + i18n.changeLanguage('en') + setLanguage('en') + }, [i18n, setLanguage]) + + const setChinese = useCallback(() => { + i18n.changeLanguage('zh') + setLanguage('zh') + }, [i18n, setLanguage]) + + if (currentLanguage === 'zh') { + return ( + + ) + } + return ( + + ) +} diff --git a/lightrag_webui/src/features/LoginPage.tsx b/lightrag_webui/src/features/LoginPage.tsx index ad0e6227..f72bafd2 100644 --- a/lightrag_webui/src/features/LoginPage.tsx +++ b/lightrag_webui/src/features/LoginPage.tsx @@ -3,15 +3,19 @@ import { useNavigate } from 'react-router-dom' import { useAuthStore } from '@/stores/state' import { loginToServer } from '@/api/lightrag' import { toast } from 'sonner' +import { useTranslation } from 'react-i18next' import { Card, CardContent, CardHeader } from '@/components/ui/Card' import Input from '@/components/ui/Input' import Button from '@/components/ui/Button' import { ZapIcon } from 'lucide-react' +import ThemeToggle from '@/components/ThemeToggle' +import LanguageToggle from '@/components/LanguageToggle' const LoginPage = () => { const navigate = useNavigate() const { login } = useAuthStore() + const { t } = useTranslation() const [loading, setLoading] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -19,7 +23,7 @@ const LoginPage = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!username || !password) { - toast.error('Please enter your username and password') + toast.error(t('login.errorEmptyFields')) return } @@ -28,10 +32,10 @@ const LoginPage = () => { const response = await loginToServer(username, password) login(response.access_token) navigate('/') - toast.success('Login succeeded') + toast.success(t('login.successMessage')) } catch (error) { console.error('Login failed...', error) - toast.error('Login failed, please check username and password') + toast.error(t('login.errorInvalidCredentials')) } finally { setLoading(false) } @@ -39,6 +43,10 @@ const LoginPage = () => { return (
+
+ + +
@@ -49,7 +57,7 @@ const LoginPage = () => {

LightRAG

- Please enter your account and password to log in to the system + {t('login.description')}

@@ -58,11 +66,11 @@ const LoginPage = () => {
setUsername(e.target.value)} required @@ -71,12 +79,12 @@ const LoginPage = () => {
setPassword(e.target.value)} required @@ -88,7 +96,7 @@ const LoginPage = () => { className="w-full h-11 text-base font-medium mt-2" disabled={loading} > - {loading ? 'Logging in...' : 'Login'} + {loading ? t('login.loggingIn') : t('login.loginButton')} diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index f85d6251..121c43af 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -1,6 +1,7 @@ import Button from '@/components/ui/Button' import { SiteInfo } from '@/lib/constants' import ThemeToggle from '@/components/ThemeToggle' +import LanguageToggle from '@/components/LanguageToggle' import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { useSettingsStore } from '@/stores/settings' import { useAuthStore } from '@/stores/state' @@ -82,6 +83,7 @@ export default function SiteHeader() {
+
{ } 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() + const nodeToExpand = useGraphStore.use.nodeToExpand() + const nodeToPrune = useGraphStore.use.nodeToPrune() // Get tab visibility const { isTabVisible } = useTabVisibility() @@ -327,6 +332,382 @@ const useLightrangeGraph = () => { } }, [isGraphTabVisible, rawGraph]) + // Handle node expansion + useEffect(() => { + const handleNodeExpand = async (nodeId: string | null) => { + if (!nodeId || !sigmaGraph || !rawGraph) return; + + try { + // Set fetching state + useGraphStore.getState().setIsFetching(true); + + // Get the node to expand + const nodeToExpand = rawGraph.getNode(nodeId); + if (!nodeToExpand) { + console.error('Node not found:', nodeId); + useGraphStore.getState().setIsFetching(false); + return; + } + + // Get the label of the node to expand + const label = nodeToExpand.labels[0]; + if (!label) { + console.error('Node has no label:', nodeId); + useGraphStore.getState().setIsFetching(false); + 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'); + useGraphStore.getState().setIsFetching(false); + return; + } + + // Process nodes to add required properties for RawNodeType + const processedNodes: RawNodeType[] = []; + for (const node of extendedGraph.nodes) { + // Generate random color values + const r = Math.floor(Math.random() * 256); + const g = Math.floor(Math.random() * 256); + const b = Math.floor(Math.random() * 256); + const color = `rgb(${r}, ${g}, ${b})`; + + // Create a properly typed RawNodeType + processedNodes.push({ + id: node.id, + labels: node.labels, + properties: node.properties, + size: 10, // Default size + x: Math.random(), // Random position + y: Math.random(), // Random position + color: color, // Random color + degree: 0 // Initial degree + }); + } + + // 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()); + + // Check if there are any new nodes that can be connected to the selected node + let hasConnectableNewNodes = false; + for (const newNode of processedNodes) { + // Skip if node already exists + if (existingNodeIds.has(newNode.id)) { + continue; + } + + // Check if this node is connected to the selected node + const isConnected = processedEdges.some( + edge => (edge.source === nodeId && edge.target === newNode.id) || + (edge.target === nodeId && edge.source === newNode.id) + ); + + if (isConnected) { + hasConnectableNewNodes = true; + break; + } + } + + // If no new connectable nodes found, show toast and return + if (!hasConnectableNewNodes) { + toast.info(t('graphPanel.propertiesView.node.noNewNodes')); + useGraphStore.getState().setIsFetching(false); + return; + } + + // Get degree range from existing graph for size calculations + let minDegree = Number.MAX_SAFE_INTEGER; + let maxDegree = 0; + sigmaGraph.forEachNode(node => { + const degree = sigmaGraph.degree(node); + minDegree = Math.min(minDegree, degree); + maxDegree = Math.max(maxDegree, degree); + }); + + // Calculate size formula parameters + const range = maxDegree - minDegree || 1; // Avoid division by zero + const scale = Constants.maxNodeSize - Constants.minNodeSize; + + // Add new nodes from the processed nodes + for (const newNode of processedNodes) { + // Skip if node already exists + if (existingNodeIds.has(newNode.id)) { + continue; + } + + // Check if this node is connected to the selected node + const isConnected = processedEdges.some( + edge => (edge.source === nodeId && edge.target === newNode.id) || + (edge.target === nodeId && edge.source === newNode.id) + ); + + if (isConnected) { + // Calculate node degree (number of connected edges) + const nodeDegree = processedEdges.filter(edge => + edge.source === newNode.id || edge.target === newNode.id + ).length; + + // Calculate node size using the same formula as in fetchGraph + const nodeSize = Math.round( + Constants.minNodeSize + scale * Math.pow((nodeDegree - minDegree) / range, 0.5) + ); + + // Add the new node to the graph with calculated size + sigmaGraph.addNode(newNode.id, { + label: newNode.labels.join(', '), + color: newNode.color, + x: nodePositions[nodeId].x + (Math.random() - 0.5) * 0.5, + y: nodePositions[nodeId].y + (Math.random() - 0.5) * 0.5, + size: nodeSize, + borderColor: '#000', + borderSize: 0.2 + }); + + // Add the node to the raw graph + if (!rawGraph.getNode(newNode.id)) { + // Update the node size to match the calculated size + newNode.size = nodeSize; + // Add to nodes array + rawGraph.nodes.push(newNode); + // Update nodeIdMap + rawGraph.nodeIdMap[newNode.id] = rawGraph.nodes.length - 1; + } + } + } + + // Add new edges + for (const newEdge of processedEdges) { + // Only add edges where both source and target exist in the graph + if (sigmaGraph.hasNode(newEdge.source) && sigmaGraph.hasNode(newEdge.target)) { + // Skip if edge already exists + if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) { + 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; + } + } + } + + // Update the dynamic edge map + rawGraph.buildDynamicMap(); + + // Restore positions for existing nodes + Object.entries(nodePositions).forEach(([id, position]) => { + if (sigmaGraph.hasNode(id)) { + sigmaGraph.setNodeAttribute(id, 'x', position.x); + sigmaGraph.setNodeAttribute(id, 'y', position.y); + } + }); + + // Update the size of the expanded node based on its new edge count + if (sigmaGraph.hasNode(nodeId)) { + // Get the new degree of the expanded node + const expandedNodeDegree = sigmaGraph.degree(nodeId); + + // Calculate new size for the expanded node using the same parameters + const newSize = Math.round( + Constants.minNodeSize + scale * Math.pow((expandedNodeDegree - minDegree) / range, 0.5) + ); + + // Update the size in sigma graph + sigmaGraph.setNodeAttribute(nodeId, 'size', newSize); + + // Update the size in raw graph + const expandedNodeIndex = rawGraph.nodeIdMap[nodeId]; + if (expandedNodeIndex !== undefined) { + rawGraph.nodes[expandedNodeIndex].size = newSize; + } + } + + // Refresh the layout and store the node ID to reselect after refresh + const nodeIdToSelect = nodeId; + useGraphStore.getState().refreshLayout(); + + // Use setTimeout to reselect the node after the layout refresh is complete + setTimeout(() => { + if (nodeIdToSelect) { + useGraphStore.getState().setSelectedNode(nodeIdToSelect, true); + } + }, 2000); // Wait a bit longer than the refreshLayout timeout (which is 10ms) + + } catch (error) { + console.error('Error expanding node:', error); + } finally { + // Reset fetching state and node to expand + useGraphStore.getState().setIsFetching(false); + } + }; + + // 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]); + + // 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); + } + }); + + return nodesToDelete; + }, []); + + // Handle node pruning + useEffect(() => { + const handleNodePrune = (nodeId: string | null) => { + if (!nodeId || !sigmaGraph || !rawGraph) return; + + try { + // Check if the node exists + if (!sigmaGraph.hasNode(nodeId)) { + console.error('Node not found:', nodeId); + return; + } + + // Get all nodes that will be deleted (including isolated nodes) + const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph); + + // Check if we would delete all nodes in the graph + if (nodesToDelete.size === sigmaGraph.nodes().length) { + toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError')); + return; + } + + // If the node is selected or focused, clear selection + const state = useGraphStore.getState(); + if (state.selectedNode === nodeId || state.focusedNode === nodeId) { + state.clearSelection(); + } + + // Process all nodes that need to be deleted + 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 + rawGraph.buildDynamicMap(); + + // 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 })); + } + + // Force a refresh of the graph layout + useGraphStore.getState().refreshLayout(); + + } 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); + } + }, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]); + const lightrageGraph = useCallback(() => { // If we already have a graph instance, return it if (sigmaGraph) { diff --git a/lightrag_webui/src/locales/en.json b/lightrag_webui/src/locales/en.json index 8d7de5fc..28d1a3f2 100644 --- a/lightrag_webui/src/locales/en.json +++ b/lightrag_webui/src/locales/en.json @@ -151,6 +151,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", diff --git a/lightrag_webui/src/locales/zh.json b/lightrag_webui/src/locales/zh.json index 55664397..919f605a 100644 --- a/lightrag_webui/src/locales/zh.json +++ b/lightrag_webui/src/locales/zh.json @@ -148,6 +148,11 @@ "degree": "度数", "properties": "属性", "relationships": "关系", + "expandNode": "扩展节点", + "pruneNode": "修剪节点", + "deleteAllNodesError": "拒绝删除图中的所有节点", + "nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点", + "noNewNodes": "没有发现可以扩展的节点", "propertyNames": { "description": "描述", "entity_id": "名称", diff --git a/lightrag_webui/src/stores/graph.ts b/lightrag_webui/src/stores/graph.ts index f04c8a0c..95c32156 100644 --- a/lightrag_webui/src/stores/graph.ts +++ b/lightrag_webui/src/stores/graph.ts @@ -96,6 +96,14 @@ interface GraphState { // 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) => ({ @@ -192,7 +200,267 @@ const useGraphStoreBase = create()((set, get) => ({ // 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 }), + + // Legacy node expansion and pruning methods - will be removed after refactoring + expandNode: async (nodeId: string) => { + const state = get(); + if (!state.sigmaGraph || !state.rawGraph || !nodeId) { + console.error('Cannot expand node: graph or node not available'); + return; + } + + try { + // Set fetching state + state.setIsFetching(true); + + // Import queryGraphs dynamically to avoid circular dependency + const { queryGraphs } = await import('@/api/lightrag'); + + // Get the node to expand + const nodeToExpand = state.rawGraph.getNode(nodeId); + if (!nodeToExpand) { + console.error('Node not found:', nodeId); + state.setIsFetching(false); + return; + } + + // Get the label of the node to expand + const label = nodeToExpand.labels[0]; + if (!label) { + console.error('Node has no label:', nodeId); + state.setIsFetching(false); + 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'); + state.setIsFetching(false); + return; + } + + // Process nodes to add required properties for RawNodeType + const processedNodes: RawNodeType[] = []; + for (const node of extendedGraph.nodes) { + // Generate random color + const randomColorValue = () => Math.floor(Math.random() * 256); + const color = `rgb(${randomColorValue()}, ${randomColorValue()}, ${randomColorValue()})`; + + // Create a properly typed RawNodeType + processedNodes.push({ + id: node.id, + labels: node.labels, + properties: node.properties, + size: 10, // Default size + x: Math.random(), // Random position + y: Math.random(), // Random position + color: color, // Random color + degree: 0 // Initial degree + }); + } + + // 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 = {}; + state.sigmaGraph.forEachNode((node) => { + nodePositions[node] = { + x: state.sigmaGraph!.getNodeAttribute(node, 'x'), + y: state.sigmaGraph!.getNodeAttribute(node, 'y') + }; + }); + + // Get existing node IDs + const existingNodeIds = new Set(state.sigmaGraph.nodes()); + + // Create a map from id to processed node for quick lookup + const processedNodeMap = new Map(); + for (const node of processedNodes) { + processedNodeMap.set(node.id, node); + } + + // Create a map from id to processed edge for quick lookup + const processedEdgeMap = new Map(); + for (const edge of processedEdges) { + processedEdgeMap.set(edge.id, edge); + } + + // Add new nodes from the processed nodes + for (const newNode of processedNodes) { + // Skip if node already exists + if (existingNodeIds.has(newNode.id)) { + continue; + } + + // Check if this node is connected to the selected node + const isConnected = processedEdges.some( + edge => (edge.source === nodeId && edge.target === newNode.id) || + (edge.target === nodeId && edge.source === newNode.id) + ); + + if (isConnected) { + // Add the new node to the graph + state.sigmaGraph.addNode(newNode.id, { + label: newNode.labels.join(', '), + color: newNode.color, + x: nodePositions[nodeId].x + (Math.random() - 0.5) * 0.5, + y: nodePositions[nodeId].y + (Math.random() - 0.5) * 0.5, + size: newNode.size, + borderColor: '#000', + borderSize: 0.2 + }); + + // Add the node to the raw graph + if (!state.rawGraph.getNode(newNode.id)) { + // Add to nodes array + state.rawGraph.nodes.push(newNode); + // Update nodeIdMap + state.rawGraph.nodeIdMap[newNode.id] = state.rawGraph.nodes.length - 1; + } + } + } + + // Add new edges + for (const newEdge of processedEdges) { + // Only add edges where both source and target exist in the graph + if (state.sigmaGraph.hasNode(newEdge.source) && state.sigmaGraph.hasNode(newEdge.target)) { + // Skip if edge already exists + if (state.sigmaGraph.hasEdge(newEdge.source, newEdge.target)) { + continue; + } + + // Add the edge to the sigma graph + newEdge.dynamicId = state.sigmaGraph.addDirectedEdge(newEdge.source, newEdge.target, { + label: newEdge.type || undefined + }); + + // Add the edge to the raw graph + if (!state.rawGraph.getEdge(newEdge.id, false)) { + // Add to edges array + state.rawGraph.edges.push(newEdge); + // Update edgeIdMap + state.rawGraph.edgeIdMap[newEdge.id] = state.rawGraph.edges.length - 1; + // Update dynamic edge map + state.rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = state.rawGraph.edges.length - 1; + } + } + } + + // Update the dynamic edge map + state.rawGraph.buildDynamicMap(); + + // Restore positions for existing nodes + Object.entries(nodePositions).forEach(([nodeId, position]) => { + if (state.sigmaGraph!.hasNode(nodeId)) { + state.sigmaGraph!.setNodeAttribute(nodeId, 'x', position.x); + state.sigmaGraph!.setNodeAttribute(nodeId, 'y', position.y); + } + }); + + // Refresh the layout + state.refreshLayout(); + + } catch (error) { + console.error('Error expanding node:', error); + } finally { + // Reset fetching state + state.setIsFetching(false); + } + }, + + pruneNode: (nodeId: string) => { + const state = get(); + if (!state.sigmaGraph || !state.rawGraph || !nodeId) { + console.error('Cannot prune node: graph or node not available'); + return; + } + + try { + // Check if the node exists + if (!state.sigmaGraph.hasNode(nodeId)) { + console.error('Node not found:', nodeId); + return; + } + + // If the node is selected or focused, clear selection + if (state.selectedNode === nodeId || state.focusedNode === nodeId) { + state.clearSelection(); + } + + // Remove the node from the sigma graph (this will also remove connected edges) + state.sigmaGraph.dropNode(nodeId); + + // Remove the node from the raw graph + const nodeIndex = state.rawGraph.nodeIdMap[nodeId]; + if (nodeIndex !== undefined) { + // Find all edges connected to this node + const edgesToRemove = state.rawGraph.edges.filter( + edge => edge.source === nodeId || edge.target === nodeId + ); + + // Remove edges from raw graph + for (const edge of edgesToRemove) { + const edgeIndex = state.rawGraph.edgeIdMap[edge.id]; + if (edgeIndex !== undefined) { + // Remove from edges array + state.rawGraph.edges.splice(edgeIndex, 1); + // Update edgeIdMap for all edges after this one + for (const [id, idx] of Object.entries(state.rawGraph.edgeIdMap)) { + if (idx > edgeIndex) { + state.rawGraph.edgeIdMap[id] = idx - 1; + } + } + // Remove from edgeIdMap + delete state.rawGraph.edgeIdMap[edge.id]; + // Remove from edgeDynamicIdMap + delete state.rawGraph.edgeDynamicIdMap[edge.dynamicId]; + } + } + + // Remove node from nodes array + state.rawGraph.nodes.splice(nodeIndex, 1); + + // Update nodeIdMap for all nodes after this one + for (const [id, idx] of Object.entries(state.rawGraph.nodeIdMap)) { + if (idx > nodeIndex) { + state.rawGraph.nodeIdMap[id] = idx - 1; + } + } + + // Remove from nodeIdMap + delete state.rawGraph.nodeIdMap[nodeId]; + + // Rebuild the dynamic edge map + state.rawGraph.buildDynamicMap(); + } + + } catch (error) { + console.error('Error pruning node:', error); + } + } })) const useGraphStore = createSelectors(useGraphStoreBase) From 20c976584b3fc1981b4638b046bebcd5d845863b Mon Sep 17 00:00:00 2001 From: yangdx Date: Fri, 14 Mar 2025 16:45:02 +0800 Subject: [PATCH 04/67] Fix linting --- .../src/components/graph/PropertiesView.tsx | 22 ++-- lightrag_webui/src/hooks/useLightragGraph.tsx | 116 +++++++++--------- lightrag_webui/src/stores/graph.ts | 82 ++++++------- 3 files changed, 110 insertions(+), 110 deletions(-) diff --git a/lightrag_webui/src/components/graph/PropertiesView.tsx b/lightrag_webui/src/components/graph/PropertiesView.tsx index 4ba89be5..3a5ea990 100644 --- a/lightrag_webui/src/components/graph/PropertiesView.tsx +++ b/lightrag_webui/src/components/graph/PropertiesView.tsx @@ -159,33 +159,33 @@ const PropertyRow = ({ const NodePropertiesView = ({ node }: { node: NodeType }) => { const { t } = useTranslation() - + const handleExpandNode = () => { useGraphStore.getState().triggerNodeExpand(node.id) } - + const handlePruneNode = () => { useGraphStore.getState().triggerNodePrune(node.id) } - + return (
- - diff --git a/lightrag_webui/src/features/LoginPage.tsx b/lightrag_webui/src/features/LoginPage.tsx index ceb7e337..a79e225c 100644 --- a/lightrag_webui/src/features/LoginPage.tsx +++ b/lightrag_webui/src/features/LoginPage.tsx @@ -9,8 +9,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/Card' import Input from '@/components/ui/Input' import Button from '@/components/ui/Button' import { ZapIcon } from 'lucide-react' -import ThemeToggle from '@/components/ThemeToggle' -import LanguageToggle from '@/components/LanguageToggle' +import AppSettings from '@/components/AppSettings' const LoginPage = () => { const navigate = useNavigate() @@ -44,8 +43,7 @@ const LoginPage = () => { return (
- - +
diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index 4f494026..1cdc7531 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -1,7 +1,6 @@ import Button from '@/components/ui/Button' -import { SiteInfo } from '@/lib/constants' +import { SiteInfo, webuiPrefix } from '@/lib/constants' import AppSettings from '@/components/AppSettings' -import LanguageToggle from '@/components/LanguageToggle' import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { useSettingsStore } from '@/stores/settings' import { useAuthStore } from '@/stores/state' @@ -67,7 +66,7 @@ export default function SiteHeader() { return (
- + +
diff --git a/lightrag_webui/src/lib/constants.ts b/lightrag_webui/src/lib/constants.ts index 47b1124f..5f960b30 100644 --- a/lightrag_webui/src/lib/constants.ts +++ b/lightrag_webui/src/lib/constants.ts @@ -1,6 +1,6 @@ import { ButtonVariantType } from '@/components/ui/Button' -export const backendBaseUrl = 'http://localhost:9621/' +export const backendBaseUrl = '' export const webuiPrefix = '' export const controlButtonVariant: ButtonVariantType = 'ghost' diff --git a/lightrag_webui/src/locales/en.json b/lightrag_webui/src/locales/en.json index 7e9623f2..079b784d 100644 --- a/lightrag_webui/src/locales/en.json +++ b/lightrag_webui/src/locales/en.json @@ -12,6 +12,7 @@ "retrieval": "Retrieval", "api": "API", "projectRepository": "Project Repository", + "logout": "Logout", "themeToggle": { "switchToLight": "Switch to light theme", "switchToDark": "Switch to dark theme" diff --git a/lightrag_webui/src/locales/zh.json b/lightrag_webui/src/locales/zh.json index 57c3d5ad..9cb0cf2f 100644 --- a/lightrag_webui/src/locales/zh.json +++ b/lightrag_webui/src/locales/zh.json @@ -12,6 +12,7 @@ "retrieval": "检索", "api": "API", "projectRepository": "项目仓库", + "logout": "退出登录", "themeToggle": { "switchToLight": "切换到浅色主题", "switchToDark": "切换到深色主题" From f4fceca7f30bc2739eb5f3dfc92383aa3610c27f Mon Sep 17 00:00:00 2001 From: yangdx Date: Sat, 15 Mar 2025 10:52:47 +0800 Subject: [PATCH 12/67] Refactor graph search to update search engin after node expand or prune --- .../src/components/graph/GraphSearch.tsx | 64 +++++++++++-------- .../src/components/graph/graphSearchTypes.ts | 5 ++ .../src/components/graph/graphSearchUtils.ts | 28 ++++++++ lightrag_webui/src/hooks/useLightragGraph.tsx | 17 ++++- 4 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 lightrag_webui/src/components/graph/graphSearchTypes.ts create mode 100644 lightrag_webui/src/components/graph/graphSearchUtils.ts diff --git a/lightrag_webui/src/components/graph/GraphSearch.tsx b/lightrag_webui/src/components/graph/GraphSearch.tsx index 2ba36bda..0570acf3 100644 --- a/lightrag_webui/src/components/graph/GraphSearch.tsx +++ b/lightrag_webui/src/components/graph/GraphSearch.tsx @@ -10,29 +10,27 @@ import { searchResultLimit } from '@/lib/constants' import { useGraphStore } from '@/stores/graph' import MiniSearch from 'minisearch' import { useTranslation } from 'react-i18next' +import { OptionItem } from './graphSearchTypes' +import { messageId, searchCache } from './graphSearchUtils' -interface OptionItem { - id: string - type: 'nodes' | 'edges' | 'message' - message?: string +const NodeOption = ({ id }: { id: string }) => { + const graph = useGraphStore.use.sigmaGraph() + if (!graph?.hasNode(id)) { + return null + } + return } function OptionComponent(item: OptionItem) { return (
- {item.type === 'nodes' && } + {item.type === 'nodes' && } {item.type === 'edges' && } {item.type === 'message' &&
{item.message}
}
) } -const messageId = '__message_item' -// Reset this cache when graph changes to ensure fresh search results -const lastGraph: any = { - graph: null, - searchEngine: null -} /** * Component thats display the search input. @@ -53,18 +51,18 @@ export const GraphSearchInput = ({ useEffect(() => { if (graph) { // Reset cache to ensure fresh search results with new graph data - lastGraph.graph = null; - lastGraph.searchEngine = null; + searchCache.graph = null; + searchCache.searchEngine = null; } }, [graph]); const searchEngine = useMemo(() => { - if (lastGraph.graph == graph) { - return lastGraph.searchEngine + if (searchCache.graph == graph) { + return searchCache.searchEngine } if (!graph || graph.nodes().length == 0) return - lastGraph.graph = graph + searchCache.graph = graph const searchEngine = new MiniSearch({ idField: 'id', @@ -85,7 +83,7 @@ export const GraphSearchInput = ({ })) searchEngine.addAll(documents) - lastGraph.searchEngine = searchEngine + searchCache.searchEngine = searchEngine return searchEngine }, [graph]) @@ -95,22 +93,38 @@ export const GraphSearchInput = ({ const loadOptions = useCallback( async (query?: string): Promise => { if (onFocus) onFocus(null) - if (!graph || !searchEngine) return [] + + // Safety checks to prevent crashes + if (!graph || !searchEngine) { + // Reset cache to ensure fresh search engine initialization on next render + searchCache.graph = null + searchCache.searchEngine = null + return [] + } - // If no query, return first searchResultLimit nodes + // Verify graph has nodes before proceeding + if (graph.nodes().length === 0) { + return [] + } + + // If no query, return first searchResultLimit nodes that exist if (!query) { - const nodeIds = graph.nodes().slice(0, searchResultLimit) + const nodeIds = graph.nodes() + .filter(id => graph.hasNode(id)) + .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' - })) + // If has query, search nodes and verify they still exist + const result: OptionItem[] = searchEngine.search(query) + .filter((r: { id: string }) => graph.hasNode(r.id)) + .map((r: { id: string }) => ({ + id: r.id, + type: 'nodes' + })) // prettier-ignore return result.length <= searchResultLimit diff --git a/lightrag_webui/src/components/graph/graphSearchTypes.ts b/lightrag_webui/src/components/graph/graphSearchTypes.ts new file mode 100644 index 00000000..80e392da --- /dev/null +++ b/lightrag_webui/src/components/graph/graphSearchTypes.ts @@ -0,0 +1,5 @@ +export interface OptionItem { + id: string + type: 'nodes' | 'edges' | 'message' + message?: string +} diff --git a/lightrag_webui/src/components/graph/graphSearchUtils.ts b/lightrag_webui/src/components/graph/graphSearchUtils.ts new file mode 100644 index 00000000..6f4cd4c9 --- /dev/null +++ b/lightrag_webui/src/components/graph/graphSearchUtils.ts @@ -0,0 +1,28 @@ +import { DirectedGraph } from 'graphology' +import MiniSearch from 'minisearch' + +export const messageId = '__message_item' + +// Reset this cache when graph changes to ensure fresh search results +export const searchCache: { + graph: DirectedGraph | null; + searchEngine: MiniSearch | null; +} = { + graph: null, + searchEngine: null +} + +export const updateSearchEngine = (nodeId: string, graph: DirectedGraph) => { + if (!searchCache.searchEngine || !graph) return + + const newDocument = { + id: nodeId, + label: graph.getNodeAttribute(nodeId, 'label') + } + searchCache.searchEngine.add(newDocument) +} + +export const removeFromSearchEngine = (nodeId: string) => { + if (!searchCache.searchEngine) return + searchCache.searchEngine.remove({ id: nodeId }) +} diff --git a/lightrag_webui/src/hooks/useLightragGraph.tsx b/lightrag_webui/src/hooks/useLightragGraph.tsx index 98c7fc0a..bf727138 100644 --- a/lightrag_webui/src/hooks/useLightragGraph.tsx +++ b/lightrag_webui/src/hooks/useLightragGraph.tsx @@ -11,6 +11,7 @@ import { useSettingsStore } from '@/stores/settings' import { useTabVisibility } from '@/contexts/useTabVisibility' import seedrandom from 'seedrandom' +import { searchCache, updateSearchEngine, removeFromSearchEngine } from '@/components/graph/graphSearchUtils' const validateGraph = (graph: RawGraph) => { if (!graph) { @@ -544,6 +545,8 @@ const useLightrangeGraph = () => { rawGraph.nodes.push(newNode); // Update nodeIdMap rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1; + // Update search engine with new node + updateSearchEngine(nodeId, sigmaGraph); } } @@ -572,8 +575,12 @@ const useLightrangeGraph = () => { } } - // Update the dynamic edge map + // Update the dynamic edge map and invalidate search cache rawGraph.buildDynamicMap(); + + // Force search engine rebuild by invalidating cache + searchCache.graph = null; + searchCache.searchEngine = null; // STEP 4: Update the expanded node's size if (sigmaGraph.hasNode(nodeId)) { @@ -710,11 +717,17 @@ const useLightrangeGraph = () => { // Remove from nodeIdMap delete rawGraph.nodeIdMap[nodeToDelete]; + // Remove from search engine + removeFromSearchEngine(nodeToDelete); } } - // Rebuild the dynamic edge map + // Rebuild the dynamic edge map and invalidate search cache rawGraph.buildDynamicMap(); + + // Force search engine rebuild by invalidating cache + searchCache.graph = null; + searchCache.searchEngine = null; // Show notification if we deleted more than just the selected node if (nodesToDelete.size > 1) { From abda12f63b9b0f7d298a0193eb155912770a97ef Mon Sep 17 00:00:00 2001 From: yangdx Date: Sat, 15 Mar 2025 11:45:10 +0800 Subject: [PATCH 13/67] style: change double quotes to single quotes in ZoomControl.tsx --- lightrag_webui/src/components/graph/ZoomControl.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lightrag_webui/src/components/graph/ZoomControl.tsx b/lightrag_webui/src/components/graph/ZoomControl.tsx index 0aa55416..a0a41a08 100644 --- a/lightrag_webui/src/components/graph/ZoomControl.tsx +++ b/lightrag_webui/src/components/graph/ZoomControl.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react' import Button from '@/components/ui/Button' import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react' import { controlButtonVariant } from '@/lib/constants' -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; /** * Component that provides zoom controls for the graph viewer. @@ -18,16 +18,16 @@ const ZoomControl = () => { return ( <> - -
@@ -254,7 +254,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => { />
- +
{Object.keys(node.properties) .sort() @@ -264,7 +264,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{node.relationships.length > 0 && ( <> -