Merge branch 'HKUDS:main' into main

This commit is contained in:
Daniel.y
2025-03-18 18:07:46 +08:00
committed by GitHub
45 changed files with 3141 additions and 1464 deletions

View File

@@ -151,9 +151,9 @@ QDRANT_URL=http://localhost:16333
### Redis ### Redis
REDIS_URI=redis://localhost:6379 REDIS_URI=redis://localhost:6379
# For jwt auth ### For JWTt Auth
AUTH_USERNAME=admin # login name AUTH_USERNAME=admin # login name
AUTH_PASSWORD=admin123 # password AUTH_PASSWORD=admin123 # password
TOKEN_SECRET=your-key # JWT key TOKEN_SECRET=your-key-for-LightRAG-API-Server # JWT key
TOKEN_EXPIRE_HOURS=4 # expire duration TOKEN_EXPIRE_HOURS=4 # expire duration
WHITELIST_PATHS=/login,/health # white list WHITELIST_PATHS=/login,/health # white list

View File

@@ -6,8 +6,10 @@ from pydantic import BaseModel
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: str sub: str # Username
exp: datetime exp: datetime # Expiration time
role: str = "user" # User role, default is regular user
metadata: dict = {} # Additional metadata
class AuthHandler: class AuthHandler:
@@ -15,13 +17,60 @@ class AuthHandler:
self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46") self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46")
self.algorithm = "HS256" self.algorithm = "HS256"
self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4)) self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4))
self.guest_expire_hours = int(
os.getenv("GUEST_TOKEN_EXPIRE_HOURS", 2)
) # Guest token default expiration time
def create_token(
self,
username: str,
role: str = "user",
custom_expire_hours: int = None,
metadata: dict = None,
) -> str:
"""
Create JWT token
Args:
username: Username
role: User role, default is "user", guest is "guest"
custom_expire_hours: Custom expiration time (hours), if None use default value
metadata: Additional metadata
Returns:
str: Encoded JWT token
"""
# Choose default expiration time based on role
if custom_expire_hours is None:
if role == "guest":
expire_hours = self.guest_expire_hours
else:
expire_hours = self.expire_hours
else:
expire_hours = custom_expire_hours
expire = datetime.utcnow() + timedelta(hours=expire_hours)
# Create payload
payload = TokenPayload(
sub=username, exp=expire, role=role, metadata=metadata or {}
)
def create_token(self, username: str) -> str:
expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
payload = TokenPayload(sub=username, exp=expire)
return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm) return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm)
def validate_token(self, token: str) -> str: def validate_token(self, token: str) -> dict:
"""
Validate JWT token
Args:
token: JWT token
Returns:
dict: Dictionary containing user information
Raises:
HTTPException: If token is invalid or expired
"""
try: try:
payload = jwt.decode(token, self.secret, algorithms=[self.algorithm]) payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
expire_timestamp = payload["exp"] expire_timestamp = payload["exp"]
@@ -31,7 +80,14 @@ class AuthHandler:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
) )
return payload["sub"]
# Return complete payload instead of just username
return {
"username": payload["sub"],
"role": payload.get("role", "user"),
"metadata": payload.get("metadata", {}),
"exp": expire_time,
}
except jwt.PyJWTError: except jwt.PyJWTError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"

View File

@@ -10,6 +10,7 @@ import logging.config
import uvicorn import uvicorn
import pipmaster as pm import pipmaster as pm
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from pathlib import Path from pathlib import Path
import configparser import configparser
from ascii_colors import ASCIIColors from ascii_colors import ASCIIColors
@@ -341,25 +342,62 @@ def create_app(args):
ollama_api = OllamaAPI(rag, top_k=args.top_k) ollama_api = OllamaAPI(rag, top_k=args.top_k)
app.include_router(ollama_api.router, prefix="/api") app.include_router(ollama_api.router, prefix="/api")
@app.post("/login") @app.get("/")
async def redirect_to_webui():
"""Redirect root path to /webui"""
return RedirectResponse(url="/webui")
@app.get("/auth-status", dependencies=[Depends(optional_api_key)])
async def get_auth_status():
"""Get authentication status and guest token if auth is not configured"""
username = os.getenv("AUTH_USERNAME")
password = os.getenv("AUTH_PASSWORD")
if not (username and password):
# Authentication not configured, return guest token
guest_token = auth_handler.create_token(
username="guest", role="guest", metadata={"auth_mode": "disabled"}
)
return {
"auth_configured": False,
"access_token": guest_token,
"token_type": "bearer",
"auth_mode": "disabled",
"message": "Authentication is disabled. Using guest access.",
}
return {"auth_configured": True, "auth_mode": "enabled"}
@app.post("/login", dependencies=[Depends(optional_api_key)])
async def login(form_data: OAuth2PasswordRequestForm = Depends()): async def login(form_data: OAuth2PasswordRequestForm = Depends()):
username = os.getenv("AUTH_USERNAME") username = os.getenv("AUTH_USERNAME")
password = os.getenv("AUTH_PASSWORD") password = os.getenv("AUTH_PASSWORD")
if not (username and password): if not (username and password):
raise HTTPException( # Authentication not configured, return guest token
status_code=status.HTTP_501_NOT_IMPLEMENTED, guest_token = auth_handler.create_token(
detail="Authentication not configured", username="guest", role="guest", metadata={"auth_mode": "disabled"}
) )
return {
"access_token": guest_token,
"token_type": "bearer",
"auth_mode": "disabled",
"message": "Authentication is disabled. Using guest access.",
}
if form_data.username != username or form_data.password != password: if form_data.username != username or form_data.password != password:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials" status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials"
) )
# Regular user login
user_token = auth_handler.create_token(
username=username, role="user", metadata={"auth_mode": "enabled"}
)
return { return {
"access_token": auth_handler.create_token(username), "access_token": user_token,
"token_type": "bearer", "token_type": "bearer",
"auth_mode": "enabled",
} }
@app.get("/health", dependencies=[Depends(optional_api_key)]) @app.get("/health", dependencies=[Depends(optional_api_key)])

View File

