Merge branch 'HKUDS:main' into main
This commit is contained in:
10
env.example
10
env.example
@@ -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
|
||||||
|
@@ -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"
|
||||||
|
@@ -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)])
|
||||||
|
@@ -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
1178
lightrag/api/webui/assets/index-CSrxfS-k.js
Normal file
1178
lightrag/api/webui/assets/index-CSrxfS-k.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
lightrag/api/webui/assets/index-mPRIIErN.css
Normal file
1
lightrag/api/webui/assets/index-mPRIIErN.css
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
@@ -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
|
||||||
|
@@ -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=="],
|
||||||
|
2
lightrag_webui/env.development.smaple
Normal file
2
lightrag_webui/env.development.smaple
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Development environment configuration
|
||||||
|
VITE_BACKEND_URL=/api
|
3
lightrag_webui/env.local.sample
Normal file
3
lightrag_webui/env.local.sample
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_BACKEND_URL=http://localhost:9621
|
||||||
|
VITE_API_PROXY=true
|
||||||
|
VITE_API_ENDPOINTS=/,/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login,/auth-status
|
@@ -5,7 +5,7 @@
|
|||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
<meta http-equiv="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>
|
||||||
|
@@ -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",
|
||||||
|
@@ -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>
|
||||||
|
157
lightrag_webui/src/AppRouter.tsx
Normal file
157
lightrag_webui/src/AppRouter.tsx
Normal 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
|
@@ -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({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Interceptor:add 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;
|
||||||
|
}
|
||||||
|
@@ -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>
|
||||||
|
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that toggles the language between English and Chinese.
|
||||||
|
*/
|
||||||
|
export default function LanguageToggle() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const currentLanguage = i18n.language
|
||||||
|
const setLanguage = useSettingsStore.use.setLanguage()
|
||||||
|
|
||||||
|
const setEnglish = useCallback(() => {
|
||||||
|
i18n.changeLanguage('en')
|
||||||
|
setLanguage('en')
|
||||||
|
}, [i18n, setLanguage])
|
||||||
|
|
||||||
|
const setChinese = useCallback(() => {
|
||||||
|
i18n.changeLanguage('zh')
|
||||||
|
setLanguage('zh')
|
||||||
|
}, [i18n, setLanguage])
|
||||||
|
|
||||||
|
if (currentLanguage === 'zh') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={setEnglish}
|
||||||
|
variant={controlButtonVariant}
|
||||||
|
tooltip="Switch to English"
|
||||||
|
size="icon"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
中
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={setChinese}
|
||||||
|
variant={controlButtonVariant}
|
||||||
|
tooltip="切换到中文"
|
||||||
|
size="icon"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@@ -13,23 +13,37 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
|
|||||||
* When the selected item changes, highlighted the node and center the camera on it.
|
* 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])
|
||||||
|
@@ -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');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting graph on sigma instance:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
assignLayout();
|
||||||
|
console.log('Initial layout applied to graph');
|
||||||
}
|
}
|
||||||
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
|
}, [sigma, sigmaGraph, assignLayout, maxIterations])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the sigma instance is set in the store
|
||||||
|
* This provides a backup in case the instance wasn't set in GraphViewer
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (sigma) {
|
||||||
|
// Double-check that the store has the sigma instance
|
||||||
|
const currentInstance = useGraphStore.getState().sigmaInstance;
|
||||||
|
if (!currentInstance) {
|
||||||
|
console.log('Setting sigma instance from GraphControl');
|
||||||
|
useGraphStore.getState().setSigmaInstance(sigma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sigma]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When component mount
|
* 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)) {
|
||||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
try {
|
||||||
newData.highlighted = true
|
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||||
if (node === selectedNode) {
|
newData.highlighted = true
|
||||||
newData.borderColor = Constants.nodeBorderColorSelected
|
if (node === selectedNode) {
|
||||||
|
newData.borderColor = Constants.nodeBorderColorSelected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in nodeReducer:', error);
|
||||||
}
|
}
|
||||||
} else if (_focusedEdge) {
|
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
|
||||||
if (graph.extremities(_focusedEdge).includes(node)) {
|
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||||
newData.highlighted = true
|
newData.highlighted = true
|
||||||
newData.size = 3
|
newData.size = 3
|
||||||
@@ -173,21 +204,28 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
|||||||
if (!disableHoverEffect) {
|
if (!disableHoverEffect) {
|
||||||
const _focusedNode = focusedNode || selectedNode
|
const _focusedNode = focusedNode || selectedNode
|
||||||
|
|
||||||
if (_focusedNode) {
|
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||||
if (hideUnselectedEdges) {
|
try {
|
||||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
if (hideUnselectedEdges) {
|
||||||
newData.hidden = true
|
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||||
}
|
newData.hidden = true
|
||||||
} else {
|
}
|
||||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
} else {
|
||||||
newData.color = Constants.edgeColorHighlighted
|
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||||
|
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
|
||||||
|
@@ -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,52 +86,70 @@ 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 (
|
||||||
<AsyncSelect<string>
|
<div className="flex items-center">
|
||||||
className="ml-2"
|
{rawGraph && (
|
||||||
triggerClassName="max-h-8"
|
<Button
|
||||||
searchInputClassName="max-h-8"
|
size="icon"
|
||||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
variant={controlButtonVariant}
|
||||||
fetcher={fetchData}
|
onClick={handleRefresh}
|
||||||
renderOption={(item) => <div>{item}</div>}
|
tooltip={t('graphPanel.graphLabels.refreshTooltip')}
|
||||||
getOptionValue={(item) => item}
|
className="mr-1"
|
||||||
getDisplayValue={(item) => <div>{item}</div>}
|
>
|
||||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
<RefreshCw className="h-4 w-4" />
|
||||||
label={t('graphPanel.graphLabels.label')}
|
</Button>
|
||||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
)}
|
||||||
value={label !== null ? label : '*'}
|
<AsyncSelect<string>
|
||||||
onChange={(newLabel) => {
|
className="ml-2"
|
||||||
const currentLabel = useSettingsStore.getState().queryLabel
|
triggerClassName="max-h-8"
|
||||||
|
searchInputClassName="max-h-8"
|
||||||
|
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||||
|
fetcher={fetchData}
|
||||||
|
renderOption={(item) => <div>{item}</div>}
|
||||||
|
getOptionValue={(item) => item}
|
||||||
|
getDisplayValue={(item) => <div>{item}</div>}
|
||||||
|
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||||
|
label={t('graphPanel.graphLabels.label')}
|
||||||
|
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||||
|
value={label !== null ? label : '*'}
|
||||||
|
onChange={(newLabel) => {
|
||||||
|
const currentLabel = useSettingsStore.getState().queryLabel
|
||||||
|
|
||||||
// select the last item means query all
|
// select the last item means query all
|
||||||
if (newLabel === '...') {
|
if (newLabel === '...') {
|
||||||
newLabel = '*'
|
newLabel = '*'
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the fetch attempted flag to force a new data fetch
|
|
||||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
|
||||||
|
|
||||||
// Clear current graph data to ensure complete reload when label changes
|
|
||||||
if (newLabel !== currentLabel) {
|
|
||||||
const graphStore = useGraphStore.getState();
|
|
||||||
graphStore.clearSelection();
|
|
||||||
|
|
||||||
// Reset the graph state but preserve the instance
|
|
||||||
if (graphStore.sigmaGraph) {
|
|
||||||
const nodes = Array.from(graphStore.sigmaGraph.nodes());
|
|
||||||
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (newLabel === currentLabel && newLabel !== '*') {
|
// Reset the fetch attempted flag to force a new data fetch
|
||||||
// reselect the same itme means qery all
|
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||||
useSettingsStore.getState().setQueryLabel('*')
|
|
||||||
} else {
|
// Clear current graph data to ensure complete reload when label changes
|
||||||
useSettingsStore.getState().setQueryLabel(newLabel)
|
if (newLabel !== currentLabel) {
|
||||||
}
|
const graphStore = useGraphStore.getState();
|
||||||
}}
|
// Reset the all graph objects and status
|
||||||
clearable={false} // Prevent clearing value on reselect
|
graphStore.reset();
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
if (newLabel === currentLabel && newLabel !== '*') {
|
||||||
|
// reselect the same itme means qery all
|
||||||
|
useSettingsStore.getState().setQueryLabel('*')
|
||||||
|
} else {
|
||||||
|
useSettingsStore.getState().setQueryLabel(newLabel)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
clearable={false} // Prevent clearing value on reselect
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,22 +100,35 @@ 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)
|
||||||
id: r.id,
|
.filter((r: { id: string }) => graph.hasNode(r.id))
|
||||||
type: 'nodes'
|
.map((r: { id: string }) => ({
|
||||||
}))
|
id: r.id,
|
||||||
|
type: 'nodes'
|
||||||
|
}))
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
return result.length <= searchResultLimit
|
return result.length <= searchResultLimit
|
||||||
|
@@ -7,7 +7,7 @@ import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
|||||||
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
import { 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 =
|
|
||||||
autoRunFor > 0
|
|
||||||
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
//cleaning
|
// Initial position update
|
||||||
return () => {
|
updatePositions()
|
||||||
stop()
|
|
||||||
if (timeout) {
|
// Set up interval for continuous updates
|
||||||
clearTimeout(timeout)
|
animationTimerRef.current = window.setInterval(() => {
|
||||||
|
updatePositions()
|
||||||
|
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
||||||
|
|
||||||
|
setIsRunning(true)
|
||||||
|
|
||||||
|
// Set a timeout to stop it if autoRunFor > 0
|
||||||
|
if (autoRunFor > 0) {
|
||||||
|
timeout = window.setTimeout(() => {
|
||||||
|
console.log('Auto-stopping layout animation after timeout')
|
||||||
|
if (animationTimerRef.current) {
|
||||||
|
window.clearInterval(animationTimerRef.current)
|
||||||
|
animationTimerRef.current = null
|
||||||
|
}
|
||||||
|
setIsRunning(false)
|
||||||
|
}, autoRunFor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [autoRunFor, start, stop, sigma])
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
// console.log('Cleaning up WorkerLayoutControl')
|
||||||
|
if (animationTimerRef.current) {
|
||||||
|
window.clearInterval(animationTimerRef.current)
|
||||||
|
animationTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (timeout) {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}, [autoRunFor, sigma, updatePositions])
|
||||||
|
|
||||||
return (
|
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 })
|
|
||||||
setLayout(newLayout)
|
try {
|
||||||
|
const graph = sigma.getGraph()
|
||||||
|
if (!graph) {
|
||||||
|
console.error('No graph available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = positions()
|
||||||
|
console.log('Positions calculated, animating nodes')
|
||||||
|
animateNodes(graph, pos, { duration: 400 })
|
||||||
|
setLayout(newLayout)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error running layout:', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[layouts, sigma]
|
[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>
|
||||||
|
@@ -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,22 +90,41 @@ 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 {
|
||||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
if (!state.sigmaGraph.hasNode(node.id)) {
|
||||||
if (edge) {
|
return {
|
||||||
const isTarget = node.id === edge.source
|
...node,
|
||||||
const neighbourId = isTarget ? edge.target : edge.source
|
relationships: []
|
||||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
|
||||||
if (neighbour) {
|
|
||||||
relationships.push({
|
|
||||||
type: 'Neighbour',
|
|
||||||
id: neighbourId,
|
|
||||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edges = state.sigmaGraph.edges(node.id)
|
||||||
|
|
||||||
|
for (const edgeId of edges) {
|
||||||
|
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
|
||||||
|
|
||||||
|
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||||
|
if (edge) {
|
||||||
|
const isTarget = node.id === edge.source
|
||||||
|
const neighbourId = isTarget ? edge.target : edge.source
|
||||||
|
|
||||||
|
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
|
||||||
|
|
||||||
|
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||||
|
if (neighbour) {
|
||||||
|
relationships.push({
|
||||||
|
type: 'Neighbour',
|
||||||
|
id: neighbourId,
|
||||||
|
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refining node properties:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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()
|
||||||
|
@@ -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">
|
||||||
|
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -11,18 +11,16 @@ 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}
|
sideOffset={sideOffset}
|
||||||
sideOffset={sideOffset}
|
className={cn(
|
||||||
className={cn(
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
className
|
||||||
className
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
))
|
))
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
@@ -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}
|
||||||
|
@@ -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]);
|
||||||
|
|
||||||
|
@@ -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,62 +180,51 @@ 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 */}
|
<SigmaContainer
|
||||||
{isGraphTabVisible ? (
|
settings={sigmaSettings}
|
||||||
<SigmaContainer
|
className="!bg-background !size-full overflow-hidden"
|
||||||
settings={sigmaSettings}
|
ref={sigmaRef}
|
||||||
className="!bg-background !size-full overflow-hidden"
|
>
|
||||||
ref={sigmaRef}
|
<GraphControl />
|
||||||
>
|
|
||||||
<GraphControl />
|
|
||||||
|
|
||||||
{enableNodeDrag && <GraphEvents />}
|
{enableNodeDrag && <GraphEvents />}
|
||||||
|
|
||||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||||
|
|
||||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||||
<GraphLabels />
|
<GraphLabels />
|
||||||
{showNodeSearchBar && (
|
{showNodeSearchBar && (
|
||||||
<GraphSearch
|
<GraphSearch
|
||||||
value={searchInitSelectedNode}
|
value={searchInitSelectedNode}
|
||||||
onFocus={onSearchFocus}
|
onFocus={onSearchFocus}
|
||||||
onChange={onSearchSelect}
|
onChange={onSearchSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
|
||||||
<Settings />
|
|
||||||
<ZoomControl />
|
|
||||||
<LayoutsControl />
|
|
||||||
<FullScreenControl />
|
|
||||||
{/* <ThemeToggle /> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPropertyPanel && (
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<PropertiesView />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
|
||||||
<MiniMap width="100px" height="100px" />
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<SettingsDisplay />
|
|
||||||
</SigmaContainer>
|
|
||||||
) : (
|
|
||||||
// Placeholder when tab is not visible
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
{/* Placeholder content */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||||
|
<LayoutsControl />
|
||||||
|
<ZoomControl />
|
||||||
|
<FullScreenControl />
|
||||||
|
<Settings />
|
||||||
|
{/* <ThemeToggle /> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPropertyPanel && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<PropertiesView />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||||
|
<MiniMap width="100px" height="100px" />
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<SettingsDisplay />
|
||||||
|
</SigmaContainer>
|
||||||
|
|
||||||
{/* Loading overlay - shown when data is loading */}
|
{/* Loading overlay - shown when data is loading */}
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
|
168
lightrag_webui/src/features/LoginPage.tsx
Normal file
168
lightrag_webui/src/features/LoginPage.tsx
Normal 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
|
@@ -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">
|
||||||
|
@@ -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>
|
||||||
|
@@ -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) {
|
||||||
|
35
lightrag_webui/src/i18n.js
Normal file
35
lightrag_webui/src/i18n.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { useSettingsStore } from "./stores/settings";
|
||||||
|
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
import zh from "./locales/zh.json";
|
||||||
|
|
||||||
|
const getStoredLanguage = () => {
|
||||||
|
try {
|
||||||
|
const settingsString = localStorage.getItem('settings-storage');
|
||||||
|
if (settingsString) {
|
||||||
|
const settings = JSON.parse(settingsString);
|
||||||
|
return settings.state?.language || 'en';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get stored language:', e);
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
zh: { translation: zh }
|
||||||
|
},
|
||||||
|
lng: getStoredLanguage(), // 使用存储的语言设置
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
@@ -1,6 +1,7 @@
|
|||||||
import { ButtonVariantType } from '@/components/ui/Button'
|
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'
|
||||||
|
|
||||||
|
@@ -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": {
|
||||||
|
@@ -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": {
|
||||||
|
@@ -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>
|
||||||
|
)
|
||||||
|
@@ -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)
|
||||||
|
@@ -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 })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
@@ -26,5 +26,5 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts", "src/vite-env.d.ts"]
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { 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
|
||||||
|
Reference in New Issue
Block a user