Merge branch 'HKUDS:main' into main
This commit is contained in:
@@ -40,9 +40,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.6",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"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",
|
||||
@@ -418,6 +420,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=="],
|
||||
@@ -566,6 +570,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=="],
|
||||
@@ -1102,6 +1108,8 @@
|
||||
|
||||
"react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="],
|
||||
|
||||
"react-error-boundary": ["react-error-boundary@5.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="],
|
||||
|
||||
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
@@ -1114,6 +1122,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=="],
|
||||
@@ -1164,6 +1176,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=="],
|
||||
@@ -1234,6 +1248,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=="],
|
||||
|
2
lightrag_webui/env.development.smaple
Normal file
2
lightrag_webui/env.development.smaple
Normal file
@@ -0,0 +1,2 @@
|
||||
# Development environment configuration
|
||||
VITE_BACKEND_URL=/api
|
3
lightrag_webui/env.local.sample
Normal file
3
lightrag_webui/env.local.sample
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_BACKEND_URL=http://localhost:9621
|
||||
VITE_API_PROXY=true
|
||||
VITE_API_ENDPOINTS=/,/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login,/auth-status
|
@@ -5,7 +5,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag</title>
|
||||
</head>
|
||||
|
@@ -49,9 +49,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.6",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"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",
|
||||
|
@@ -8,7 +8,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'
|
||||
|
||||
@@ -27,8 +26,6 @@ function App() {
|
||||
|
||||
// Health check
|
||||
useEffect(() => {
|
||||
if (!enableHealthCheck) return
|
||||
|
||||
// Check immediately
|
||||
useBackendState.getState().check()
|
||||
|
||||
@@ -56,24 +53,24 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TabVisibilityProvider>
|
||||
<main className="flex h-screen w-screen overflow-x-hidden">
|
||||
<main className="flex h-screen w-screen overflow-hidden">
|
||||
<Tabs
|
||||
defaultValue={currentTab}
|
||||
className="!m-0 flex grow flex-col !p-0"
|
||||
className="!m-0 flex grow flex-col !p-0 overflow-hidden"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<SiteHeader />
|
||||
<div className="relative grow">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0 overflow-auto">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<RetrievalTesting />
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<ApiSite />
|
||||
</TabsContent>
|
||||
</div>
|
||||
@@ -81,7 +78,6 @@ function App() {
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
||||
{apiKeyInvalid && <ApiKeyAlert />}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TabVisibilityProvider>
|
||||
</ThemeProvider>
|
||||
|
190
lightrag_webui/src/AppRouter.tsx
Normal file
190
lightrag_webui/src/AppRouter.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { getAuthStatus } from '@/api/lightrag'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import LoginPage from '@/features/LoginPage'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
// This effect will run when the component mounts
|
||||
// and will check if authentication is required
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
// Skip check if already authenticated
|
||||
if (isAuthenticated) {
|
||||
if (isMounted) setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token
|
||||
useAuthStore.getState().login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth status:', error)
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthStatus()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Handle navigation when authentication status changes
|
||||
useEffect(() => {
|
||||
if (!isChecking && !isAuthenticated) {
|
||||
const currentPath = window.location.hash.slice(1); // Remove the '#' from hash
|
||||
const isLoginPage = currentPath === '/login';
|
||||
|
||||
if (!isLoginPage) {
|
||||
// Use navigation service for redirection
|
||||
console.log('Not authenticated, redirecting to login');
|
||||
navigationService.navigateToLogin();
|
||||
}
|
||||
}
|
||||
}, [isChecking, isAuthenticated]);
|
||||
|
||||
// Show nothing while checking auth status or when not authenticated on login page
|
||||
if (isChecking || (!isAuthenticated && window.location.hash.slice(1) === '/login')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show children only when authenticated
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const AppContent = () => {
|
||||
const [initializing, setInitializing] = useState(true)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
// Check token validity and auth configuration on app initialization
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
|
||||
|
||||
// If we have a token, we're already authenticated
|
||||
if (token && isAuthenticated) {
|
||||
if (isMounted) setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no token or not authenticated, check if auth is configured
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token
|
||||
useAuthStore.getState().login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
} else if (!token) {
|
||||
// Only logout if we don't have a token
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error)
|
||||
if (isMounted && !isAuthenticated) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setInitializing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuth()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Show nothing while initializing
|
||||
if (initializing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const AppRouter = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
<Toaster position="bottom-center" />
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRouter
|
@@ -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 { navigationService } from '@/services/navigation'
|
||||
|
||||
// Types
|
||||
export type LightragNodeType = {
|
||||
@@ -125,6 +126,21 @@ export type DocsStatusesResponse = {
|
||||
statuses: Record<DocStatus, DocStatusResponse[]>
|
||||
}
|
||||
|
||||
export type AuthStatusResponse = {
|
||||
auth_configured: boolean
|
||||
access_token?: string
|
||||
token_type?: string
|
||||
auth_mode?: 'enabled' | 'disabled'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type LoginResponse = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
|
||||
message?: string // Optional message
|
||||
}
|
||||
|
||||
export const InvalidApiKeyError = 'Invalid API Key'
|
||||
export const RequireApiKeError = 'API Key required'
|
||||
|
||||
@@ -136,9 +152,15 @@ const axiosInstance = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
// Interceptor:add api key
|
||||
// Interceptor: add api key and check authentication
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const apiKey = useSettingsStore.getState().apiKey
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
|
||||
|
||||
// Always include token if it exists, regardless of path
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey
|
||||
}
|
||||
@@ -150,6 +172,16 @@ axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
if (error.response?.status === 401) {
|
||||
// For login API, throw error directly
|
||||
if (error.config?.url?.includes('/login')) {
|
||||
throw error;
|
||||
}
|
||||
// For other APIs, navigate to login page
|
||||
navigationService.navigateToLogin();
|
||||
// Return a never-resolving promise to prevent further execution
|
||||
return new Promise(() => {});
|
||||
}
|
||||
throw new Error(
|
||||
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
|
||||
error.response.data
|
||||
@@ -324,3 +356,74 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
|
||||
const response = await axiosInstance.delete('/documents')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getAuthStatus = async (): Promise<AuthStatusResponse> => {
|
||||
try {
|
||||
// Add a timeout to the request to prevent hanging
|
||||
const response = await axiosInstance.get('/auth-status', {
|
||||
timeout: 5000, // 5 second timeout
|
||||
headers: {
|
||||
'Accept': 'application/json' // Explicitly request JSON
|
||||
}
|
||||
});
|
||||
|
||||
// Check if response is HTML (which indicates a redirect or wrong endpoint)
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
console.warn('Received HTML response instead of JSON for auth-status endpoint');
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
}
|
||||
|
||||
// Strict validation of the response data
|
||||
if (response.data &&
|
||||
typeof response.data === 'object' &&
|
||||
'auth_configured' in response.data &&
|
||||
typeof response.data.auth_configured === 'boolean') {
|
||||
|
||||
// For unconfigured auth, ensure we have an access token
|
||||
if (!response.data.auth_configured) {
|
||||
if (response.data.access_token && typeof response.data.access_token === 'string') {
|
||||
return response.data;
|
||||
} else {
|
||||
console.warn('Auth not configured but no valid access token provided');
|
||||
}
|
||||
} else {
|
||||
// For configured auth, just return the data
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// If response data is invalid but we got a response, log it
|
||||
console.warn('Received invalid auth status response:', response.data);
|
||||
|
||||
// Default to auth configured if response is invalid
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
} catch (error) {
|
||||
// If the request fails, assume authentication is configured
|
||||
console.error('Failed to get auth status:', errorMessage(error));
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
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;
|
||||
}
|
||||
|
@@ -5,8 +5,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { PaletteIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function AppSettings() {
|
||||
interface AppSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AppSettings({ className }: AppSettingsProps) {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -27,7 +32,7 @@ export default function AppSettings() {
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
|
||||
<PaletteIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
@@ -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 (
|
||||
<Button
|
||||
onClick={setEnglish}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to English"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
中
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setChinese}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="切换到中文"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
EN
|
||||
</Button>
|
||||
)
|
||||
}
|
@@ -13,23 +13,37 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
|
||||
* When the selected item changes, highlighted the node and center the camera on it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const graph = sigma.getGraph();
|
||||
|
||||
if (move) {
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
gotoNode(node)
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
gotoNode(node);
|
||||
} catch (error) {
|
||||
console.error('Error focusing on node:', error);
|
||||
}
|
||||
} else {
|
||||
// If no node is selected but move is true, reset to default view
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
|
||||
sigma.setCustomBBox(null);
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false);
|
||||
} else if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting node:', error);
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false)
|
||||
} else if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', false);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up node highlight:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import Graph from 'graphology'
|
||||
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import { AbstractGraph } from 'graphology-types'
|
||||
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useEffect } from 'react'
|
||||
@@ -25,7 +25,6 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
||||
|
||||
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
||||
const { assign: assignLayout } = useLayoutForceAtlas2({
|
||||
@@ -45,14 +44,42 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
|
||||
/**
|
||||
* When component mount or maxIterations changes
|
||||
* => load the graph and apply layout
|
||||
* => ensure graph reference and apply layout
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigmaGraph) {
|
||||
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
|
||||
assignLayout()
|
||||
if (sigmaGraph && sigma) {
|
||||
// Ensure sigma binding to sigmaGraph
|
||||
try {
|
||||
if (typeof sigma.setGraph === 'function') {
|
||||
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);
|
||||
console.log('Binding graph to sigma instance');
|
||||
} else {
|
||||
(sigma as any).graph = sigmaGraph;
|
||||
console.warn('Simgma missing setGraph function, set graph property directly');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting graph on sigma instance:', error);
|
||||
}
|
||||
|
||||
assignLayout();
|
||||
console.log('Initial layout applied to graph');
|
||||
}
|
||||
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
|
||||
}, [sigma, sigmaGraph, assignLayout, maxIterations])
|
||||
|
||||
/**
|
||||
* Ensure the sigma instance is set in the store
|
||||
* This provides a backup in case the instance wasn't set in GraphViewer
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigma) {
|
||||
// Double-check that the store has the sigma instance
|
||||
const currentInstance = useGraphStore.getState().sigmaInstance;
|
||||
if (!currentInstance) {
|
||||
console.log('Setting sigma instance from GraphControl');
|
||||
useGraphStore.getState().setSigmaInstance(sigma);
|
||||
}
|
||||
}
|
||||
}, [sigma]);
|
||||
|
||||
/**
|
||||
* When component mount
|
||||
@@ -138,14 +165,18 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
const _focusedEdge = focusedEdge || selectedEdge
|
||||
|
||||
if (_focusedNode) {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||
try {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in nodeReducer:', error);
|
||||
}
|
||||
} else if (_focusedEdge) {
|
||||
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
|
||||
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||
newData.highlighted = true
|
||||
newData.size = 3
|
||||
@@ -173,21 +204,28 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
if (!disableHoverEffect) {
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
|
||||
if (_focusedNode) {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||
try {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in edgeReducer:', error);
|
||||
}
|
||||
} else {
|
||||
if (focusedEdge || selectedEdge) {
|
||||
if (edge === selectedEdge) {
|
||||
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
|
||||
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
|
||||
|
||||
if (_selectedEdge || _focusedEdge) {
|
||||
if (edge === _selectedEdge) {
|
||||
newData.color = Constants.edgeColorSelected
|
||||
} else if (edge === focusedEdge) {
|
||||
} else if (edge === _focusedEdge) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
} else if (hideUnselectedEdges) {
|
||||
newData.hidden = true
|
||||
|
@@ -2,20 +2,23 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { labelListLimit } from '@/lib/constants'
|
||||
import { labelListLimit, controlButtonVariant } from '@/lib/constants'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
const GraphLabels = () => {
|
||||
const { t } = useTranslation()
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
|
||||
const rawGraph = useGraphStore.use.rawGraph()
|
||||
const labelsLoadedRef = useRef(false)
|
||||
|
||||
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
||||
const fetchInProgressRef = useRef(false)
|
||||
|
||||
// Fetch labels once on component mount, using global flag to prevent duplicates
|
||||
// Fetch labels and trigger initial data load
|
||||
useEffect(() => {
|
||||
// Check if we've already attempted to fetch labels in this session
|
||||
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
|
||||
@@ -26,8 +29,6 @@ const GraphLabels = () => {
|
||||
// Set global flag to indicate we've attempted to fetch in this session
|
||||
useGraphStore.getState().setLabelsFetchAttempted(true)
|
||||
|
||||
console.log('Fetching graph labels (once per session)...')
|
||||
|
||||
useGraphStore.getState().fetchAllDatabaseLabels()
|
||||
.then(() => {
|
||||
labelsLoadedRef.current = true
|
||||
@@ -42,6 +43,14 @@ const GraphLabels = () => {
|
||||
}
|
||||
}, []) // Empty dependency array ensures this only runs once on mount
|
||||
|
||||
// Trigger data load when labels are loaded
|
||||
useEffect(() => {
|
||||
if (labelsLoadedRef.current) {
|
||||
// Reset the fetch attempted flag to force a new data fetch
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
}
|
||||
}, [label])
|
||||
|
||||
const getSearchEngine = useCallback(() => {
|
||||
// Create search engine
|
||||
const searchEngine = new MiniSearch({
|
||||
@@ -83,52 +92,73 @@ const GraphLabels = () => {
|
||||
[getSearchEngine]
|
||||
)
|
||||
|
||||
return (
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const handleRefresh = useCallback(() => {
|
||||
// Reset labels fetch status to allow fetching labels again
|
||||
useGraphStore.getState().setLabelsFetchAttempted(false)
|
||||
|
||||
// Reset graph data fetch status directly, not depending on allDatabaseLabels changes
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
|
||||
// Fetch all labels again
|
||||
useGraphStore.getState().fetchAllDatabaseLabels()
|
||||
.then(() => {
|
||||
// Trigger a graph data reload by changing the query label back and forth
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to refresh labels:', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*'
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{rawGraph && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRefresh}
|
||||
tooltip={t('graphPanel.graphLabels.refreshTooltip')}
|
||||
className="mr-1"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
|
||||
// Reset the fetch attempted flag to force a new data fetch
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
|
||||
// Clear current graph data to ensure complete reload when label changes
|
||||
if (newLabel !== currentLabel) {
|
||||
const graphStore = useGraphStore.getState();
|
||||
graphStore.clearSelection();
|
||||
|
||||
// Reset the graph state but preserve the instance
|
||||
if (graphStore.sigmaGraph) {
|
||||
const nodes = Array.from(graphStore.sigmaGraph.nodes());
|
||||
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*'
|
||||
}
|
||||
}
|
||||
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
// reselect the same itme means qery all
|
||||
useSettingsStore.getState().setQueryLabel('*')
|
||||
} else {
|
||||
// Handle reselecting the same label
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
newLabel = '*'
|
||||
}
|
||||
|
||||
// Update the label, which will trigger the useEffect to handle data loading
|
||||
useSettingsStore.getState().setQueryLabel(newLabel)
|
||||
}
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
/>
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useEffect, useMemo } from 'react'
|
||||
import { FC, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
NodeById,
|
||||
@@ -11,28 +11,34 @@ import { useGraphStore } from '@/stores/graph'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface OptionItem {
|
||||
// Message item identifier for search results
|
||||
export const messageId = '__message_item'
|
||||
|
||||
// Search result option item interface
|
||||
export 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 <NodeById id={id} />
|
||||
}
|
||||
|
||||
function OptionComponent(item: OptionItem) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === 'nodes' && <NodeById id={item.id} />}
|
||||
{item.type === 'nodes' && <NodeOption id={item.id} />}
|
||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||
{item.type === 'message' && <div>{item.message}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -48,25 +54,24 @@ export const GraphSearchInput = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
const searchEngine = useGraphStore.use.searchEngine()
|
||||
|
||||
// Force reset the cache when graph changes
|
||||
// Reset search engine when graph changes
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
// Reset cache to ensure fresh search results with new graph data
|
||||
lastGraph.graph = null;
|
||||
lastGraph.searchEngine = null;
|
||||
useGraphStore.getState().resetSearchEngine()
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
if (lastGraph.graph == graph) {
|
||||
return lastGraph.searchEngine
|
||||
// Create search engine when needed
|
||||
useEffect(() => {
|
||||
// Skip if no graph, empty graph, or search engine already exists
|
||||
if (!graph || graph.nodes().length === 0 || searchEngine) {
|
||||
return
|
||||
}
|
||||
if (!graph || graph.nodes().length == 0) return
|
||||
|
||||
lastGraph.graph = graph
|
||||
|
||||
const searchEngine = new MiniSearch({
|
||||
// Create new search engine
|
||||
const newSearchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
fields: ['label'],
|
||||
searchOptions: {
|
||||
@@ -78,16 +83,16 @@ export const GraphSearchInput = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Add documents
|
||||
// Add nodes to search engine
|
||||
const documents = graph.nodes().map((id: string) => ({
|
||||
id: id,
|
||||
label: graph.getNodeAttribute(id, 'label')
|
||||
}))
|
||||
searchEngine.addAll(documents)
|
||||
newSearchEngine.addAll(documents)
|
||||
|
||||
lastGraph.searchEngine = searchEngine
|
||||
return searchEngine
|
||||
}, [graph])
|
||||
// Update search engine in store
|
||||
useGraphStore.getState().setSearchEngine(newSearchEngine)
|
||||
}, [graph, searchEngine])
|
||||
|
||||
/**
|
||||
* Loading the options while the user is typing.
|
||||
@@ -95,22 +100,35 @@ export const GraphSearchInput = ({
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
if (!graph || !searchEngine) return []
|
||||
|
||||
// If no query, return first searchResultLimit nodes
|
||||
// Safety checks to prevent crashes
|
||||
if (!graph || !searchEngine) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Verify graph has nodes before proceeding
|
||||
if (graph.nodes().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// If no query, return some nodes for user to select
|
||||
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
|
||||
|
@@ -7,7 +7,7 @@ import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
||||
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
||||
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
@@ -26,43 +26,161 @@ type LayoutName =
|
||||
| 'Force Directed'
|
||||
| 'Force Atlas'
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
|
||||
// Extend WorkerLayoutControlProps to include mainLayout
|
||||
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {
|
||||
mainLayout: LayoutHook;
|
||||
}
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {
|
||||
const sigma = useSigma()
|
||||
const { stop, start, isRunning } = layout
|
||||
// Use local state to track animation running status
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
// Timer reference for animation
|
||||
const animationTimerRef = useRef<number | null>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Function to update node positions using the layout algorithm
|
||||
const updatePositions = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
try {
|
||||
const graph = sigma.getGraph()
|
||||
if (!graph || graph.order === 0) return
|
||||
|
||||
// Use mainLayout to get positions, similar to refreshLayout function
|
||||
// console.log('Getting positions from mainLayout')
|
||||
const positions = mainLayout.positions()
|
||||
|
||||
// Animate nodes to new positions
|
||||
// console.log('Updating node positions with layout algorithm')
|
||||
animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates
|
||||
} catch (error) {
|
||||
console.error('Error updating positions:', error)
|
||||
// Stop animation if there's an error
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
}, [sigma, mainLayout])
|
||||
|
||||
// Improved click handler that uses our own animation timer
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning) {
|
||||
// Stop the animation
|
||||
console.log('Stopping layout animation')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
|
||||
// Try to kill the layout algorithm if it's running
|
||||
try {
|
||||
if (typeof layout.kill === 'function') {
|
||||
layout.kill()
|
||||
console.log('Layout algorithm killed')
|
||||
} else if (typeof layout.stop === 'function') {
|
||||
layout.stop()
|
||||
console.log('Layout algorithm stopped')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping layout algorithm:', error)
|
||||
}
|
||||
|
||||
setIsRunning(false)
|
||||
} else {
|
||||
// Start the animation
|
||||
console.log('Starting layout animation')
|
||||
|
||||
// Initial position update
|
||||
updatePositions()
|
||||
|
||||
// Set up interval for continuous updates
|
||||
animationTimerRef.current = window.setInterval(() => {
|
||||
updatePositions()
|
||||
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
||||
|
||||
setIsRunning(true)
|
||||
|
||||
// Set a timeout to automatically stop the animation after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (animationTimerRef.current) {
|
||||
console.log('Auto-stopping layout animation after 3 seconds')
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
setIsRunning(false)
|
||||
|
||||
// Try to stop the layout algorithm
|
||||
try {
|
||||
if (typeof layout.kill === 'function') {
|
||||
layout.kill()
|
||||
} else if (typeof layout.stop === 'function') {
|
||||
layout.stop()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping layout algorithm:', error)
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}, [isRunning, layout, updatePositions])
|
||||
|
||||
/**
|
||||
* Init component when Sigma or component settings change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) {
|
||||
console.log('No sigma instance available')
|
||||
return
|
||||
}
|
||||
|
||||
// we run the algo
|
||||
// Auto-run if specified
|
||||
let timeout: number | null = null
|
||||
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
||||
start()
|
||||
// set a timeout to stop it
|
||||
timeout =
|
||||
autoRunFor > 0
|
||||
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
||||
: null
|
||||
}
|
||||
console.log('Auto-starting layout animation')
|
||||
|
||||
//cleaning
|
||||
return () => {
|
||||
stop()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
// Initial position update
|
||||
updatePositions()
|
||||
|
||||
// Set up interval for continuous updates
|
||||
animationTimerRef.current = window.setInterval(() => {
|
||||
updatePositions()
|
||||
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
||||
|
||||
setIsRunning(true)
|
||||
|
||||
// Set a timeout to stop it if autoRunFor > 0
|
||||
if (autoRunFor > 0) {
|
||||
timeout = window.setTimeout(() => {
|
||||
console.log('Auto-stopping layout animation after timeout')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
setIsRunning(false)
|
||||
}, autoRunFor)
|
||||
}
|
||||
}
|
||||
}, [autoRunFor, start, stop, sigma])
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// console.log('Cleaning up WorkerLayoutControl')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
setIsRunning(false)
|
||||
}
|
||||
}, [autoRunFor, sigma, updatePositions])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => (isRunning ? stop() : start())}
|
||||
onClick={handleClick}
|
||||
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
|
||||
variant={controlButtonVariant}
|
||||
>
|
||||
@@ -85,8 +203,27 @@ const LayoutsControl = () => {
|
||||
const layoutCircular = useLayoutCircular()
|
||||
const layoutCirclepack = useLayoutCirclepack()
|
||||
const layoutRandom = useLayoutRandom()
|
||||
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
|
||||
const layoutForce = useLayoutForce({ maxIterations: maxIterations })
|
||||
const layoutNoverlap = useLayoutNoverlap({
|
||||
maxIterations: maxIterations,
|
||||
settings: {
|
||||
margin: 5,
|
||||
expansion: 1.1,
|
||||
gridSize: 1,
|
||||
ratio: 1,
|
||||
speed: 3,
|
||||
}
|
||||
})
|
||||
// Add parameters for Force Directed layout to improve convergence
|
||||
const layoutForce = useLayoutForce({
|
||||
maxIterations: maxIterations,
|
||||
settings: {
|
||||
attraction: 0.0003, // Lower attraction force to reduce oscillation
|
||||
repulsion: 0.05, // Lower repulsion force to reduce oscillation
|
||||
gravity: 0.01, // Increase gravity to make nodes converge to center faster
|
||||
inertia: 0.4, // Lower inertia to add damping effect
|
||||
maxMove: 100 // Limit maximum movement per step to prevent large jumps
|
||||
}
|
||||
})
|
||||
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
|
||||
const workerNoverlap = useWorkerLayoutNoverlap()
|
||||
const workerForce = useWorkerLayoutForce()
|
||||
@@ -130,10 +267,23 @@ const LayoutsControl = () => {
|
||||
|
||||
const runLayout = useCallback(
|
||||
(newLayout: LayoutName) => {
|
||||
console.debug(newLayout)
|
||||
console.debug('Running layout:', newLayout)
|
||||
const { positions } = layouts[newLayout].layout
|
||||
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
|
||||
setLayout(newLayout)
|
||||
|
||||
try {
|
||||
const graph = sigma.getGraph()
|
||||
if (!graph) {
|
||||
console.error('No graph available')
|
||||
return
|
||||
}
|
||||
|
||||
const pos = positions()
|
||||
console.log('Positions calculated, animating nodes')
|
||||
animateNodes(graph, pos, { duration: 400 })
|
||||
setLayout(newLayout)
|
||||
} catch (error) {
|
||||
console.error('Error running layout:', error)
|
||||
}
|
||||
},
|
||||
[layouts, sigma]
|
||||
)
|
||||
@@ -142,7 +292,10 @@ const LayoutsControl = () => {
|
||||
<>
|
||||
<div>
|
||||
{layouts[layout] && 'worker' in layouts[layout] && (
|
||||
<WorkerLayoutControl layout={layouts[layout].worker!} />
|
||||
<WorkerLayoutControl
|
||||
layout={layouts[layout].worker!}
|
||||
mainLayout={layouts[layout].layout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import Text from '@/components/ui/Text'
|
||||
import Button from '@/components/ui/Button'
|
||||
import useLightragGraph from '@/hooks/useLightragGraph'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GitBranchPlus, Scissors } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Component that view properties of elements in graph.
|
||||
@@ -88,22 +90,41 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const relationships = []
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
for (const edgeId of state.sigmaGraph.edges(node.id)) {
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
try {
|
||||
if (!state.sigmaGraph.hasNode(node.id)) {
|
||||
return {
|
||||
...node,
|
||||
relationships: []
|
||||
}
|
||||
}
|
||||
|
||||
const edges = state.sigmaGraph.edges(node.id)
|
||||
|
||||
for (const edgeId of edges) {
|
||||
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
|
||||
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
|
||||
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
|
||||
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining node properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
relationships
|
||||
@@ -112,8 +133,31 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
|
||||
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const sourceNode = state.rawGraph?.getNode(edge.source)
|
||||
const targetNode = state.rawGraph?.getNode(edge.target)
|
||||
let sourceNode: RawNodeType | undefined = undefined
|
||||
let targetNode: RawNodeType | undefined = undefined
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
try {
|
||||
if (!state.sigmaGraph.hasEdge(edge.id)) {
|
||||
return {
|
||||
...edge,
|
||||
sourceNode: undefined,
|
||||
targetNode: undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.source)) {
|
||||
sourceNode = state.rawGraph.getNode(edge.source)
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.target)) {
|
||||
targetNode = state.rawGraph.getNode(edge.target)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining edge properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
sourceNode,
|
||||
@@ -157,9 +201,40 @@ 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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handleExpandNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.expandNode')}
|
||||
>
|
||||
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handlePruneNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
|
||||
<PropertyRow
|
||||
@@ -171,7 +246,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
/>
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.node.properties')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(node.properties)
|
||||
.sort()
|
||||
@@ -181,7 +256,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
</div>
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-emerald-700">
|
||||
{t('graphPanel.propertiesView.node.relationships')}
|
||||
</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
@@ -208,7 +283,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">{t('graphPanel.propertiesView.edge.title')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
||||
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
||||
@@ -227,7 +302,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.edge.properties')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(edge.properties)
|
||||
.sort()
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback} from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Button from '@/components/ui/Button'
|
||||
@@ -7,10 +7,8 @@ import Input from '@/components/ui/Input'
|
||||
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
@@ -114,8 +112,6 @@ const LabeledNumberInput = ({
|
||||
*/
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const [tempApiKey, setTempApiKey] = useState<string>('')
|
||||
const refreshLayout = useGraphStore.use.refreshLayout()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
@@ -129,11 +125,6 @@ export default function Settings() {
|
||||
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
||||
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const apiKey = useSettingsStore.use.apiKey()
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(apiKey || '')
|
||||
}, [apiKey, opened])
|
||||
|
||||
const setEnableNodeDrag = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||
@@ -182,11 +173,22 @@ export default function Settings() {
|
||||
const setGraphQueryMaxDepth = useCallback((depth: number) => {
|
||||
if (depth < 1) return
|
||||
useSettingsStore.setState({ graphQueryMaxDepth: depth })
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const setGraphMinDegree = useCallback((degree: number) => {
|
||||
if (degree < 0) return
|
||||
useSettingsStore.setState({ graphMinDegree: degree })
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 300)
|
||||
|
||||
}, [])
|
||||
|
||||
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
|
||||
@@ -194,34 +196,19 @@ export default function Settings() {
|
||||
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
|
||||
}, [])
|
||||
|
||||
const setApiKey = useCallback(async () => {
|
||||
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
||||
await useBackendState.getState().check()
|
||||
setOpened(false)
|
||||
}, [tempApiKey])
|
||||
|
||||
const handleTempApiKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTempApiKey(e.target.value)
|
||||
},
|
||||
[setTempApiKey]
|
||||
)
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const saveSettings = () => setOpened(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
|
||||
size="icon"
|
||||
onClick={refreshLayout}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</Button>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.settings')}
|
||||
size="icon"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -303,30 +290,15 @@ export default function Settings() {
|
||||
onEditFinished={setGraphLayoutMaxIterations}
|
||||
/>
|
||||
<Separator />
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto px-4"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</label>
|
||||
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="w-0 flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
value={tempApiKey}
|
||||
onChange={handleTempApiKeyChange}
|
||||
placeholder={t('graphPanel.sideBar.settings.enterYourAPIkey')}
|
||||
className="max-h-full w-full min-w-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={setApiKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="max-h-full shrink-0"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
@@ -11,7 +11,7 @@ const SettingsDisplay = () => {
|
||||
const graphMinDegree = useSettingsStore.use.graphMinDegree()
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
||||
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
|
||||
</div>
|
||||
|
@@ -1,37 +1,107 @@
|
||||
import { useCamera } from '@react-sigma/core'
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } 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.
|
||||
*/
|
||||
const ZoomControl = () => {
|
||||
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||
const sigma = useSigma()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||
const handleResetZoom = useCallback(() => reset(), [reset])
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
try {
|
||||
// First clear any custom bounding box and refresh
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.refresh()
|
||||
|
||||
// Get graph after refresh
|
||||
const graph = sigma.getGraph()
|
||||
|
||||
// Check if graph has nodes before accessing them
|
||||
if (!graph?.order || graph.nodes().length === 0) {
|
||||
// Use reset() for empty graph case
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
sigma.getCamera().animate(
|
||||
{ x: 0.5, y: 0.5, ratio: 1.1 },
|
||||
{ duration: 1000 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error resetting zoom:', error)
|
||||
// Use reset() as fallback on error
|
||||
reset()
|
||||
}
|
||||
}, [sigma, reset])
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle + Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
const handleRotateCounterClockwise = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle - Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon">
|
||||
<ZoomInIcon />
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotateCounterClockwise}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon">
|
||||
<ZoomOutIcon />
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotate}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleResetZoom}
|
||||
tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
|
||||
size="icon"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -11,18 +11,16 @@ const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
|
@@ -38,7 +38,7 @@ const TooltipContent = React.forwardRef<
|
||||
side={side}
|
||||
align={align}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md z-60',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -15,16 +15,22 @@ export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ ch
|
||||
// Get current tab from settings store
|
||||
const currentTab = useSettingsStore.use.currentTab();
|
||||
|
||||
// Initialize visibility state with current tab as visible
|
||||
// Initialize visibility state with all tabs visible
|
||||
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
||||
[currentTab]: true
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
}));
|
||||
|
||||
// Update visibility when current tab changes
|
||||
// Keep all tabs visible because we use CSS to control TAB visibility instead of React
|
||||
useEffect(() => {
|
||||
setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
[currentTab]: true
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
}));
|
||||
}, [currentTab]);
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Table,
|
||||
@@ -27,9 +27,7 @@ export default function DocumentManager() {
|
||||
const { t } = useTranslation()
|
||||
const health = useBackendState.use.health()
|
||||
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isDocumentsTabVisible = isTabVisible('documents')
|
||||
const initialLoadRef = useRef(false)
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -45,7 +43,6 @@ export default function DocumentManager() {
|
||||
} else {
|
||||
setDocs(null)
|
||||
}
|
||||
// console.log(docs)
|
||||
} else {
|
||||
setDocs(null)
|
||||
}
|
||||
@@ -54,13 +51,12 @@ export default function DocumentManager() {
|
||||
}
|
||||
}, [setDocs, t])
|
||||
|
||||
// Only fetch documents when the tab becomes visible for the first time
|
||||
// Fetch documents when the tab becomes visible
|
||||
useEffect(() => {
|
||||
if (isDocumentsTabVisible && !initialLoadRef.current) {
|
||||
if (currentTab === 'documents') {
|
||||
fetchDocuments()
|
||||
initialLoadRef.current = true
|
||||
}
|
||||
}, [isDocumentsTabVisible, fetchDocuments])
|
||||
}, [currentTab, fetchDocuments])
|
||||
|
||||
const scanDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,9 +67,9 @@ export default function DocumentManager() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Only set up polling when the tab is visible and health is good
|
||||
// Set up polling when the documents tab is active and health is good
|
||||
useEffect(() => {
|
||||
if (!isDocumentsTabVisible || !health) {
|
||||
if (currentTab !== 'documents' || !health) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +82,7 @@ export default function DocumentManager() {
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [health, fetchDocuments, t, isDocumentsTabVisible])
|
||||
}, [health, fetchDocuments, t, currentTab])
|
||||
|
||||
return (
|
||||
<Card className="!size-full !rounded-none !border-none">
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
@@ -108,46 +107,46 @@ const GraphEvents = () => {
|
||||
const GraphViewer = () => {
|
||||
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
||||
const sigmaRef = useRef<any>(null)
|
||||
const initAttemptedRef = useRef(false)
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
const shouldRender = useGraphStore.use.shouldRender() // Rendering control state
|
||||
|
||||
// Get tab visibility
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
|
||||
// Handle component mount/unmount and tab visibility
|
||||
useEffect(() => {
|
||||
// When component mounts or tab becomes visible
|
||||
if (isGraphTabVisible && !shouldRender && !isFetching && !initAttemptedRef.current) {
|
||||
// If tab is visible but graph is not rendering, try to enable rendering
|
||||
useGraphStore.getState().setShouldRender(true)
|
||||
initAttemptedRef.current = true
|
||||
console.log('Graph viewer initialized')
|
||||
}
|
||||
|
||||
// Cleanup function when component unmounts
|
||||
return () => {
|
||||
// Only log cleanup, don't actually clean up the WebGL context
|
||||
// This allows the WebGL context to persist across tab switches
|
||||
console.log('Graph viewer cleanup')
|
||||
}
|
||||
}, [isGraphTabVisible, shouldRender, isFetching])
|
||||
|
||||
// Initialize sigma settings once on component mount
|
||||
// All dynamic settings will be updated in GraphControl using useSetSettings
|
||||
useEffect(() => {
|
||||
setSigmaSettings(defaultSigmaSettings)
|
||||
console.log('Initialized sigma settings')
|
||||
}, [])
|
||||
|
||||
// Clean up sigma instance when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// TAB is mount twice in vite dev mode, this is a workaround
|
||||
|
||||
const sigma = useGraphStore.getState().sigmaInstance;
|
||||
if (sigma) {
|
||||
try {
|
||||
// Destroy sigma,and clear WebGL context
|
||||
sigma.kill();
|
||||
useGraphStore.getState().setSigmaInstance(null);
|
||||
console.log('Cleared sigma instance on Graphviewer unmount');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up sigma instance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,
|
||||
// but testing showed it wasn't executing or having any effect, while the backup mechanism
|
||||
// in GraphControl was sufficient. This code was removed to simplify implementation
|
||||
|
||||
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
||||
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
||||
@@ -167,62 +166,51 @@ const GraphViewer = () => {
|
||||
[selectedNode]
|
||||
)
|
||||
|
||||
// Since TabsContent now forces mounting of all tabs, we need to conditionally render
|
||||
// the SigmaContainer based on visibility to avoid unnecessary rendering
|
||||
// Always render SigmaContainer but control its visibility with CSS
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Only render the SigmaContainer when the tab is visible */}
|
||||
{isGraphTabVisible ? (
|
||||
<SigmaContainer
|
||||
settings={sigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<SigmaContainer
|
||||
settings={sigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<Settings />
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
) : (
|
||||
// Placeholder when tab is not visible
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{/* Placeholder content */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<LayoutsControl />
|
||||
<ZoomControl />
|
||||
<FullScreenControl />
|
||||
<Settings />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
|
||||
{/* Loading overlay - shown when data is loading */}
|
||||
{isFetching && (
|
||||
|
177
lightrag_webui/src/features/LoginPage.tsx
Normal file
177
lightrag_webui/src/features/LoginPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { loginToServer, getAuthStatus } 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 AppSettings from '@/components/AppSettings'
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthenticated } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('LoginPage mounted')
|
||||
}, []);
|
||||
|
||||
// Check if authentication is configured, skip login if not
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
const checkAuthConfig = async () => {
|
||||
try {
|
||||
// If already authenticated, redirect to home
|
||||
if (isAuthenticated) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token and redirect
|
||||
login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
navigate('/')
|
||||
return // Exit early, no need to set checkingAuth to false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth configuration:', error)
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setCheckingAuth(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthConfig()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated, login, navigate])
|
||||
|
||||
// Don't render anything while checking auth
|
||||
if (checkingAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) {
|
||||
toast.error(t('login.errorEmptyFields'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await loginToServer(username, password)
|
||||
|
||||
// Check authentication mode
|
||||
const isGuestMode = response.auth_mode === 'disabled'
|
||||
login(response.access_token, isGuestMode)
|
||||
|
||||
if (isGuestMode) {
|
||||
// Show authentication disabled notification
|
||||
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
|
||||
} else {
|
||||
toast.success(t('login.successMessage'))
|
||||
}
|
||||
|
||||
// Navigate to home page after successful login
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Login failed...', error)
|
||||
toast.error(t('login.errorInvalidCredentials'))
|
||||
|
||||
// Clear any existing auth state
|
||||
useAuthStore.getState().logout()
|
||||
// Clear local storage
|
||||
localStorage.removeItem('LIGHTRAG-API-TOKEN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
||||
</div>
|
||||
<Card className="w-full max-w-[480px] shadow-lg mx-4">
|
||||
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="logo.png" alt="LightRAG Logo" className="h-12 w-12" />
|
||||
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8 pb-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="username" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="password" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 text-base font-medium mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('login.loggingIn') : t('login.loginButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
@@ -112,7 +112,7 @@ export default function RetrievalTesting() {
|
||||
}, [setMessages])
|
||||
|
||||
return (
|
||||
<div className="flex size-full gap-2 px-2 pb-12">
|
||||
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="relative grow">
|
||||
<div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
|
||||
|
@@ -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 (
|
||||
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
|
||||
<a href="/" className="mr-6 flex items-center gap-2">
|
||||
<a href={webuiPrefix} className="mr-6 flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
||||
{/* <img src='/logo.png' className="size-4" /> */}
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
@@ -64,6 +71,11 @@ export default function SiteHeader() {
|
||||
|
||||
<div className="flex h-10 flex-1 justify-center">
|
||||
<TabsNavigation />
|
||||
{isGuestMode && (
|
||||
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
|
||||
{t('login.guestMode', 'Guest Mode')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center">
|
||||
@@ -74,6 +86,9 @@ export default function SiteHeader() {
|
||||
</a>
|
||||
</Button>
|
||||
<AppSettings />
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.logout')} onClick={handleLogout}>
|
||||
<LogOutIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
@@ -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
|
||||
}
|
||||
}, [queryLabel, rawGraph, sigmaGraph])
|
||||
|
||||
// Data fetching logic
|
||||
useEffect(() => {
|
||||
// Skip if fetch is already in progress or no query label
|
||||
if (fetchInProgressRef.current || !queryLabel) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parameters have changed
|
||||
if (!isFetching && !fetchInProgressRef.current &&
|
||||
(paramsChanged || !useGraphStore.getState().graphDataFetchAttempted)) {
|
||||
|
||||
// Only fetch data if the Graph tab is visible
|
||||
if (!isGraphTabVisible) {
|
||||
console.log('Graph tab not visible, skipping data fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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)
|
||||
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<string, {x: number, y: number}> = {};
|
||||
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<string>();
|
||||
const edgesToAdd = new Set<string>();
|
||||
|
||||
// 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<string, number>();
|
||||
const nodesWithDiscardedEdges = new Set<string>();
|
||||
|
||||
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<string>,
|
||||
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);
|
||||
}
|
||||
}, [isGraphTabVisible, rawGraph])
|
||||
}, [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<string>([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 {
|
||||
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);
|
||||
}
|
||||
}, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);
|
||||
|
||||
const lightrageGraph = useCallback(() => {
|
||||
// If we already have a graph instance, return it
|
||||
|
35
lightrag_webui/src/i18n.js
Normal file
35
lightrag_webui/src/i18n.js
Normal file
@@ -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;
|
@@ -1,6 +1,7 @@
|
||||
import { ButtonVariantType } from '@/components/ui/Button'
|
||||
|
||||
export const backendBaseUrl = ''
|
||||
export const webuiPrefix = '/webui/'
|
||||
|
||||
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||
|
||||
|
@@ -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": {
|
||||
|
@@ -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": {
|
||||
|
@@ -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(<Root />)
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppRouter />
|
||||
</StrictMode>
|
||||
)
|
||||
|
90
lightrag_webui/src/services/navigation.ts
Normal file
90
lightrag_webui/src/services/navigation.ts
Normal file
@@ -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();
|
@@ -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<void>
|
||||
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<GraphState>()((set, get) => ({
|
||||
const useGraphStoreBase = create<GraphState>()((set) => ({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
@@ -106,7 +120,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
|
||||
moveToSelectedNode: false,
|
||||
isFetching: false,
|
||||
shouldRender: false,
|
||||
|
||||
// Initialize global flags
|
||||
graphDataFetchAttempted: false,
|
||||
@@ -114,21 +127,13 @@ const useGraphStoreBase = create<GraphState>()((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<GraphState>()((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<GraphState>()((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)
|
||||
|
@@ -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<BackendState>()((set) => ({
|
||||
health: true,
|
||||
message: null,
|
||||
@@ -57,3 +64,60 @@ const useBackendStateStoreBase = create<BackendState>()((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<AuthState>(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
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts", "src/vite-env.d.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
|
||||
|
Reference in New Issue
Block a user