@@ -9,7 +9,7 @@ import sys
import logging import logging
from ascii_colors import ASCIIColors from ascii_colors import ASCIIColors
from lightrag.api import __api_version__ from lightrag.api import __api_version__
from fastapi import HTTPException, Security, Depends, Request from fastapi import HTTPException, Security, Depends, Request, status
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from starlette.status import HTTP_403_FORBIDDEN from starlette.status import HTTP_403_FORBIDDEN
@@ -35,7 +35,8 @@ ollama_server_infos = OllamaServerInfos()
def get_auth_dependency(): def get_auth_dependency():
whitelist = os.getenv("WHITELIST_PATHS", "").split(",") # Set default whitelist paths
whitelist = os.getenv("WHITELIST_PATHS", "/login,/health").split(",")
async def dependency( async def dependency(
request: Request, request: Request,
@@ -44,10 +45,43 @@ def get_auth_dependency():
if request.url.path in whitelist: if request.url.path in whitelist:
return return
if not (os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")): # Check if authentication is configured
auth_configured = bool(
os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")
)
# If authentication is not configured, accept any token including guest tokens
if not auth_configured:
if token: # If token is provided, still validate it
try:
# Validate token but don't raise exception
token_info = auth_handler.validate_token(token)
# Check if it's a guest token
if token_info.get("role") != "guest":
# Non-guest tokens are not valid when auth is not configured
pass
except Exception as e:
# Ignore validation errors but log them
print(f"Token validation error (ignored): {str(e)}")
return return
auth_handler.validate_token(token) # If authentication is configured, validate the token and reject guest tokens
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required"
)
token_info = auth_handler.validate_token(token)
# Reject guest tokens when authentication is configured
if token_info.get("role") == "guest":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Guest access not allowed when authentication is configured.",
)
# At this point, we have a valid non-guest token
return
return dependency return dependency

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,11 +5,11 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
<script type="module" crossorigin src="./assets/index-DwcJE583.js"></script> <script type="module" crossorigin src="/webui/assets/index-CSrxfS-k.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BV5s8k-a.css"> <link rel="stylesheet" crossorigin href="/webui/assets/index-mPRIIErN.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -373,6 +373,9 @@ class NetworkXStorage(BaseGraphStorage):
# Add edges to result # Add edges to result
for edge in subgraph.edges(): for edge in subgraph.edges():
source, target = edge source, target = edge
# Esure unique edge_id for undirect graph
if source > target:
source, target = target, source
edge_id = f"{source}-{target}" edge_id = f"{source}-{target}"
if edge_id in seen_edges: if edge_id in seen_edges:
continue continue

View File

@@ -40,9 +40,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-error-boundary": "^5.0.0",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-router-dom": "^7.3.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark-gfm": "^4.0.1", "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/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/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=="], "@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=="], "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=="], "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=="], "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-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-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=="], "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-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-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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="],

View File

@@ -0,0 +1,2 @@
# Development environment configuration
VITE_BACKEND_URL=/api

View 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

View File

@@ -5,7 +5,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
</head> </head>

View File

@@ -49,9 +49,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-error-boundary": "^5.0.0",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-router-dom": "^7.3.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",

View File

@@ -5,10 +5,9 @@ import MessageAlert from '@/components/MessageAlert'
import ApiKeyAlert from '@/components/ApiKeyAlert' import ApiKeyAlert from '@/components/ApiKeyAlert'
import StatusIndicator from '@/components/graph/StatusIndicator' import StatusIndicator from '@/components/graph/StatusIndicator'
import { healthCheckInterval } from '@/lib/constants' import { healthCheckInterval } from '@/lib/constants'
import { useBackendState } from '@/stores/state' import { useBackendState, useAuthStore } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Toaster } from 'sonner'
import SiteHeader from '@/features/SiteHeader' import SiteHeader from '@/features/SiteHeader'
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag' import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
@@ -27,7 +26,8 @@ function App() {
// Health check // Health check
useEffect(() => { useEffect(() => {
if (!enableHealthCheck) return const { isAuthenticated } = useAuthStore.getState();
if (!enableHealthCheck || !isAuthenticated) return
// Check immediately // Check immediately
useBackendState.getState().check() useBackendState.getState().check()
@@ -56,24 +56,24 @@ function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<TabVisibilityProvider> <TabVisibilityProvider>
<main className="flex h-screen w-screen overflow-x-hidden"> <main className="flex h-screen w-screen overflow-hidden">
<Tabs <Tabs
defaultValue={currentTab} defaultValue={currentTab}
className="!m-0 flex grow flex-col !p-0" className="!m-0 flex grow flex-col !p-0 overflow-hidden"
onValueChange={handleTabChange} onValueChange={handleTabChange}
> >
<SiteHeader /> <SiteHeader />
<div className="relative grow"> <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 /> <DocumentManager />
</TabsContent> </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 /> <GraphViewer />
</TabsContent> </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 /> <RetrievalTesting />
</TabsContent> </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 /> <ApiSite />
</TabsContent> </TabsContent>
</div> </div>
@@ -81,7 +81,6 @@ function App() {
{enableHealthCheck && <StatusIndicator />} {enableHealthCheck && <StatusIndicator />}
{message !== null && !apiKeyInvalid && <MessageAlert />} {message !== null && !apiKeyInvalid && <MessageAlert />}
{apiKeyInvalid && <ApiKeyAlert />} {apiKeyInvalid && <ApiKeyAlert />}
<Toaster />
</main> </main>
</TabVisibilityProvider> </TabVisibilityProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -0,0 +1,157 @@
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/stores/state'
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)
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])
// Show nothing while checking auth status
if (isChecking) {
return null
}
// After checking, if still not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
const AppRouter = () => {
const [initializing, setInitializing] = useState(true)
const { isAuthenticated } = useAuthStore()
// 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 (
<ThemeProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<App />
</ProtectedRoute>
}
/>
</Routes>
<Toaster position="top-center" />
</Router>
</ThemeProvider>
)
}
export default AppRouter

View File

@@ -1,7 +1,8 @@
import axios, { AxiosError } from 'axios' import axios, { AxiosError } from 'axios'
import { backendBaseUrl } from '@/lib/constants' import { backendBaseUrl, webuiPrefix } from '@/lib/constants'
import { errorMessage } from '@/lib/utils' import { errorMessage } from '@/lib/utils'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useAuthStore } from '@/stores/state'
// Types // Types
export type LightragNodeType = { export type LightragNodeType = {
@@ -125,6 +126,21 @@ export type DocsStatusesResponse = {
statuses: Record<DocStatus, DocStatusResponse[]> 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 InvalidApiKeyError = 'Invalid API Key'
export const RequireApiKeError = 'API Key required' export const RequireApiKeError = 'API Key required'
@@ -136,12 +152,26 @@ const axiosInstance = axios.create({
} }
}) })
// Interceptoradd api key // Interceptor: add api key and check authentication
axiosInstance.interceptors.request.use((config) => { axiosInstance.interceptors.request.use((config) => {
const apiKey = useSettingsStore.getState().apiKey const apiKey = useSettingsStore.getState().apiKey
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
// Check authentication status for paths that require authentication
const authRequiredPaths = ['/documents', '/graphs', '/query', '/health']; // Add all paths that require authentication
const isAuthRequired = authRequiredPaths.some(path => config.url?.includes(path));
if (isAuthRequired && !token && config.url !== '/login') {
// Cancel the request and return a rejected Promise
return Promise.reject(new Error('Authentication required'));
}
if (apiKey) { if (apiKey) {
config.headers['X-API-Key'] = apiKey config.headers['X-API-Key'] = apiKey
} }
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config return config
}) })
@@ -150,6 +180,17 @@ axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError) => { (error: AxiosError) => {
if (error.response) { if (error.response) {
if (error.response?.status === 401) {
localStorage.removeItem('LIGHTRAG-API-TOKEN');
sessionStorage.clear();
useAuthStore.getState().logout();
if (window.location.pathname !== `${webuiPrefix}/#/login`) {
window.location.href = `${webuiPrefix}/#/login`;
}
return Promise.reject(error);
}
throw new Error( throw new Error(
`${error.response.status} ${error.response.statusText}\n${JSON.stringify( `${error.response.status} ${error.response.statusText}\n${JSON.stringify(
error.response.data error.response.data
@@ -324,3 +365,74 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
const response = await axiosInstance.delete('/documents') const response = await axiosInstance.delete('/documents')
return response.data 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;
}

View File

@@ -5,8 +5,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react' import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next' 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 [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation() const { t } = useTranslation()
@@ -27,7 +32,7 @@ export default function AppSettings() {
return ( return (
<Popover open={opened} onOpenChange={setOpened}> <Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild> <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" /> <PaletteIcon className="h-5 w-5" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View 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>
)
}

View File

@@ -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. * When the selected item changes, highlighted the node and center the camera on it.
*/ */
useEffect(() => { useEffect(() => {
const graph = sigma.getGraph();
if (move) { if (move) {
if (node) { if (node && graph.hasNode(node)) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true) try {
gotoNode(node) graph.setNodeAttribute(node, 'highlighted', true);
gotoNode(node);
} catch (error) {
console.error('Error focusing on node:', error);
}
} else { } else {
// If no node is selected but move is true, reset to default view // If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null) sigma.setCustomBBox(null);
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 }) 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 () => { return () => {
if (node) { if (node && graph.hasNode(node)) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false) try {
graph.setNodeAttribute(node, 'highlighted', false);
} catch (error) {
console.error('Error cleaning up node highlight:', error);
}
} }
} }
}, [node, move, sigma, gotoNode]) }, [node, move, sigma, gotoNode])

View File

@@ -1,5 +1,5 @@
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core' import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import Graph from 'graphology' import { AbstractGraph } from 'graphology-types'
// import { useLayoutCircular } from '@react-sigma/layout-circular' // import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2' import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect } from 'react' import { useEffect } from 'react'
@@ -25,7 +25,6 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const sigma = useSigma<NodeType, EdgeType>() const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>() const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>() const setSettings = useSetSettings<NodeType, EdgeType>()
const loadGraph = useLoadGraph<NodeType, EdgeType>()
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations() const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const { assign: assignLayout } = useLayoutForceAtlas2({ const { assign: assignLayout } = useLayoutForceAtlas2({
@@ -45,14 +44,42 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
/** /**
* When component mount or maxIterations changes * When component mount or maxIterations changes
* => load the graph and apply layout * => ensure graph reference and apply layout
*/ */
useEffect(() => { useEffect(() => {
if (sigmaGraph) { if (sigmaGraph && sigma) {
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>) // Ensure sigma binding to sigmaGraph
assignLayout() 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');
} }
}, [assignLayout, loadGraph, sigmaGraph, maxIterations]) } catch (error) {
console.error('Error setting graph on sigma instance:', error);
}
assignLayout();
console.log('Initial layout applied to graph');
}
}, [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 * When component mount
@@ -138,14 +165,18 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const _focusedNode = focusedNode || selectedNode const _focusedNode = focusedNode || selectedNode
const _focusedEdge = focusedEdge || selectedEdge const _focusedEdge = focusedEdge || selectedEdge
if (_focusedNode) { if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) { if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
newData.highlighted = true newData.highlighted = true
if (node === selectedNode) { if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected newData.borderColor = Constants.nodeBorderColorSelected
} }
} }
} else if (_focusedEdge) { } catch (error) {
console.error('Error in nodeReducer:', error);
}
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
if (graph.extremities(_focusedEdge).includes(node)) { if (graph.extremities(_focusedEdge).includes(node)) {
newData.highlighted = true newData.highlighted = true
newData.size = 3 newData.size = 3
@@ -173,7 +204,8 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
if (!disableHoverEffect) { if (!disableHoverEffect) {
const _focusedNode = focusedNode || selectedNode const _focusedNode = focusedNode || selectedNode
if (_focusedNode) { if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (hideUnselectedEdges) { if (hideUnselectedEdges) {
if (!graph.extremities(edge).includes(_focusedNode)) { if (!graph.extremities(edge).includes(_focusedNode)) {
newData.hidden = true newData.hidden = true
@@ -183,11 +215,17 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
newData.color = Constants.edgeColorHighlighted newData.color = Constants.edgeColorHighlighted
} }
} }
} catch (error) {
console.error('Error in edgeReducer:', error);
}
} else { } else {
if (focusedEdge || selectedEdge) { const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
if (edge === selectedEdge) { const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
if (_selectedEdge || _focusedEdge) {
if (edge === _selectedEdge) {
newData.color = Constants.edgeColorSelected newData.color = Constants.edgeColorSelected
} else if (edge === focusedEdge) { } else if (edge === _focusedEdge) {
newData.color = Constants.edgeColorHighlighted newData.color = Constants.edgeColorHighlighted
} else if (hideUnselectedEdges) { } else if (hideUnselectedEdges) {
newData.hidden = true newData.hidden = true

View File

@@ -2,14 +2,17 @@ import { useCallback, useEffect, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect' import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants' import { labelListLimit, controlButtonVariant } from '@/lib/constants'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import Button from '@/components/ui/Button'
const GraphLabels = () => { const GraphLabels = () => {
const { t } = useTranslation() const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels() const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
const rawGraph = useGraphStore.use.rawGraph()
const labelsLoadedRef = useRef(false) const labelsLoadedRef = useRef(false)
// Track if a fetch is in progress to prevent multiple simultaneous fetches // Track if a fetch is in progress to prevent multiple simultaneous fetches
@@ -83,7 +86,29 @@ const GraphLabels = () => {
[getSearchEngine] [getSearchEngine]
) )
const handleRefresh = useCallback(() => {
const currentLabel = useSettingsStore.getState().queryLabel
useGraphStore.getState().setGraphDataFetchAttempted(false)
useGraphStore.getState().reset()
useSettingsStore.getState().setQueryLabel(currentLabel)
}, [])
return ( 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> <AsyncSelect<string>
className="ml-2" className="ml-2"
triggerClassName="max-h-8" triggerClassName="max-h-8"
@@ -111,13 +136,8 @@ const GraphLabels = () => {
// Clear current graph data to ensure complete reload when label changes // Clear current graph data to ensure complete reload when label changes
if (newLabel !== currentLabel) { if (newLabel !== currentLabel) {
const graphStore = useGraphStore.getState(); const graphStore = useGraphStore.getState();
graphStore.clearSelection(); // Reset the all graph objects and status
graphStore.reset();
// Reset the graph state but preserve the instance
if (graphStore.sigmaGraph) {
const nodes = Array.from(graphStore.sigmaGraph.nodes());
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
}
} }
if (newLabel === currentLabel && newLabel !== '*') { if (newLabel === currentLabel && newLabel !== '*') {
@@ -129,6 +149,7 @@ const GraphLabels = () => {
}} }}
clearable={false} // Prevent clearing value on reselect clearable={false} // Prevent clearing value on reselect
/> />
</div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { FC, useCallback, useEffect, useMemo } from 'react' import { FC, useCallback, useEffect } from 'react'
import { import {
EdgeById, EdgeById,
NodeById, NodeById,
@@ -11,28 +11,34 @@ import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next' 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 id: string
type: 'nodes' | 'edges' | 'message' type: 'nodes' | 'edges' | 'message'
message?: string 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) { function OptionComponent(item: OptionItem) {
return ( return (
<div> <div>
{item.type === 'nodes' && <NodeById id={item.id} />} {item.type === 'nodes' && <NodeOption id={item.id} />}
{item.type === 'edges' && <EdgeById id={item.id} />} {item.type === 'edges' && <EdgeById id={item.id} />}
{item.type === 'message' && <div>{item.message}</div>} {item.type === 'message' && <div>{item.message}</div>}
</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. * Component thats display the search input.
@@ -48,25 +54,24 @@ export const GraphSearchInput = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
const searchEngine = useGraphStore.use.searchEngine()
// Force reset the cache when graph changes // Reset search engine when graph changes
useEffect(() => { useEffect(() => {
if (graph) { if (graph) {
// Reset cache to ensure fresh search results with new graph data useGraphStore.getState().resetSearchEngine()
lastGraph.graph = null;
lastGraph.searchEngine = null;
} }
}, [graph]); }, [graph]);
const searchEngine = useMemo(() => { // Create search engine when needed
if (lastGraph.graph == graph) { useEffect(() => {
return lastGraph.searchEngine // 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 // Create new search engine
const newSearchEngine = new MiniSearch({
const searchEngine = new MiniSearch({
idField: 'id', idField: 'id',
fields: ['label'], fields: ['label'],
searchOptions: { searchOptions: {
@@ -78,16 +83,16 @@ export const GraphSearchInput = ({
} }
}) })
// Add documents // Add nodes to search engine
const documents = graph.nodes().map((id: string) => ({ const documents = graph.nodes().map((id: string) => ({
id: id, id: id,
label: graph.getNodeAttribute(id, 'label') label: graph.getNodeAttribute(id, 'label')
})) }))
searchEngine.addAll(documents) newSearchEngine.addAll(documents)
lastGraph.searchEngine = searchEngine // Update search engine in store
return searchEngine useGraphStore.getState().setSearchEngine(newSearchEngine)
}, [graph]) }, [graph, searchEngine])
/** /**
* Loading the options while the user is typing. * Loading the options while the user is typing.
@@ -95,19 +100,32 @@ export const GraphSearchInput = ({
const loadOptions = useCallback( const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => { async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null) 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) { 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 => ({ return nodeIds.map(id => ({
id, id,
type: 'nodes' type: 'nodes'
})) }))
} }
// If has query, search nodes // If has query, search nodes and verify they still exist
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({ const result: OptionItem[] = searchEngine.search(query)
.filter((r: { id: string }) => graph.hasNode(r.id))
.map((r: { id: string }) => ({
id: r.id, id: r.id,
type: 'nodes' type: 'nodes'
})) }))

View File

@@ -7,7 +7,7 @@ import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2' import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap' import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
import { useLayoutRandom } from '@react-sigma/layout-random' 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 Button from '@/components/ui/Button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
@@ -26,43 +26,161 @@ type LayoutName =
| 'Force Directed' | 'Force Directed'
| 'Force Atlas' | '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 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() 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. * Init component when Sigma or component settings change.
*/ */
useEffect(() => { useEffect(() => {
if (!sigma) { if (!sigma) {
console.log('No sigma instance available')
return return
} }
// we run the algo // Auto-run if specified
let timeout: number | null = null let timeout: number | null = null
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) { if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
start() console.log('Auto-starting layout animation')
// set a timeout to stop it
timeout = // Initial position update
autoRunFor > 0 updatePositions()
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
: null // 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)
}
} }
//cleaning // Cleanup function
return () => { return () => {
stop() // console.log('Cleaning up WorkerLayoutControl')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
if (timeout) { if (timeout) {
clearTimeout(timeout) window.clearTimeout(timeout)
} }
setIsRunning(false)
} }
}, [autoRunFor, start, stop, sigma]) }, [autoRunFor, sigma, updatePositions])
return ( return (
<Button <Button
size="icon" size="icon"
onClick={() => (isRunning ? stop() : start())} onClick={handleClick}
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')} tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
variant={controlButtonVariant} variant={controlButtonVariant}
> >
@@ -85,8 +203,27 @@ const LayoutsControl = () => {
const layoutCircular = useLayoutCircular() const layoutCircular = useLayoutCircular()
const layoutCirclepack = useLayoutCirclepack() const layoutCirclepack = useLayoutCirclepack()
const layoutRandom = useLayoutRandom() const layoutRandom = useLayoutRandom()
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } }) const layoutNoverlap = useLayoutNoverlap({
const layoutForce = useLayoutForce({ maxIterations: maxIterations }) 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 layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
const workerNoverlap = useWorkerLayoutNoverlap() const workerNoverlap = useWorkerLayoutNoverlap()
const workerForce = useWorkerLayoutForce() const workerForce = useWorkerLayoutForce()
@@ -130,10 +267,23 @@ const LayoutsControl = () => {
const runLayout = useCallback( const runLayout = useCallback(
(newLayout: LayoutName) => { (newLayout: LayoutName) => {
console.debug(newLayout) console.debug('Running layout:', newLayout)
const { positions } = layouts[newLayout].layout const { positions } = layouts[newLayout].layout
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
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) setLayout(newLayout)
} catch (error) {
console.error('Error running layout:', error)
}
}, },
[layouts, sigma] [layouts, sigma]
) )
@@ -142,7 +292,10 @@ const LayoutsControl = () => {
<> <>
<div> <div>
{layouts[layout] && 'worker' in layouts[layout] && ( {layouts[layout] && 'worker' in layouts[layout] && (
<WorkerLayoutControl layout={layouts[layout].worker!} /> <WorkerLayoutControl
layout={layouts[layout].worker!}
mainLayout={layouts[layout].layout}
/>
)} )}
</div> </div>
<div> <div>

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph' import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
import Text from '@/components/ui/Text' import Text from '@/components/ui/Text'
import Button from '@/components/ui/Button'
import useLightragGraph from '@/hooks/useLightragGraph' import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { GitBranchPlus, Scissors } from 'lucide-react'
/** /**
* Component that view properties of elements in graph. * Component that view properties of elements in graph.
@@ -88,11 +90,26 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
const relationships = [] const relationships = []
if (state.sigmaGraph && state.rawGraph) { if (state.sigmaGraph && state.rawGraph) {
for (const edgeId of state.sigmaGraph.edges(node.id)) { 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) const edge = state.rawGraph.getEdge(edgeId, true)
if (edge) { if (edge) {
const isTarget = node.id === edge.source const isTarget = node.id === edge.source
const neighbourId = isTarget ? edge.target : edge.source const neighbourId = isTarget ? edge.target : edge.source
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
const neighbour = state.rawGraph.getNode(neighbourId) const neighbour = state.rawGraph.getNode(neighbourId)
if (neighbour) { if (neighbour) {
relationships.push({ relationships.push({
@@ -103,7 +120,11 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
} }
} }
} }
} catch (error) {
console.error('Error refining node properties:', error)
} }
}
return { return {
...node, ...node,
relationships relationships
@@ -112,8 +133,31 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => { const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
const state = useGraphStore.getState() const state = useGraphStore.getState()
const sourceNode = state.rawGraph?.getNode(edge.source) let sourceNode: RawNodeType | undefined = undefined
const targetNode = state.rawGraph?.getNode(edge.target) 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 { return {
...edge, ...edge,
sourceNode, sourceNode,
@@ -157,9 +201,40 @@ const PropertyRow = ({
const NodePropertiesView = ({ node }: { node: NodeType }) => { const NodePropertiesView = ({ node }: { node: NodeType }) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleExpandNode = () => {
useGraphStore.getState().triggerNodeExpand(node.id)
}
const handlePruneNode = () => {
useGraphStore.getState().triggerNodePrune(node.id)
}
return ( return (
<div className="flex flex-col gap-2"> <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"
onClick={handleExpandNode}
tooltip={t('graphPanel.propertiesView.node.expandNode')}
>
<GitBranchPlus className="h-4 w-4 text-gray-700" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 border border-gray-400 hover:bg-gray-200"
onClick={handlePruneNode}
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
>
<Scissors className="h-4 w-4 text-gray-900" />
</Button>
</div>
</div>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} /> <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
<PropertyRow <PropertyRow
@@ -171,7 +246,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
/> />
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} /> <PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
</div> </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"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(node.properties) {Object.keys(node.properties)
.sort() .sort()
@@ -181,7 +256,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
</div> </div>
{node.relationships.length > 0 && ( {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')} {t('graphPanel.propertiesView.node.relationships')}
</label> </label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <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() const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <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"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} /> <PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />} {edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
@@ -227,7 +302,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
}} }}
/> />
</div> </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"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(edge.properties) {Object.keys(edge.properties)
.sort() .sort()

View File

@@ -8,9 +8,8 @@ import Input from '@/components/ui/Input'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state' 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'; import { useTranslation } from 'react-i18next';
/** /**
@@ -115,7 +114,6 @@ const LabeledNumberInput = ({
export default function Settings() { export default function Settings() {
const [opened, setOpened] = useState<boolean>(false) const [opened, setOpened] = useState<boolean>(false)
const [tempApiKey, setTempApiKey] = useState<string>('') const [tempApiKey, setTempApiKey] = useState<string>('')
const refreshLayout = useGraphStore.use.refreshLayout()
const showPropertyPanel = useSettingsStore.use.showPropertyPanel() const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar() const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
@@ -211,14 +209,6 @@ export default function Settings() {
return ( return (
<> <>
<Button
variant={controlButtonVariant}
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
size="icon"
onClick={refreshLayout}
>
<RefreshCwIcon />
</Button>
<Popover open={opened} onOpenChange={setOpened}> <Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon"> <Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">

View File

@@ -1,37 +1,107 @@
import { useCamera } from '@react-sigma/core' import { useCamera, useSigma } from '@react-sigma/core'
import { useCallback } from 'react' import { useCallback } from 'react'
import Button from '@/components/ui/Button' 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 { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
/** /**
* Component that provides zoom controls for the graph viewer. * Component that provides zoom controls for the graph viewer.
*/ */
const ZoomControl = () => { const ZoomControl = () => {
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 }) const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
const sigma = useSigma()
const { t } = useTranslation(); const { t } = useTranslation();
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut]) 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 ( return (
<> <>
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon"> <Button
<ZoomInIcon /> variant={controlButtonVariant}
onClick={handleRotateCounterClockwise}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
size="icon"
>
<RotateCcwIcon />
</Button> </Button>
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon"> <Button
<ZoomOutIcon /> variant={controlButtonVariant}
onClick={handleRotate}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
size="icon"
>
<RotateCwIcon />
</Button> </Button>
<Button <Button
variant={controlButtonVariant} variant={controlButtonVariant}
onClick={handleResetZoom} onClick={handleResetZoom}
tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")} tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
size="icon" size="icon"
> >
<FullscreenIcon /> <FullscreenIcon />
</Button> </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>
</> </>
) )
} }

View File

@@ -11,7 +11,6 @@ const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>, React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}
@@ -22,7 +21,6 @@ const PopoverContent = React.forwardRef<
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal>
)) ))
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName

View File

@@ -38,7 +38,7 @@ const TooltipContent = React.forwardRef<
side={side} side={side}
align={align} align={align}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -15,16 +15,22 @@ export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ ch
// Get current tab from settings store // Get current tab from settings store
const currentTab = useSettingsStore.use.currentTab(); 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>>(() => ({ 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(() => { useEffect(() => {
setVisibleTabs((prev) => ({ setVisibleTabs((prev) => ({
...prev, ...prev,
[currentTab]: true 'documents': true,
'knowledge-graph': true,
'retrieval': true,
'api': true
})); }));
}, [currentTab]); }, [currentTab]);

View File

@@ -148,6 +148,19 @@ const GraphViewer = () => {
setSigmaSettings(defaultSigmaSettings) setSigmaSettings(defaultSigmaSettings)
}, []) }, [])
// Clean up sigma instance when component unmounts
useEffect(() => {
return () => {
// Clear the sigma instance when component unmounts
useGraphStore.getState().setSigmaInstance(null);
console.log('Cleared sigma instance on unmount');
};
}, []);
// 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) => { const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
if (value === null) useGraphStore.getState().setFocusedNode(null) if (value === null) useGraphStore.getState().setFocusedNode(null)
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id) else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
@@ -167,12 +180,9 @@ const GraphViewer = () => {
[selectedNode] [selectedNode]
) )
// Since TabsContent now forces mounting of all tabs, we need to conditionally render // Always render SigmaContainer but control its visibility with CSS
// the SigmaContainer based on visibility to avoid unnecessary rendering
return ( return (
<div className="relative h-full w-full"> <div className="relative h-full w-full overflow-hidden">
{/* Only render the SigmaContainer when the tab is visible */}
{isGraphTabVisible ? (
<SigmaContainer <SigmaContainer
settings={sigmaSettings} settings={sigmaSettings}
className="!bg-background !size-full overflow-hidden" className="!bg-background !size-full overflow-hidden"
@@ -196,10 +206,10 @@ const GraphViewer = () => {
</div> </div>
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg"> <div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
<Settings />
<ZoomControl />
<LayoutsControl /> <LayoutsControl />
<ZoomControl />
<FullScreenControl /> <FullScreenControl />
<Settings />
{/* <ThemeToggle /> */} {/* <ThemeToggle /> */}
</div> </div>
@@ -215,14 +225,6 @@ const GraphViewer = () => {
<SettingsDisplay /> <SettingsDisplay />
</SigmaContainer> </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>
)}
{/* Loading overlay - shown when data is loading */} {/* Loading overlay - shown when data is loading */}
{isFetching && ( {isFetching && (

View File

@@ -0,0 +1,168 @@
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)
// Check if authentication is configured
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('/')
} catch (error) {
console.error('Login failed...', error)
toast.error(t('login.errorInvalidCredentials'))
} 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

View File

@@ -112,7 +112,7 @@ export default function RetrievalTesting() {
}, [setMessages]) }, [setMessages])
return ( 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="flex grow flex-col gap-4">
<div className="relative grow"> <div className="relative grow">
<div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"> <div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">

View File

@@ -1,12 +1,14 @@
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { SiteInfo } from '@/lib/constants' import { SiteInfo, webuiPrefix } from '@/lib/constants'
import AppSettings from '@/components/AppSettings' import AppSettings from '@/components/AppSettings'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useAuthStore } from '@/stores/state'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { ZapIcon, GithubIcon } from 'lucide-react' import { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react'
interface NavigationTabProps { interface NavigationTabProps {
value: string value: string
@@ -54,9 +56,17 @@ function TabsNavigation() {
export default function SiteHeader() { export default function SiteHeader() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const { logout, isGuestMode } = useAuthStore()
const handleLogout = () => {
logout()
navigate('/login')
}
return ( 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"> <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" /> <ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
{/* <img src='/logo.png' className="size-4" /> */} {/* <img src='/logo.png' className="size-4" /> */}
<span className="font-bold md:inline-block">{SiteInfo.name}</span> <span className="font-bold md:inline-block">{SiteInfo.name}</span>
@@ -64,6 +74,11 @@ export default function SiteHeader() {
<div className="flex h-10 flex-1 justify-center"> <div className="flex h-10 flex-1 justify-center">
<TabsNavigation /> <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> </div>
<nav className="flex items-center"> <nav className="flex items-center">
@@ -74,6 +89,9 @@ export default function SiteHeader() {
</a> </a>
</Button> </Button>
<AppSettings /> <AppSettings />
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.logout')} onClick={handleLogout}>
<LogOutIcon className="size-4" aria-hidden="true" />
</Button>
</div> </div>
</nav> </nav>
</header> </header>

View File

@@ -1,8 +1,10 @@
import Graph, { DirectedGraph } from 'graphology' import Graph, { DirectedGraph } from 'graphology'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { randomColor, errorMessage } from '@/lib/utils' import { randomColor, errorMessage } from '@/lib/utils'
import * as Constants from '@/lib/constants' 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 { queryGraphs } from '@/api/lightrag'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
@@ -172,12 +174,15 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
} }
const useLightrangeGraph = () => { const useLightrangeGraph = () => {
const { t } = useTranslation()
const queryLabel = useSettingsStore.use.queryLabel() const queryLabel = useSettingsStore.use.queryLabel()
const rawGraph = useGraphStore.use.rawGraph() const rawGraph = useGraphStore.use.rawGraph()
const sigmaGraph = useGraphStore.use.sigmaGraph() const sigmaGraph = useGraphStore.use.sigmaGraph()
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth() const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
const minDegree = useSettingsStore.use.graphMinDegree() const minDegree = useSettingsStore.use.graphMinDegree()
const isFetching = useGraphStore.use.isFetching() const isFetching = useGraphStore.use.isFetching()
const nodeToExpand = useGraphStore.use.nodeToExpand()
const nodeToPrune = useGraphStore.use.nodeToPrune()
// Get tab visibility // Get tab visibility
const { isTabVisible } = useTabVisibility() const { isTabVisible } = useTabVisibility()
@@ -327,6 +332,419 @@ const useLightrangeGraph = () => {
} }
}, [isGraphTabVisible, rawGraph]) }, [isGraphTabVisible, rawGraph])
// Handle node expansion
useEffect(() => {
const handleNodeExpand = async (nodeId: string | null) => {
if (!nodeId || !sigmaGraph || !rawGraph) return;
try {
// Get the node to expand
const nodeToExpand = rawGraph.getNode(nodeId);
if (!nodeToExpand) {
console.error('Node not found:', nodeId);
return;
}
// Get the label of the node to expand
const label = nodeToExpand.labels[0];
if (!label) {
console.error('Node has no label:', nodeId);
return;
}
// Fetch the extended subgraph with depth 2
const extendedGraph = await queryGraphs(label, 2, 0);
if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
console.error('Failed to fetch extended graph');
return;
}
// Process nodes to add required properties for RawNodeType
const processedNodes: RawNodeType[] = [];
for (const node of extendedGraph.nodes) {
// Generate random color values
seedrandom(node.id, { global: true });
const color = randomColor();
// Create a properly typed RawNodeType
processedNodes.push({
id: node.id,
labels: node.labels,
properties: node.properties,
size: 10, // Default size, will be calculated later
x: Math.random(), // Random position, will be adjusted later
y: Math.random(), // Random position, will be adjusted later
color: color, // Random color
degree: 0 // Initial degree, will be calculated later
});
}
// Process edges to add required properties for RawEdgeType
const processedEdges: RawEdgeType[] = [];
for (const edge of extendedGraph.edges) {
// Create a properly typed RawEdgeType
processedEdges.push({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
properties: edge.properties,
dynamicId: '' // Will be set when adding to sigma graph
});
}
// Store current node positions
const nodePositions: Record<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);
}
}, [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(() => { const lightrageGraph = useCallback(() => {
// If we already have a graph instance, return it // If we already have a graph instance, return it
if (sigmaGraph) { if (sigmaGraph) {

View 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;

View File

@@ -1,6 +1,7 @@
import { ButtonVariantType } from '@/components/ui/Button' import { ButtonVariantType } from '@/components/ui/Button'
export const backendBaseUrl = '' export const backendBaseUrl = ''
export const webuiPrefix = '/webui/'
export const controlButtonVariant: ButtonVariantType = 'ghost' export const controlButtonVariant: ButtonVariantType = 'ghost'

View File

@@ -12,11 +12,26 @@
"retrieval": "Retrieval", "retrieval": "Retrieval",
"api": "API", "api": "API",
"projectRepository": "Project Repository", "projectRepository": "Project Repository",
"logout": "Logout",
"themeToggle": { "themeToggle": {
"switchToLight": "Switch to light theme", "switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark 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": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
"button": "Clear", "button": "Clear",
@@ -97,12 +112,14 @@
"zoomControl": { "zoomControl": {
"zoomIn": "Zoom In", "zoomIn": "Zoom In",
"zoomOut": "Zoom Out", "zoomOut": "Zoom Out",
"resetZoom": "Reset Zoom" "resetZoom": "Reset Zoom",
"rotateCamera": "Clockwise Rotate",
"rotateCameraCounterClockwise": "Counter-Clockwise Rotate"
}, },
"layoutsControl": { "layoutsControl": {
"startAnimation": "Start the layout animation", "startAnimation": "Continue layout animation",
"stopAnimation": "Stop the layout animation", "stopAnimation": "Stop layout animation",
"layoutGraph": "Layout Graph", "layoutGraph": "Layout Graph",
"layouts": { "layouts": {
"Circular": "Circular", "Circular": "Circular",
@@ -151,6 +168,11 @@
"degree": "Degree", "degree": "Degree",
"properties": "Properties", "properties": "Properties",
"relationships": "Relationships", "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": { "propertyNames": {
"description": "Description", "description": "Description",
"entity_id": "Name", "entity_id": "Name",
@@ -177,7 +199,8 @@
"noLabels": "No labels found", "noLabels": "No labels found",
"label": "Label", "label": "Label",
"placeholder": "Search labels...", "placeholder": "Search labels...",
"andOthers": "And {count} others" "andOthers": "And {count} others",
"refreshTooltip": "Reload graph data"
} }
}, },
"retrievePanel": { "retrievePanel": {

View File

@@ -12,11 +12,26 @@
"retrieval": "检索", "retrieval": "检索",
"api": "API", "api": "API",
"projectRepository": "项目仓库", "projectRepository": "项目仓库",
"logout": "退出登录",
"themeToggle": { "themeToggle": {
"switchToLight": "切换到浅色主题", "switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题" "switchToDark": "切换到深色主题"
} }
}, },
"login": {
"description": "请输入您的账号和密码登录系统",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"loginButton": "登录",
"loggingIn": "登录中...",
"successMessage": "登录成功",
"errorEmptyFields": "请输入您的用户名和密码",
"errorInvalidCredentials": "登录失败,请检查用户名和密码",
"authDisabled": "认证已禁用,使用无需登陆模式。",
"guestMode": "无需登陆"
},
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
"button": "清空", "button": "清空",
@@ -96,10 +111,12 @@
"zoomControl": { "zoomControl": {
"zoomIn": "放大", "zoomIn": "放大",
"zoomOut": "缩小", "zoomOut": "缩小",
"resetZoom": "重置缩放" "resetZoom": "重置缩放",
"rotateCamera": "顺时针旋转图形",
"rotateCameraCounterClockwise": "逆时针旋转图形"
}, },
"layoutsControl": { "layoutsControl": {
"startAnimation": "开始布局动画", "startAnimation": "继续布局动画",
"stopAnimation": "停止布局动画", "stopAnimation": "停止布局动画",
"layoutGraph": "图布局", "layoutGraph": "图布局",
"layouts": { "layouts": {
@@ -108,7 +125,7 @@
"Random": "随机", "Random": "随机",
"Noverlaps": "无重叠", "Noverlaps": "无重叠",
"Force Directed": "力导向", "Force Directed": "力导向",
"Force Atlas": "力图" "Force Atlas": "力图"
} }
}, },
"fullScreenControl": { "fullScreenControl": {
@@ -148,6 +165,11 @@
"degree": "度数", "degree": "度数",
"properties": "属性", "properties": "属性",
"relationships": "关系", "relationships": "关系",
"expandNode": "扩展节点",
"pruneNode": "修剪节点",
"deleteAllNodesError": "拒绝删除图中的所有节点",
"nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点",
"noNewNodes": "没有发现可以扩展的节点",
"propertyNames": { "propertyNames": {
"description": "描述", "description": "描述",
"entity_id": "名称", "entity_id": "名称",
@@ -174,7 +196,8 @@
"noLabels": "未找到标签", "noLabels": "未找到标签",
"label": "标签", "label": "标签",
"placeholder": "搜索标签...", "placeholder": "搜索标签...",
"andOthers": "还有 {count} 个" "andOthers": "还有 {count} 个",
"refreshTooltip": "重新加载图形数据"
} }
}, },
"retrievePanel": { "retrievePanel": {

View File

@@ -1,5 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' 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>
)

View File

@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { createSelectors } from '@/lib/utils' import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology' import { DirectedGraph } from 'graphology'
import { getGraphLabels } from '@/api/lightrag' import { getGraphLabels } from '@/api/lightrag'
import MiniSearch from 'minisearch'
export type RawNodeType = { export type RawNodeType = {
id: string id: string
@@ -66,8 +67,11 @@ interface GraphState {
rawGraph: RawGraph | null rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null sigmaGraph: DirectedGraph | null
sigmaInstance: any | null
allDatabaseLabels: string[] allDatabaseLabels: string[]
searchEngine: MiniSearch | null
moveToSelectedNode: boolean moveToSelectedNode: boolean
isFetching: boolean isFetching: boolean
shouldRender: boolean shouldRender: boolean
@@ -76,7 +80,7 @@ interface GraphState {
graphDataFetchAttempted: boolean graphDataFetchAttempted: boolean
labelsFetchAttempted: boolean labelsFetchAttempted: boolean
refreshLayout: () => void setSigmaInstance: (instance: any) => void
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
setFocusedNode: (nodeId: string | null) => void setFocusedNode: (nodeId: string | null) => void
setSelectedEdge: (edgeId: string | null) => void setSelectedEdge: (edgeId: string | null) => void
@@ -93,12 +97,24 @@ interface GraphState {
setIsFetching: (isFetching: boolean) => void setIsFetching: (isFetching: boolean) => void
setShouldRender: (shouldRender: boolean) => void setShouldRender: (shouldRender: boolean) => void
// 搜索引擎方法
setSearchEngine: (engine: MiniSearch | null) => void
resetSearchEngine: () => void
// Methods to set global flags // Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => void setGraphDataFetchAttempted: (attempted: boolean) => void
setLabelsFetchAttempted: (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, selectedNode: null,
focusedNode: null, focusedNode: null,
selectedEdge: null, selectedEdge: null,
@@ -114,18 +130,11 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
rawGraph: null, rawGraph: null,
sigmaGraph: null, sigmaGraph: null,
sigmaInstance: null,
allDatabaseLabels: ['*'], allDatabaseLabels: ['*'],
refreshLayout: () => { searchEngine: null,
const currentGraph = get().sigmaGraph;
if (currentGraph) {
get().clearSelection();
get().setSigmaGraph(null);
setTimeout(() => {
get().setSigmaGraph(currentGraph);
}, 10);
}
},
setIsFetching: (isFetching: boolean) => set({ isFetching }), setIsFetching: (isFetching: boolean) => set({ isFetching }),
setShouldRender: (shouldRender: boolean) => set({ shouldRender }), setShouldRender: (shouldRender: boolean) => set({ shouldRender }),
@@ -142,22 +151,14 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
focusedEdge: null focusedEdge: null
}), }),
reset: () => { reset: () => {
// Get the existing graph
const existingGraph = get().sigmaGraph;
// If we have an existing graph, clear it by removing all nodes
if (existingGraph) {
const nodes = Array.from(existingGraph.nodes());
nodes.forEach(node => existingGraph.dropNode(node));
}
set({ set({
selectedNode: null, selectedNode: null,
focusedNode: null, focusedNode: null,
selectedEdge: null, selectedEdge: null,
focusedEdge: null, focusedEdge: null,
rawGraph: null, rawGraph: null,
// Keep the existing graph instance but with cleared data sigmaGraph: null, // to avoid other components from acccessing graph objects
searchEngine: null,
moveToSelectedNode: false, moveToSelectedNode: false,
shouldRender: false shouldRender: false
}); });
@@ -190,9 +191,23 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }), 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 // Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }), 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) const useGraphStore = createSelectors(useGraphStoreBase)

View File

@@ -16,6 +16,15 @@ interface BackendState {
setErrorMessage: (message: string, messageTitle: string) => void setErrorMessage: (message: string, messageTitle: string) => void
} }
interface AuthState {
isAuthenticated: boolean;
showLoginModal: boolean;
isGuestMode: boolean; // Add guest mode flag
login: (token: string, isGuest?: boolean) => void;
logout: () => void;
setShowLoginModal: (show: boolean) => void;
}
const useBackendStateStoreBase = create<BackendState>()((set) => ({ const useBackendStateStoreBase = create<BackendState>()((set) => ({
health: true, health: true,
message: null, message: null,
@@ -57,3 +66,64 @@ const useBackendStateStoreBase = create<BackendState>()((set) => ({
const useBackendState = createSelectors(useBackendStateStoreBase) const useBackendState = createSelectors(useBackendStateStoreBase)
export { useBackendState } 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,
showLoginModal: false,
isGuestMode: initialState.isGuestMode,
login: (token, isGuest = false) => {
localStorage.setItem('LIGHTRAG-API-TOKEN', token);
set({
isAuthenticated: true,
showLoginModal: false,
isGuestMode: isGuest
});
},
logout: () => {
localStorage.removeItem('LIGHTRAG-API-TOKEN');
set({
isAuthenticated: false,
isGuestMode: false
});
},
setShowLoginModal: (show) => set({ showLoginModal: show })
};
});

View File

@@ -26,5 +26,5 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "vite.config.ts", "src/vite-env.d.ts"]
} }

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import path from 'path' import path from 'path'
import { webuiPrefix } from '@/lib/constants'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@@ -12,7 +12,8 @@ export default defineConfig({
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
} }
}, },
base: './', // base: import.meta.env.VITE_BASE_URL || '/webui/',
base: webuiPrefix,
build: { build: {
outDir: path.resolve(__dirname, '../lightrag/api/webui'), outDir: path.resolve(__dirname, '../lightrag/api/webui'),
emptyOutDir: true emptyOutDir: true