Merge branch 'HKUDS:main' into main
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1 +1,2 @@
|
||||
lightrag/api/webui/** -diff
|
||||
lightrag/api/webui/** binary
|
||||
lightrag/api/webui/** linguist-generated
|
||||
|
17
README.md
17
README.md
@@ -45,6 +45,7 @@ This repository hosts the code of LightRAG. The structure of this code is based
|
||||
🎉 News
|
||||
</summary>
|
||||
|
||||
- [X] [2025.03.18]🎯📢LightRAG now supports citation functionality.
|
||||
- [X] [2025.02.05]🎯📢Our team has released [VideoRAG](https://github.com/HKUDS/VideoRAG) understanding extremely long-context videos.
|
||||
- [X] [2025.01.13]🎯📢Our team has released [MiniRAG](https://github.com/HKUDS/MiniRAG) making RAG simpler with small models.
|
||||
- [X] [2025.01.06]🎯📢You can now [use PostgreSQL for Storage](#using-postgresql-for-storage).
|
||||
@@ -673,6 +674,22 @@ rag.insert(text_content.decode('utf-8'))
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Citation Functionality</b></summary>
|
||||
|
||||
By providing file paths, the system ensures that sources can be traced back to their original documents.
|
||||
|
||||
```python
|
||||
# Define documents and their file paths
|
||||
documents = ["Document content 1", "Document content 2"]
|
||||
file_paths = ["path/to/doc1.txt", "path/to/doc2.txt"]
|
||||
|
||||
# Insert documents with file paths
|
||||
rag.insert(documents, file_paths=file_paths)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Storage
|
||||
|
||||
<details>
|
||||
|
12
env.example
12
env.example
@@ -73,6 +73,8 @@ LLM_BINDING_HOST=http://localhost:11434
|
||||
### Embedding Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)
|
||||
EMBEDDING_MODEL=bge-m3:latest
|
||||
EMBEDDING_DIM=1024
|
||||
EMBEDDING_BATCH_NUM=32
|
||||
EMBEDDING_FUNC_MAX_ASYNC=16
|
||||
# EMBEDDING_BINDING_API_KEY=your_api_key
|
||||
### ollama example
|
||||
EMBEDDING_BINDING=ollama
|
||||
@@ -151,9 +153,9 @@ QDRANT_URL=http://localhost:16333
|
||||
### Redis
|
||||
REDIS_URI=redis://localhost:6379
|
||||
|
||||
# For jwt auth
|
||||
AUTH_USERNAME=admin # login name
|
||||
AUTH_PASSWORD=admin123 # password
|
||||
TOKEN_SECRET=your-key # JWT key
|
||||
TOKEN_EXPIRE_HOURS=4 # expire duration
|
||||
### For JWTt Auth
|
||||
AUTH_USERNAME=admin # login name
|
||||
AUTH_PASSWORD=admin123 # password
|
||||
TOKEN_SECRET=your-key-for-LightRAG-API-Server # JWT key
|
||||
TOKEN_EXPIRE_HOURS=4 # expire duration
|
||||
WHITELIST_PATHS=/login,/health # white list
|
||||
|
@@ -1,5 +1,5 @@
|
||||
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
||||
|
||||
__version__ = "1.2.6"
|
||||
__version__ = "1.2.7"
|
||||
__author__ = "Zirui Guo"
|
||||
__url__ = "https://github.com/HKUDS/LightRAG"
|
||||
|
@@ -3,11 +3,16 @@ from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str
|
||||
exp: datetime
|
||||
sub: str # Username
|
||||
exp: datetime # Expiration time
|
||||
role: str = "user" # User role, default is regular user
|
||||
metadata: dict = {} # Additional metadata
|
||||
|
||||
|
||||
class AuthHandler:
|
||||
@@ -15,13 +20,60 @@ class AuthHandler:
|
||||
self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46")
|
||||
self.algorithm = "HS256"
|
||||
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)
|
||||
|
||||
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:
|
||||
payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
|
||||
expire_timestamp = payload["exp"]
|
||||
@@ -31,7 +83,14 @@ class AuthHandler:
|
||||
raise HTTPException(
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
|
@@ -29,7 +29,9 @@ preload_app = True
|
||||
worker_class = "uvicorn.workers.UvicornWorker"
|
||||
|
||||
# Other Gunicorn configurations
|
||||
timeout = int(os.getenv("TIMEOUT", 150)) # Default 150s to match run_with_gunicorn.py
|
||||
timeout = int(
|
||||
os.getenv("TIMEOUT", 150 * 2)
|
||||
) # Default 150s *2 to match run_with_gunicorn.py
|
||||
keepalive = int(os.getenv("KEEPALIVE", 5)) # Default 5s
|
||||
|
||||
# Logging configuration
|
||||
|
@@ -10,6 +10,7 @@ import logging.config
|
||||
import uvicorn
|
||||
import pipmaster as pm
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
from ascii_colors import ASCIIColors
|
||||
@@ -48,7 +49,7 @@ from .auth import auth_handler
|
||||
# Load environment variables
|
||||
# Updated to use the .env that is inside the current folder
|
||||
# This update allows the user to put a different.env file for each lightrag folder
|
||||
load_dotenv(".env", override=True)
|
||||
load_dotenv()
|
||||
|
||||
# Initialize config parser
|
||||
config = configparser.ConfigParser()
|
||||
@@ -341,25 +342,62 @@ def create_app(args):
|
||||
ollama_api = OllamaAPI(rag, top_k=args.top_k)
|
||||
app.include_router(ollama_api.router, prefix="/api")
|
||||
|
||||
@app.post("/login")
|
||||
@app.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()):
|
||||
username = os.getenv("AUTH_USERNAME")
|
||||
password = os.getenv("AUTH_PASSWORD")
|
||||
|
||||
if not (username and password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Authentication not configured",
|
||||
# Authentication not configured, return guest token
|
||||
guest_token = auth_handler.create_token(
|
||||
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:
|
||||
raise HTTPException(
|
||||
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 {
|
||||
"access_token": auth_handler.create_token(username),
|
||||
"access_token": user_token,
|
||||
"token_type": "bearer",
|
||||
"auth_mode": "enabled",
|
||||
}
|
||||
|
||||
@app.get("/health", dependencies=[Depends(optional_api_key)])
|
||||
|
@@ -405,7 +405,7 @@ async def pipeline_index_file(rag: LightRAG, file_path: Path):
|
||||
|
||||
|
||||
async def pipeline_index_files(rag: LightRAG, file_paths: List[Path]):
|
||||
"""Index multiple files concurrently
|
||||
"""Index multiple files sequentially to avoid high CPU load
|
||||
|
||||
Args:
|
||||
rag: LightRAG instance
|
||||
@@ -416,12 +416,12 @@ async def pipeline_index_files(rag: LightRAG, file_paths: List[Path]):
|
||||
try:
|
||||
enqueued = False
|
||||
|
||||
if len(file_paths) == 1:
|
||||
enqueued = await pipeline_enqueue_file(rag, file_paths[0])
|
||||
else:
|
||||
tasks = [pipeline_enqueue_file(rag, path) for path in file_paths]
|
||||
enqueued = any(await asyncio.gather(*tasks))
|
||||
# Process files sequentially
|
||||
for file_path in file_paths:
|
||||
if await pipeline_enqueue_file(rag, file_path):
|
||||
enqueued = True
|
||||
|
||||
# Process the queue only if at least one file was successfully enqueued
|
||||
if enqueued:
|
||||
await rag.apipeline_process_enqueue_documents()
|
||||
except Exception as e:
|
||||
@@ -472,14 +472,34 @@ async def run_scanning_process(rag: LightRAG, doc_manager: DocumentManager):
|
||||
total_files = len(new_files)
|
||||
logger.info(f"Found {total_files} new files to index.")
|
||||
|
||||
for idx, file_path in enumerate(new_files):
|
||||
try:
|
||||
await pipeline_index_file(rag, file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing file {file_path}: {str(e)}")
|
||||
if not new_files:
|
||||
return
|
||||
|
||||
# Get MAX_PARALLEL_INSERT from global_args
|
||||
max_parallel = global_args["max_parallel_insert"]
|
||||
# Calculate batch size as 2 * MAX_PARALLEL_INSERT
|
||||
batch_size = 2 * max_parallel
|
||||
|
||||
# Process files in batches
|
||||
for i in range(0, total_files, batch_size):
|
||||
batch_files = new_files[i : i + batch_size]
|
||||
batch_num = i // batch_size + 1
|
||||
total_batches = (total_files + batch_size - 1) // batch_size
|
||||
|
||||
logger.info(
|
||||
f"Processing batch {batch_num}/{total_batches} with {len(batch_files)} files"
|
||||
)
|
||||
await pipeline_index_files(rag, batch_files)
|
||||
|
||||
# Log progress
|
||||
processed = min(i + batch_size, total_files)
|
||||
logger.info(
|
||||
f"Processed {processed}/{total_files} files ({processed/total_files*100:.1f}%)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during scanning process: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def create_document_routes(
|
||||
|
@@ -13,7 +13,7 @@ from dotenv import load_dotenv
|
||||
|
||||
# Updated to use the .env that is inside the current folder
|
||||
# This update allows the user to put a different.env file for each lightrag folder
|
||||
load_dotenv(".env")
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def check_and_install_dependencies():
|
||||
@@ -140,7 +140,7 @@ def main():
|
||||
|
||||
# Timeout configuration prioritizes command line arguments
|
||||
gunicorn_config.timeout = (
|
||||
args.timeout if args.timeout else int(os.getenv("TIMEOUT", 150))
|
||||
args.timeout if args.timeout * 2 else int(os.getenv("TIMEOUT", 150 * 2))
|
||||
)
|
||||
|
||||
# Keepalive configuration
|
||||
|
@@ -9,14 +9,14 @@ import sys
|
||||
import logging
|
||||
from ascii_colors import ASCIIColors
|
||||
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 fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||
from starlette.status import HTTP_403_FORBIDDEN
|
||||
from .auth import auth_handler
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(override=True)
|
||||
load_dotenv()
|
||||
|
||||
global_args = {"main_args": None}
|
||||
|
||||
@@ -35,19 +35,46 @@ ollama_server_infos = OllamaServerInfos()
|
||||
|
||||
|
||||
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(
|
||||
request: Request,
|
||||
token: str = Depends(OAuth2PasswordBearer(tokenUrl="login", auto_error=False)),
|
||||
):
|
||||
# Check if authentication is configured
|
||||
auth_configured = bool(
|
||||
os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")
|
||||
)
|
||||
|
||||
# If authentication is not configured, skip all validation
|
||||
if not auth_configured:
|
||||
return
|
||||
|
||||
# For configured auth, allow whitelist paths without token
|
||||
if request.url.path in whitelist:
|
||||
return
|
||||
|
||||
if not (os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")):
|
||||
return
|
||||
# Require token for all other paths when auth is configured
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required"
|
||||
)
|
||||
|
||||
auth_handler.validate_token(token)
|
||||
try:
|
||||
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.",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
return dependency
|
||||
|
||||
@@ -338,6 +365,9 @@ def parse_args(is_uvicorn_mode: bool = False) -> argparse.Namespace:
|
||||
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
|
||||
)
|
||||
|
||||
# Get MAX_PARALLEL_INSERT from environment
|
||||
global_args["max_parallel_insert"] = get_env_value("MAX_PARALLEL_INSERT", 2, int)
|
||||
|
||||
# Handle openai-ollama special case
|
||||
if args.llm_binding == "openai-ollama":
|
||||
args.llm_binding = "openai"
|
||||
@@ -414,8 +444,8 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
ASCIIColors.yellow(f"{args.log_level}")
|
||||
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
|
||||
ASCIIColors.yellow(f"{args.verbose}")
|
||||
ASCIIColors.white(" ├─ Timeout: ", end="")
|
||||
ASCIIColors.yellow(f"{args.timeout if args.timeout else 'None (infinite)'}")
|
||||
ASCIIColors.white(" ├─ History Turns: ", end="")
|
||||
ASCIIColors.yellow(f"{args.history_turns}")
|
||||
ASCIIColors.white(" └─ API Key: ", end="")
|
||||
ASCIIColors.yellow("Set" if args.key else "Not Set")
|
||||
|
||||
@@ -432,8 +462,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
ASCIIColors.yellow(f"{args.llm_binding}")
|
||||
ASCIIColors.white(" ├─ Host: ", end="")
|
||||
ASCIIColors.yellow(f"{args.llm_binding_host}")
|
||||
ASCIIColors.white(" └─ Model: ", end="")
|
||||
ASCIIColors.white(" ├─ Model: ", end="")
|
||||
ASCIIColors.yellow(f"{args.llm_model}")
|
||||
ASCIIColors.white(" └─ Timeout: ", end="")
|
||||
ASCIIColors.yellow(f"{args.timeout if args.timeout else 'None (infinite)'}")
|
||||
|
||||
# Embedding Configuration
|
||||
ASCIIColors.magenta("\n📊 Embedding Configuration:")
|
||||
@@ -448,8 +480,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
|
||||
# RAG Configuration
|
||||
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
|
||||
ASCIIColors.white(" ├─ Max Async Operations: ", end="")
|
||||
ASCIIColors.white(" ├─ Max Async for LLM: ", end="")
|
||||
ASCIIColors.yellow(f"{args.max_async}")
|
||||
ASCIIColors.white(" ├─ Max Parallel Insert: ", end="")
|
||||
ASCIIColors.yellow(f"{global_args['max_parallel_insert']}")
|
||||
ASCIIColors.white(" ├─ Max Tokens: ", end="")
|
||||
ASCIIColors.yellow(f"{args.max_tokens}")
|
||||
ASCIIColors.white(" ├─ Max Embed Tokens: ", end="")
|
||||
@@ -458,8 +492,6 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
ASCIIColors.yellow(f"{args.chunk_size}")
|
||||
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
|
||||
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
|
||||
ASCIIColors.white(" ├─ History Turns: ", end="")
|
||||
ASCIIColors.yellow(f"{args.history_turns}")
|
||||
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
|
||||
ASCIIColors.yellow(f"{args.cosine_threshold}")
|
||||
ASCIIColors.white(" ├─ Top-K: ", end="")
|
||||
|
File diff suppressed because one or more lines are too long
1
lightrag/api/webui/assets/index-BSOt8Nur.css
generated
Normal file
1
lightrag/api/webui/assets/index-BSOt8Nur.css
generated
Normal file
File diff suppressed because one or more lines are too long
1
lightrag/api/webui/assets/index-BV5s8k-a.css
generated
1
lightrag/api/webui/assets/index-BV5s8k-a.css
generated
File diff suppressed because one or more lines are too long
6
lightrag/api/webui/index.html
generated
6
lightrag/api/webui/index.html
generated
@@ -5,11 +5,11 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="icon" type="image/svg+xml" href="./logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag</title>
|
||||
<script type="module" crossorigin src="./assets/index-DwcJE583.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BV5s8k-a.css">
|
||||
<script type="module" crossorigin src="/webui/assets/index-4I5HV9Fr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/webui/assets/index-BSOt8Nur.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
@@ -257,6 +257,8 @@ class DocProcessingStatus:
|
||||
"""First 100 chars of document content, used for preview"""
|
||||
content_length: int
|
||||
"""Total length of document"""
|
||||
file_path: str
|
||||
"""File path of the document"""
|
||||
status: DocStatus
|
||||
"""Current processing status"""
|
||||
created_at: str
|
||||
|
@@ -87,6 +87,9 @@ class JsonDocStatusStorage(DocStatusStorage):
|
||||
# If content is missing, use content_summary as content
|
||||
if "content" not in data and "content_summary" in data:
|
||||
data["content"] = data["content_summary"]
|
||||
# If file_path is not in data, use document id as file path
|
||||
if "file_path" not in data:
|
||||
data["file_path"] = "no-file-path"
|
||||
result[k] = DocProcessingStatus(**data)
|
||||
except KeyError as e:
|
||||
logger.error(f"Missing required field for document {k}: {e}")
|
||||
|
@@ -373,6 +373,9 @@ class NetworkXStorage(BaseGraphStorage):
|
||||
# Add edges to result
|
||||
for edge in subgraph.edges():
|
||||
source, target = edge
|
||||
# Esure unique edge_id for undirect graph
|
||||
if source > target:
|
||||
source, target = target, source
|
||||
edge_id = f"{source}-{target}"
|
||||
if edge_id in seen_edges:
|
||||
continue
|
||||
|
@@ -423,6 +423,7 @@ class PGVectorStorage(BaseVectorStorage):
|
||||
"full_doc_id": item["full_doc_id"],
|
||||
"content": item["content"],
|
||||
"content_vector": json.dumps(item["__vector__"].tolist()),
|
||||
"file_path": item["file_path"],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error to prepare upsert,\nsql: {e}\nitem: {item}")
|
||||
@@ -445,6 +446,7 @@ class PGVectorStorage(BaseVectorStorage):
|
||||
"content": item["content"],
|
||||
"content_vector": json.dumps(item["__vector__"].tolist()),
|
||||
"chunk_ids": chunk_ids,
|
||||
"file_path": item["file_path"],
|
||||
# TODO: add document_id
|
||||
}
|
||||
return upsert_sql, data
|
||||
@@ -465,6 +467,7 @@ class PGVectorStorage(BaseVectorStorage):
|
||||
"content": item["content"],
|
||||
"content_vector": json.dumps(item["__vector__"].tolist()),
|
||||
"chunk_ids": chunk_ids,
|
||||
"file_path": item["file_path"],
|
||||
# TODO: add document_id
|
||||
}
|
||||
return upsert_sql, data
|
||||
@@ -732,7 +735,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
||||
if result is None or result == []:
|
||||
return None
|
||||
else:
|
||||
return DocProcessingStatus(
|
||||
return dict(
|
||||
content=result[0]["content"],
|
||||
content_length=result[0]["content_length"],
|
||||
content_summary=result[0]["content_summary"],
|
||||
@@ -740,11 +743,34 @@ class PGDocStatusStorage(DocStatusStorage):
|
||||
chunks_count=result[0]["chunks_count"],
|
||||
created_at=result[0]["created_at"],
|
||||
updated_at=result[0]["updated_at"],
|
||||
file_path=result[0]["file_path"],
|
||||
)
|
||||
|
||||
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
|
||||
"""Get doc_chunks data by id"""
|
||||
raise NotImplementedError
|
||||
"""Get doc_chunks data by multiple IDs."""
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
sql = "SELECT * FROM LIGHTRAG_DOC_STATUS WHERE workspace=$1 AND id = ANY($2)"
|
||||
params = {"workspace": self.db.workspace, "ids": ids}
|
||||
|
||||
results = await self.db.query(sql, params, True)
|
||||
|
||||
if not results:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"content": row["content"],
|
||||
"content_length": row["content_length"],
|
||||
"content_summary": row["content_summary"],
|
||||
"status": row["status"],
|
||||
"chunks_count": row["chunks_count"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"file_path": row["file_path"],
|
||||
}
|
||||
for row in results
|
||||
]
|
||||
|
||||
async def get_status_counts(self) -> dict[str, int]:
|
||||
"""Get counts of documents in each status"""
|
||||
@@ -774,6 +800,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
||||
created_at=element["created_at"],
|
||||
updated_at=element["updated_at"],
|
||||
chunks_count=element["chunks_count"],
|
||||
file_path=element["file_path"],
|
||||
)
|
||||
for element in result
|
||||
}
|
||||
@@ -793,14 +820,15 @@ class PGDocStatusStorage(DocStatusStorage):
|
||||
if not data:
|
||||
return
|
||||
|
||||
sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content,content_summary,content_length,chunks_count,status)
|
||||
values($1,$2,$3,$4,$5,$6,$7)
|
||||
sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content,content_summary,content_length,chunks_count,status,file_path)
|
||||
values($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
on conflict(id,workspace) do update set
|
||||
content = EXCLUDED.content,
|
||||
content_summary = EXCLUDED.content_summary,
|
||||
content_length = EXCLUDED.content_length,
|
||||
chunks_count = EXCLUDED.chunks_count,
|
||||
status = EXCLUDED.status,
|
||||
file_path = EXCLUDED.file_path,
|
||||
updated_at = CURRENT_TIMESTAMP"""
|
||||
for k, v in data.items():
|
||||
# chunks_count is optional
|
||||
@@ -814,6 +842,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
||||
"content_length": v["content_length"],
|
||||
"chunks_count": v["chunks_count"] if "chunks_count" in v else -1,
|
||||
"status": v["status"],
|
||||
"file_path": v["file_path"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1058,7 +1087,6 @@ class PGGraphStorage(BaseGraphStorage):
|
||||
|
||||
Args:
|
||||
query (str): a cypher query to be executed
|
||||
params (dict): parameters for the query
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: a list of dictionaries containing the result set
|
||||
@@ -1549,6 +1577,7 @@ TABLES = {
|
||||
tokens INTEGER,
|
||||
content TEXT,
|
||||
content_vector VECTOR,
|
||||
file_path VARCHAR(256),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP,
|
||||
CONSTRAINT LIGHTRAG_DOC_CHUNKS_PK PRIMARY KEY (workspace, id)
|
||||
@@ -1563,7 +1592,8 @@ TABLES = {
|
||||
content_vector VECTOR,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP,
|
||||
chunk_id TEXT NULL,
|
||||
chunk_ids VARCHAR(255)[] NULL,
|
||||
file_path TEXT NULL,
|
||||
CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id)
|
||||
)"""
|
||||
},
|
||||
@@ -1577,7 +1607,8 @@ TABLES = {
|
||||
content_vector VECTOR,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP,
|
||||
chunk_id TEXT NULL,
|
||||
chunk_ids VARCHAR(255)[] NULL,
|
||||
file_path TEXT NULL,
|
||||
CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id)
|
||||
)"""
|
||||
},
|
||||
@@ -1602,6 +1633,7 @@ TABLES = {
|
||||
content_length int4 NULL,
|
||||
chunks_count int4 NULL,
|
||||
status varchar(64) NULL,
|
||||
file_path TEXT NULL,
|
||||
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)
|
||||
@@ -1650,35 +1682,38 @@ SQL_TEMPLATES = {
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
""",
|
||||
"upsert_chunk": """INSERT INTO LIGHTRAG_DOC_CHUNKS (workspace, id, tokens,
|
||||
chunk_order_index, full_doc_id, content, content_vector)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
chunk_order_index, full_doc_id, content, content_vector, file_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace,id) DO UPDATE
|
||||
SET tokens=EXCLUDED.tokens,
|
||||
chunk_order_index=EXCLUDED.chunk_order_index,
|
||||
full_doc_id=EXCLUDED.full_doc_id,
|
||||
content = EXCLUDED.content,
|
||||
content_vector=EXCLUDED.content_vector,
|
||||
file_path=EXCLUDED.file_path,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
""",
|
||||
"upsert_entity": """INSERT INTO LIGHTRAG_VDB_ENTITY (workspace, id, entity_name, content,
|
||||
content_vector, chunk_ids)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::varchar[])
|
||||
content_vector, chunk_ids, file_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::varchar[], $7)
|
||||
ON CONFLICT (workspace,id) DO UPDATE
|
||||
SET entity_name=EXCLUDED.entity_name,
|
||||
content=EXCLUDED.content,
|
||||
content_vector=EXCLUDED.content_vector,
|
||||
chunk_ids=EXCLUDED.chunk_ids,
|
||||
file_path=EXCLUDED.file_path,
|
||||
update_time=CURRENT_TIMESTAMP
|
||||
""",
|
||||
"upsert_relationship": """INSERT INTO LIGHTRAG_VDB_RELATION (workspace, id, source_id,
|
||||
target_id, content, content_vector, chunk_ids)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[])
|
||||
target_id, content, content_vector, chunk_ids, file_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[], $8)
|
||||
ON CONFLICT (workspace,id) DO UPDATE
|
||||
SET source_id=EXCLUDED.source_id,
|
||||
target_id=EXCLUDED.target_id,
|
||||
content=EXCLUDED.content,
|
||||
content_vector=EXCLUDED.content_vector,
|
||||
chunk_ids=EXCLUDED.chunk_ids,
|
||||
file_path=EXCLUDED.file_path,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
""",
|
||||
# SQL for VectorStorage
|
||||
|
@@ -41,6 +41,9 @@ _pipeline_status_lock: Optional[LockType] = None
|
||||
_graph_db_lock: Optional[LockType] = None
|
||||
_data_init_lock: Optional[LockType] = None
|
||||
|
||||
# async locks for coroutine synchronization in multiprocess mode
|
||||
_async_locks: Optional[Dict[str, asyncio.Lock]] = None
|
||||
|
||||
|
||||
class UnifiedLock(Generic[T]):
|
||||
"""Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock"""
|
||||
@@ -51,12 +54,14 @@ class UnifiedLock(Generic[T]):
|
||||
is_async: bool,
|
||||
name: str = "unnamed",
|
||||
enable_logging: bool = True,
|
||||
async_lock: Optional[asyncio.Lock] = None,
|
||||
):
|
||||
self._lock = lock
|
||||
self._is_async = is_async
|
||||
self._pid = os.getpid() # for debug only
|
||||
self._name = name # for debug only
|
||||
self._enable_logging = enable_logging # for debug only
|
||||
self._async_lock = async_lock # auxiliary lock for coroutine synchronization
|
||||
|
||||
async def __aenter__(self) -> "UnifiedLock[T]":
|
||||
try:
|
||||
@@ -64,16 +69,39 @@ class UnifiedLock(Generic[T]):
|
||||
f"== Lock == Process {self._pid}: Acquiring lock '{self._name}' (async={self._is_async})",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
|
||||
# If in multiprocess mode and async lock exists, acquire it first
|
||||
if not self._is_async and self._async_lock is not None:
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Acquiring async lock for '{self._name}'",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
await self._async_lock.acquire()
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Async lock for '{self._name}' acquired",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
|
||||
# Then acquire the main lock
|
||||
if self._is_async:
|
||||
await self._lock.acquire()
|
||||
else:
|
||||
self._lock.acquire()
|
||||
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Lock '{self._name}' acquired (async={self._is_async})",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
return self
|
||||
except Exception as e:
|
||||
# If main lock acquisition fails, release the async lock if it was acquired
|
||||
if (
|
||||
not self._is_async
|
||||
and self._async_lock is not None
|
||||
and self._async_lock.locked()
|
||||
):
|
||||
self._async_lock.release()
|
||||
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
|
||||
level="ERROR",
|
||||
@@ -82,15 +110,29 @@ class UnifiedLock(Generic[T]):
|
||||
raise
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
main_lock_released = False
|
||||
try:
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Releasing lock '{self._name}' (async={self._is_async})",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
|
||||
# Release main lock first
|
||||
if self._is_async:
|
||||
self._lock.release()
|
||||
else:
|
||||
self._lock.release()
|
||||
|
||||
main_lock_released = True
|
||||
|
||||
# Then release async lock if in multiprocess mode
|
||||
if not self._is_async and self._async_lock is not None:
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Releasing async lock for '{self._name}'",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
self._async_lock.release()
|
||||
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Lock '{self._name}' released (async={self._is_async})",
|
||||
enable_output=self._enable_logging,
|
||||
@@ -101,6 +143,31 @@ class UnifiedLock(Generic[T]):
|
||||
level="ERROR",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
|
||||
# If main lock release failed but async lock hasn't been released, try to release it
|
||||
if (
|
||||
not main_lock_released
|
||||
and not self._is_async
|
||||
and self._async_lock is not None
|
||||
):
|
||||
try:
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Attempting to release async lock after main lock failure",
|
||||
level="WARNING",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
self._async_lock.release()
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Successfully released async lock after main lock failure",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
except Exception as inner_e:
|
||||
direct_log(
|
||||
f"== Lock == Process {self._pid}: Failed to release async lock after main lock failure: {inner_e}",
|
||||
level="ERROR",
|
||||
enable_output=self._enable_logging,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def __enter__(self) -> "UnifiedLock[T]":
|
||||
@@ -151,51 +218,61 @@ class UnifiedLock(Generic[T]):
|
||||
|
||||
def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||
"""return unified storage lock for data consistency"""
|
||||
async_lock = _async_locks.get("internal_lock") if is_multiprocess else None
|
||||
return UnifiedLock(
|
||||
lock=_internal_lock,
|
||||
is_async=not is_multiprocess,
|
||||
name="internal_lock",
|
||||
enable_logging=enable_logging,
|
||||
async_lock=async_lock,
|
||||
)
|
||||
|
||||
|
||||
def get_storage_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||
"""return unified storage lock for data consistency"""
|
||||
async_lock = _async_locks.get("storage_lock") if is_multiprocess else None
|
||||
return UnifiedLock(
|
||||
lock=_storage_lock,
|
||||
is_async=not is_multiprocess,
|
||||
name="storage_lock",
|
||||
enable_logging=enable_logging,
|
||||
async_lock=async_lock,
|
||||
)
|
||||
|
||||
|
||||
def get_pipeline_status_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||
"""return unified storage lock for data consistency"""
|
||||
async_lock = _async_locks.get("pipeline_status_lock") if is_multiprocess else None
|
||||
return UnifiedLock(
|
||||
lock=_pipeline_status_lock,
|
||||
is_async=not is_multiprocess,
|
||||
name="pipeline_status_lock",
|
||||
enable_logging=enable_logging,
|
||||
async_lock=async_lock,
|
||||
)
|
||||
|
||||
|
||||
def get_graph_db_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||
"""return unified graph database lock for ensuring atomic operations"""
|
||||
async_lock = _async_locks.get("graph_db_lock") if is_multiprocess else None
|
||||
return UnifiedLock(
|
||||
lock=_graph_db_lock,
|
||||
is_async=not is_multiprocess,
|
||||
name="graph_db_lock",
|
||||
enable_logging=enable_logging,
|
||||
async_lock=async_lock,
|
||||
)
|
||||
|
||||
|
||||
def get_data_init_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||
"""return unified data initialization lock for ensuring atomic data initialization"""
|
||||
async_lock = _async_locks.get("data_init_lock") if is_multiprocess else None
|
||||
return UnifiedLock(
|
||||
lock=_data_init_lock,
|
||||
is_async=not is_multiprocess,
|
||||
name="data_init_lock",
|
||||
enable_logging=enable_logging,
|
||||
async_lock=async_lock,
|
||||
)
|
||||
|
||||
|
||||
@@ -229,7 +306,8 @@ def initialize_share_data(workers: int = 1):
|
||||
_shared_dicts, \
|
||||
_init_flags, \
|
||||
_initialized, \
|
||||
_update_flags
|
||||
_update_flags, \
|
||||
_async_locks
|
||||
|
||||
# Check if already initialized
|
||||
if _initialized:
|
||||
@@ -251,6 +329,16 @@ def initialize_share_data(workers: int = 1):
|
||||
_shared_dicts = _manager.dict()
|
||||
_init_flags = _manager.dict()
|
||||
_update_flags = _manager.dict()
|
||||
|
||||
# Initialize async locks for multiprocess mode
|
||||
_async_locks = {
|
||||
"internal_lock": asyncio.Lock(),
|
||||
"storage_lock": asyncio.Lock(),
|
||||
"pipeline_status_lock": asyncio.Lock(),
|
||||
"graph_db_lock": asyncio.Lock(),
|
||||
"data_init_lock": asyncio.Lock(),
|
||||
}
|
||||
|
||||
direct_log(
|
||||
f"Process {os.getpid()} Shared-Data created for Multiple Process (workers={workers})"
|
||||
)
|
||||
@@ -264,6 +352,7 @@ def initialize_share_data(workers: int = 1):
|
||||
_shared_dicts = {}
|
||||
_init_flags = {}
|
||||
_update_flags = {}
|
||||
_async_locks = None # No need for async locks in single process mode
|
||||
direct_log(f"Process {os.getpid()} Shared-Data created for Single Process")
|
||||
|
||||
# Mark as initialized
|
||||
@@ -458,7 +547,8 @@ def finalize_share_data():
|
||||
_shared_dicts, \
|
||||
_init_flags, \
|
||||
_initialized, \
|
||||
_update_flags
|
||||
_update_flags, \
|
||||
_async_locks
|
||||
|
||||
# Check if already initialized
|
||||
if not _initialized:
|
||||
@@ -523,5 +613,6 @@ def finalize_share_data():
|
||||
_graph_db_lock = None
|
||||
_data_init_lock = None
|
||||
_update_flags = None
|
||||
_async_locks = None
|
||||
|
||||
direct_log(f"Process {os.getpid()} storage data finalization complete")
|
||||
|
@@ -183,10 +183,10 @@ class LightRAG:
|
||||
embedding_func: EmbeddingFunc | None = field(default=None)
|
||||
"""Function for computing text embeddings. Must be set before use."""
|
||||
|
||||
embedding_batch_num: int = field(default=32)
|
||||
embedding_batch_num: int = field(default=int(os.getenv("EMBEDDING_BATCH_NUM", 32)))
|
||||
"""Batch size for embedding computations."""
|
||||
|
||||
embedding_func_max_async: int = field(default=16)
|
||||
embedding_func_max_async: int = field(default=int(os.getenv("EMBEDDING_FUNC_MAX_ASYNC", 16)))
|
||||
"""Maximum number of concurrent embedding function calls."""
|
||||
|
||||
embedding_cache_config: dict[str, Any] = field(
|
||||
@@ -389,20 +389,21 @@ class LightRAG:
|
||||
self.namespace_prefix, NameSpace.VECTOR_STORE_ENTITIES
|
||||
),
|
||||
embedding_func=self.embedding_func,
|
||||
meta_fields={"entity_name", "source_id", "content"},
|
||||
meta_fields={"entity_name", "source_id", "content", "file_path"},
|
||||
)
|
||||
self.relationships_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
||||
namespace=make_namespace(
|
||||
self.namespace_prefix, NameSpace.VECTOR_STORE_RELATIONSHIPS
|
||||
),
|
||||
embedding_func=self.embedding_func,
|
||||
meta_fields={"src_id", "tgt_id", "source_id", "content"},
|
||||
meta_fields={"src_id", "tgt_id", "source_id", "content", "file_path"},
|
||||
)
|
||||
self.chunks_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
||||
namespace=make_namespace(
|
||||
self.namespace_prefix, NameSpace.VECTOR_STORE_CHUNKS
|
||||
),
|
||||
embedding_func=self.embedding_func,
|
||||
meta_fields={"full_doc_id", "content", "file_path"},
|
||||
)
|
||||
|
||||
# Initialize document status storage
|
||||
@@ -547,6 +548,7 @@ class LightRAG:
|
||||
split_by_character: str | None = None,
|
||||
split_by_character_only: bool = False,
|
||||
ids: str | list[str] | None = None,
|
||||
file_paths: str | list[str] | None = None,
|
||||
) -> None:
|
||||
"""Sync Insert documents with checkpoint support
|
||||
|
||||
@@ -557,10 +559,13 @@ class LightRAG:
|
||||
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
||||
split_by_character is None, this parameter is ignored.
|
||||
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
||||
file_paths: single string of the file path or list of file paths, used for citation
|
||||
"""
|
||||
loop = always_get_an_event_loop()
|
||||
loop.run_until_complete(
|
||||
self.ainsert(input, split_by_character, split_by_character_only, ids)
|
||||
self.ainsert(
|
||||
input, split_by_character, split_by_character_only, ids, file_paths
|
||||
)
|
||||
)
|
||||
|
||||
async def ainsert(
|
||||
@@ -569,6 +574,7 @@ class LightRAG:
|
||||
split_by_character: str | None = None,
|
||||
split_by_character_only: bool = False,
|
||||
ids: str | list[str] | None = None,
|
||||
file_paths: str | list[str] | None = None,
|
||||
) -> None:
|
||||
"""Async Insert documents with checkpoint support
|
||||
|
||||
@@ -579,8 +585,9 @@ class LightRAG:
|
||||
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
||||
split_by_character is None, this parameter is ignored.
|
||||
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
||||
file_paths: list of file paths corresponding to each document, used for citation
|
||||
"""
|
||||
await self.apipeline_enqueue_documents(input, ids)
|
||||
await self.apipeline_enqueue_documents(input, ids, file_paths)
|
||||
await self.apipeline_process_enqueue_documents(
|
||||
split_by_character, split_by_character_only
|
||||
)
|
||||
@@ -654,7 +661,10 @@ class LightRAG:
|
||||
await self._insert_done()
|
||||
|
||||
async def apipeline_enqueue_documents(
|
||||
self, input: str | list[str], ids: list[str] | None = None
|
||||
self,
|
||||
input: str | list[str],
|
||||
ids: list[str] | None = None,
|
||||
file_paths: str | list[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Pipeline for Processing Documents
|
||||
@@ -664,11 +674,30 @@ class LightRAG:
|
||||
3. Generate document initial status
|
||||
4. Filter out already processed documents
|
||||
5. Enqueue document in status
|
||||
|
||||
Args:
|
||||
input: Single document string or list of document strings
|
||||
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
||||
file_paths: list of file paths corresponding to each document, used for citation
|
||||
"""
|
||||
if isinstance(input, str):
|
||||
input = [input]
|
||||
if isinstance(ids, str):
|
||||
ids = [ids]
|
||||
if isinstance(file_paths, str):
|
||||
file_paths = [file_paths]
|
||||
|
||||
# If file_paths is provided, ensure it matches the number of documents
|
||||
if file_paths is not None:
|
||||
if isinstance(file_paths, str):
|
||||
file_paths = [file_paths]
|
||||
if len(file_paths) != len(input):
|
||||
raise ValueError(
|
||||
"Number of file paths must match the number of documents"
|
||||
)
|
||||
else:
|
||||
# If no file paths provided, use placeholder
|
||||
file_paths = ["unknown_source"] * len(input)
|
||||
|
||||
# 1. Validate ids if provided or generate MD5 hash IDs
|
||||
if ids is not None:
|
||||
@@ -681,32 +710,59 @@ class LightRAG:
|
||||
raise ValueError("IDs must be unique")
|
||||
|
||||
# Generate contents dict of IDs provided by user and documents
|
||||
contents = {id_: doc for id_, doc in zip(ids, input)}
|
||||
contents = {
|
||||
id_: {"content": doc, "file_path": path}
|
||||
for id_, doc, path in zip(ids, input, file_paths)
|
||||
}
|
||||
else:
|
||||
# Clean input text and remove duplicates
|
||||
input = list(set(clean_text(doc) for doc in input))
|
||||
# Generate contents dict of MD5 hash IDs and documents
|
||||
contents = {compute_mdhash_id(doc, prefix="doc-"): doc for doc in input}
|
||||
cleaned_input = [
|
||||
(clean_text(doc), path) for doc, path in zip(input, file_paths)
|
||||
]
|
||||
unique_content_with_paths = {}
|
||||
|
||||
# Keep track of unique content and their paths
|
||||
for content, path in cleaned_input:
|
||||
if content not in unique_content_with_paths:
|
||||
unique_content_with_paths[content] = path
|
||||
|
||||
# Generate contents dict of MD5 hash IDs and documents with paths
|
||||
contents = {
|
||||
compute_mdhash_id(content, prefix="doc-"): {
|
||||
"content": content,
|
||||
"file_path": path,
|
||||
}
|
||||
for content, path in unique_content_with_paths.items()
|
||||
}
|
||||
|
||||
# 2. Remove duplicate contents
|
||||
unique_contents = {
|
||||
id_: content
|
||||
for content, id_ in {
|
||||
content: id_ for id_, content in contents.items()
|
||||
}.items()
|
||||
unique_contents = {}
|
||||
for id_, content_data in contents.items():
|
||||
content = content_data["content"]
|
||||
file_path = content_data["file_path"]
|
||||
if content not in unique_contents:
|
||||
unique_contents[content] = (id_, file_path)
|
||||
|
||||
# Reconstruct contents with unique content
|
||||
contents = {
|
||||
id_: {"content": content, "file_path": file_path}
|
||||
for content, (id_, file_path) in unique_contents.items()
|
||||
}
|
||||
|
||||
# 3. Generate document initial status
|
||||
new_docs: dict[str, Any] = {
|
||||
id_: {
|
||||
"content": content,
|
||||
"content_summary": get_content_summary(content),
|
||||
"content_length": len(content),
|
||||
"status": DocStatus.PENDING,
|
||||
"content": content_data["content"],
|
||||
"content_summary": get_content_summary(content_data["content"]),
|
||||
"content_length": len(content_data["content"]),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"file_path": content_data[
|
||||
"file_path"
|
||||
], # Store file path in document status
|
||||
}
|
||||
for id_, content in unique_contents.items()
|
||||
for id_, content_data in contents.items()
|
||||
}
|
||||
|
||||
# 4. Filter out already processed documents
|
||||
@@ -841,11 +897,15 @@ class LightRAG:
|
||||
) -> None:
|
||||
"""Process single document"""
|
||||
try:
|
||||
# Get file path from status document
|
||||
file_path = getattr(status_doc, "file_path", "unknown_source")
|
||||
|
||||
# Generate chunks from document
|
||||
chunks: dict[str, Any] = {
|
||||
compute_mdhash_id(dp["content"], prefix="chunk-"): {
|
||||
**dp,
|
||||
"full_doc_id": doc_id,
|
||||
"file_path": file_path, # Add file path to each chunk
|
||||
}
|
||||
for dp in self.chunking_func(
|
||||
status_doc.content,
|
||||
@@ -856,6 +916,7 @@ class LightRAG:
|
||||
self.tiktoken_model_name,
|
||||
)
|
||||
}
|
||||
|
||||
# Process document (text chunks and full docs) in parallel
|
||||
# Create tasks with references for potential cancellation
|
||||
doc_status_task = asyncio.create_task(
|
||||
@@ -863,11 +924,13 @@ class LightRAG:
|
||||
{
|
||||
doc_id: {
|
||||
"status": DocStatus.PROCESSING,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"chunks_count": len(chunks),
|
||||
"content": status_doc.content,
|
||||
"content_summary": status_doc.content_summary,
|
||||
"content_length": status_doc.content_length,
|
||||
"created_at": status_doc.created_at,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"file_path": file_path,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -906,6 +969,7 @@ class LightRAG:
|
||||
"content_length": status_doc.content_length,
|
||||
"created_at": status_doc.created_at,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"file_path": file_path,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -937,6 +1001,7 @@ class LightRAG:
|
||||
"content_length": status_doc.content_length,
|
||||
"created_at": status_doc.created_at,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"file_path": file_path,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1063,7 +1128,10 @@ class LightRAG:
|
||||
loop.run_until_complete(self.ainsert_custom_kg(custom_kg, full_doc_id))
|
||||
|
||||
async def ainsert_custom_kg(
|
||||
self, custom_kg: dict[str, Any], full_doc_id: str = None
|
||||
self,
|
||||
custom_kg: dict[str, Any],
|
||||
full_doc_id: str = None,
|
||||
file_path: str = "custom_kg",
|
||||
) -> None:
|
||||
update_storage = False
|
||||
try:
|
||||
@@ -1093,6 +1161,7 @@ class LightRAG:
|
||||
"full_doc_id": full_doc_id
|
||||
if full_doc_id is not None
|
||||
else source_id,
|
||||
"file_path": file_path, # Add file path
|
||||
"status": DocStatus.PROCESSED,
|
||||
}
|
||||
all_chunks_data[chunk_id] = chunk_entry
|
||||
@@ -1197,6 +1266,7 @@ class LightRAG:
|
||||
"source_id": dp["source_id"],
|
||||
"description": dp["description"],
|
||||
"entity_type": dp["entity_type"],
|
||||
"file_path": file_path, # Add file path
|
||||
}
|
||||
for dp in all_entities_data
|
||||
}
|
||||
@@ -1212,6 +1282,7 @@ class LightRAG:
|
||||
"keywords": dp["keywords"],
|
||||
"description": dp["description"],
|
||||
"weight": dp["weight"],
|
||||
"file_path": file_path, # Add file path
|
||||
}
|
||||
for dp in all_relationships_data
|
||||
}
|
||||
@@ -1473,8 +1544,7 @@ class LightRAG:
|
||||
"""
|
||||
try:
|
||||
# 1. Get the document status and related data
|
||||
doc_status = await self.doc_status.get_by_id(doc_id)
|
||||
if not doc_status:
|
||||
if not await self.doc_status.get_by_id(doc_id):
|
||||
logger.warning(f"Document {doc_id} not found")
|
||||
return
|
||||
|
||||
@@ -1877,6 +1947,8 @@ class LightRAG:
|
||||
|
||||
# 2. Update entity information in the graph
|
||||
new_node_data = {**node_data, **updated_data}
|
||||
new_node_data["entity_id"] = new_entity_name
|
||||
|
||||
if "entity_name" in new_node_data:
|
||||
del new_node_data[
|
||||
"entity_name"
|
||||
@@ -1893,7 +1965,7 @@ class LightRAG:
|
||||
|
||||
# Store relationships that need to be updated
|
||||
relations_to_update = []
|
||||
|
||||
relations_to_delete = []
|
||||
# Get all edges related to the original entity
|
||||
edges = await self.chunk_entity_relation_graph.get_node_edges(
|
||||
entity_name
|
||||
@@ -1905,6 +1977,12 @@ class LightRAG:
|
||||
source, target
|
||||
)
|
||||
if edge_data:
|
||||
relations_to_delete.append(
|
||||
compute_mdhash_id(source + target, prefix="rel-")
|
||||
)
|
||||
relations_to_delete.append(
|
||||
compute_mdhash_id(target + source, prefix="rel-")
|
||||
)
|
||||
if source == entity_name:
|
||||
await self.chunk_entity_relation_graph.upsert_edge(
|
||||
new_entity_name, target, edge_data
|
||||
@@ -1930,6 +2008,12 @@ class LightRAG:
|
||||
f"Deleted old entity '{entity_name}' and its vector embedding from database"
|
||||
)
|
||||
|
||||
# Delete old relation records from vector database
|
||||
await self.relationships_vdb.delete(relations_to_delete)
|
||||
logger.info(
|
||||
f"Deleted {len(relations_to_delete)} relation records for entity '{entity_name}' from vector database"
|
||||
)
|
||||
|
||||
# Update relationship vector representations
|
||||
for src, tgt, edge_data in relations_to_update:
|
||||
description = edge_data.get("description", "")
|
||||
@@ -2220,7 +2304,6 @@ class LightRAG:
|
||||
"""Synchronously create a new entity.
|
||||
|
||||
Creates a new entity in the knowledge graph and adds it to the vector database.
|
||||
|
||||
Args:
|
||||
entity_name: Name of the new entity
|
||||
entity_data: Dictionary containing entity attributes, e.g. {"description": "description", "entity_type": "type"}
|
||||
@@ -2429,39 +2512,21 @@ class LightRAG:
|
||||
# 4. Get all relationships of the source entities
|
||||
all_relations = []
|
||||
for entity_name in source_entities:
|
||||
# Get all relationships where this entity is the source
|
||||
outgoing_edges = await self.chunk_entity_relation_graph.get_node_edges(
|
||||
# Get all relationships of the source entities
|
||||
edges = await self.chunk_entity_relation_graph.get_node_edges(
|
||||
entity_name
|
||||
)
|
||||
if outgoing_edges:
|
||||
for src, tgt in outgoing_edges:
|
||||
if edges:
|
||||
for src, tgt in edges:
|
||||
# Ensure src is the current entity
|
||||
if src == entity_name:
|
||||
edge_data = await self.chunk_entity_relation_graph.get_edge(
|
||||
src, tgt
|
||||
)
|
||||
all_relations.append(("outgoing", src, tgt, edge_data))
|
||||
|
||||
# Get all relationships where this entity is the target
|
||||
incoming_edges = []
|
||||
all_labels = await self.chunk_entity_relation_graph.get_all_labels()
|
||||
for label in all_labels:
|
||||
if label == entity_name:
|
||||
continue
|
||||
node_edges = await self.chunk_entity_relation_graph.get_node_edges(
|
||||
label
|
||||
)
|
||||
for src, tgt in node_edges or []:
|
||||
if tgt == entity_name:
|
||||
incoming_edges.append((src, tgt))
|
||||
|
||||
for src, tgt in incoming_edges:
|
||||
edge_data = await self.chunk_entity_relation_graph.get_edge(
|
||||
src, tgt
|
||||
)
|
||||
all_relations.append(("incoming", src, tgt, edge_data))
|
||||
all_relations.append((src, tgt, edge_data))
|
||||
|
||||
# 5. Create or update the target entity
|
||||
merged_entity_data["entity_id"] = target_entity
|
||||
if not target_exists:
|
||||
await self.chunk_entity_relation_graph.upsert_node(
|
||||
target_entity, merged_entity_data
|
||||
@@ -2475,8 +2540,11 @@ class LightRAG:
|
||||
|
||||
# 6. Recreate all relationships, pointing to the target entity
|
||||
relation_updates = {} # Track relationships that need to be merged
|
||||
relations_to_delete = []
|
||||
|
||||
for rel_type, src, tgt, edge_data in all_relations:
|
||||
for src, tgt, edge_data in all_relations:
|
||||
relations_to_delete.append(compute_mdhash_id(src + tgt, prefix="rel-"))
|
||||
relations_to_delete.append(compute_mdhash_id(tgt + src, prefix="rel-"))
|
||||
new_src = target_entity if src in source_entities else src
|
||||
new_tgt = target_entity if tgt in source_entities else tgt
|
||||
|
||||
@@ -2521,6 +2589,12 @@ class LightRAG:
|
||||
f"Created or updated relationship: {rel_data['src']} -> {rel_data['tgt']}"
|
||||
)
|
||||
|
||||
# Delete relationships records from vector database
|
||||
await self.relationships_vdb.delete(relations_to_delete)
|
||||
logger.info(
|
||||
f"Deleted {len(relations_to_delete)} relation records for entity '{entity_name}' from vector database"
|
||||
)
|
||||
|
||||
# 7. Update entity vector representation
|
||||
description = merged_entity_data.get("description", "")
|
||||
source_id = merged_entity_data.get("source_id", "")
|
||||
@@ -2583,19 +2657,6 @@ class LightRAG:
|
||||
entity_id = compute_mdhash_id(entity_name, prefix="ent-")
|
||||
await self.entities_vdb.delete([entity_id])
|
||||
|
||||
# Also ensure any relationships specific to this entity are deleted from vector DB
|
||||
# This is a safety check, as these should have been transformed to the target entity already
|
||||
entity_relation_prefix = compute_mdhash_id(entity_name, prefix="rel-")
|
||||
relations_with_entity = await self.relationships_vdb.search_by_prefix(
|
||||
entity_relation_prefix
|
||||
)
|
||||
if relations_with_entity:
|
||||
relation_ids = [r["id"] for r in relations_with_entity]
|
||||
await self.relationships_vdb.delete(relation_ids)
|
||||
logger.info(
|
||||
f"Deleted {len(relation_ids)} relation records for entity '{entity_name}' from vector database"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Deleted source entity '{entity_name}' and its vector embedding from database"
|
||||
)
|
||||
|
@@ -138,16 +138,31 @@ async def hf_model_complete(
|
||||
|
||||
|
||||
async def hf_embed(texts: list[str], tokenizer, embed_model) -> np.ndarray:
|
||||
device = next(embed_model.parameters()).device
|
||||
# Detect the appropriate device
|
||||
if torch.cuda.is_available():
|
||||
device = next(embed_model.parameters()).device # Use CUDA if available
|
||||
elif torch.backends.mps.is_available():
|
||||
device = torch.device("mps") # Use MPS for Apple Silicon
|
||||
else:
|
||||
device = torch.device("cpu") # Fallback to CPU
|
||||
|
||||
# Move the model to the detected device
|
||||
embed_model = embed_model.to(device)
|
||||
|
||||
# Tokenize the input texts and move them to the same device
|
||||
encoded_texts = tokenizer(
|
||||
texts, return_tensors="pt", padding=True, truncation=True
|
||||
).to(device)
|
||||
|
||||
# Perform inference
|
||||
with torch.no_grad():
|
||||
outputs = embed_model(
|
||||
input_ids=encoded_texts["input_ids"],
|
||||
attention_mask=encoded_texts["attention_mask"],
|
||||
)
|
||||
embeddings = outputs.last_hidden_state.mean(dim=1)
|
||||
|
||||
# Convert embeddings to NumPy
|
||||
if embeddings.dtype == torch.bfloat16:
|
||||
return embeddings.detach().to(torch.float32).cpu().numpy()
|
||||
else:
|
||||
|
@@ -138,6 +138,7 @@ async def _handle_entity_relation_summary(
|
||||
async def _handle_single_entity_extraction(
|
||||
record_attributes: list[str],
|
||||
chunk_key: str,
|
||||
file_path: str = "unknown_source",
|
||||
):
|
||||
if len(record_attributes) < 4 or record_attributes[0] != '"entity"':
|
||||
return None
|
||||
@@ -171,13 +172,14 @@ async def _handle_single_entity_extraction(
|
||||
entity_type=entity_type,
|
||||
description=entity_description,
|
||||
source_id=chunk_key,
|
||||
metadata={"created_at": time.time()},
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_single_relationship_extraction(
|
||||
record_attributes: list[str],
|
||||
chunk_key: str,
|
||||
file_path: str = "unknown_source",
|
||||
):
|
||||
if len(record_attributes) < 5 or record_attributes[0] != '"relationship"':
|
||||
return None
|
||||
@@ -199,7 +201,7 @@ async def _handle_single_relationship_extraction(
|
||||
description=edge_description,
|
||||
keywords=edge_keywords,
|
||||
source_id=edge_source_id,
|
||||
metadata={"created_at": time.time()},
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,6 +215,7 @@ async def _merge_nodes_then_upsert(
|
||||
already_entity_types = []
|
||||
already_source_ids = []
|
||||
already_description = []
|
||||
already_file_paths = []
|
||||
|
||||
already_node = await knowledge_graph_inst.get_node(entity_name)
|
||||
if already_node is not None:
|
||||
@@ -220,6 +223,9 @@ async def _merge_nodes_then_upsert(
|
||||
already_source_ids.extend(
|
||||
split_string_by_multi_markers(already_node["source_id"], [GRAPH_FIELD_SEP])
|
||||
)
|
||||
already_file_paths.extend(
|
||||
split_string_by_multi_markers(already_node["file_path"], [GRAPH_FIELD_SEP])
|
||||
)
|
||||
already_description.append(already_node["description"])
|
||||
|
||||
entity_type = sorted(
|
||||
@@ -235,6 +241,11 @@ async def _merge_nodes_then_upsert(
|
||||
source_id = GRAPH_FIELD_SEP.join(
|
||||
set([dp["source_id"] for dp in nodes_data] + already_source_ids)
|
||||
)
|
||||
file_path = GRAPH_FIELD_SEP.join(
|
||||
set([dp["file_path"] for dp in nodes_data] + already_file_paths)
|
||||
)
|
||||
|
||||
logger.debug(f"file_path: {file_path}")
|
||||
description = await _handle_entity_relation_summary(
|
||||
entity_name, description, global_config
|
||||
)
|
||||
@@ -243,6 +254,7 @@ async def _merge_nodes_then_upsert(
|
||||
entity_type=entity_type,
|
||||
description=description,
|
||||
source_id=source_id,
|
||||
file_path=file_path,
|
||||
)
|
||||
await knowledge_graph_inst.upsert_node(
|
||||
entity_name,
|
||||
@@ -263,6 +275,7 @@ async def _merge_edges_then_upsert(
|
||||
already_source_ids = []
|
||||
already_description = []
|
||||
already_keywords = []
|
||||
already_file_paths = []
|
||||
|
||||
if await knowledge_graph_inst.has_edge(src_id, tgt_id):
|
||||
already_edge = await knowledge_graph_inst.get_edge(src_id, tgt_id)
|
||||
@@ -279,6 +292,14 @@ async def _merge_edges_then_upsert(
|
||||
)
|
||||
)
|
||||
|
||||
# Get file_path with empty string default if missing or None
|
||||
if already_edge.get("file_path") is not None:
|
||||
already_file_paths.extend(
|
||||
split_string_by_multi_markers(
|
||||
already_edge["file_path"], [GRAPH_FIELD_SEP]
|
||||
)
|
||||
)
|
||||
|
||||
# Get description with empty string default if missing or None
|
||||
if already_edge.get("description") is not None:
|
||||
already_description.append(already_edge["description"])
|
||||
@@ -315,6 +336,12 @@ async def _merge_edges_then_upsert(
|
||||
+ already_source_ids
|
||||
)
|
||||
)
|
||||
file_path = GRAPH_FIELD_SEP.join(
|
||||
set(
|
||||
[dp["file_path"] for dp in edges_data if dp.get("file_path")]
|
||||
+ already_file_paths
|
||||
)
|
||||
)
|
||||
|
||||
for need_insert_id in [src_id, tgt_id]:
|
||||
if not (await knowledge_graph_inst.has_node(need_insert_id)):
|
||||
@@ -325,6 +352,7 @@ async def _merge_edges_then_upsert(
|
||||
"source_id": source_id,
|
||||
"description": description,
|
||||
"entity_type": "UNKNOWN",
|
||||
"file_path": file_path,
|
||||
},
|
||||
)
|
||||
description = await _handle_entity_relation_summary(
|
||||
@@ -338,6 +366,7 @@ async def _merge_edges_then_upsert(
|
||||
description=description,
|
||||
keywords=keywords,
|
||||
source_id=source_id,
|
||||
file_path=file_path,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -347,6 +376,7 @@ async def _merge_edges_then_upsert(
|
||||
description=description,
|
||||
keywords=keywords,
|
||||
source_id=source_id,
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
return edge_data
|
||||
@@ -456,11 +486,14 @@ async def extract_entities(
|
||||
else:
|
||||
return await use_llm_func(input_text)
|
||||
|
||||
async def _process_extraction_result(result: str, chunk_key: str):
|
||||
async def _process_extraction_result(
|
||||
result: str, chunk_key: str, file_path: str = "unknown_source"
|
||||
):
|
||||
"""Process a single extraction result (either initial or gleaning)
|
||||
Args:
|
||||
result (str): The extraction result to process
|
||||
chunk_key (str): The chunk key for source tracking
|
||||
file_path (str): The file path for citation
|
||||
Returns:
|
||||
tuple: (nodes_dict, edges_dict) containing the extracted entities and relationships
|
||||
"""
|
||||
@@ -482,14 +515,14 @@ async def extract_entities(
|
||||
)
|
||||
|
||||
if_entities = await _handle_single_entity_extraction(
|
||||
record_attributes, chunk_key
|
||||
record_attributes, chunk_key, file_path
|
||||
)
|
||||
if if_entities is not None:
|
||||
maybe_nodes[if_entities["entity_name"]].append(if_entities)
|
||||
continue
|
||||
|
||||
if_relation = await _handle_single_relationship_extraction(
|
||||
record_attributes, chunk_key
|
||||
record_attributes, chunk_key, file_path
|
||||
)
|
||||
if if_relation is not None:
|
||||
maybe_edges[(if_relation["src_id"], if_relation["tgt_id"])].append(
|
||||
@@ -508,6 +541,8 @@ async def extract_entities(
|
||||
chunk_key = chunk_key_dp[0]
|
||||
chunk_dp = chunk_key_dp[1]
|
||||
content = chunk_dp["content"]
|
||||
# Get file path from chunk data or use default
|
||||
file_path = chunk_dp.get("file_path", "unknown_source")
|
||||
|
||||
# Get initial extraction
|
||||
hint_prompt = entity_extract_prompt.format(
|
||||
@@ -517,9 +552,9 @@ async def extract_entities(
|
||||
final_result = await _user_llm_func_with_cache(hint_prompt)
|
||||
history = pack_user_ass_to_openai_messages(hint_prompt, final_result)
|
||||
|
||||
# Process initial extraction
|
||||
# Process initial extraction with file path
|
||||
maybe_nodes, maybe_edges = await _process_extraction_result(
|
||||
final_result, chunk_key
|
||||
final_result, chunk_key, file_path
|
||||
)
|
||||
|
||||
# Process additional gleaning results
|
||||
@@ -530,9 +565,9 @@ async def extract_entities(
|
||||
|
||||
history += pack_user_ass_to_openai_messages(continue_prompt, glean_result)
|
||||
|
||||
# Process gleaning result separately
|
||||
# Process gleaning result separately with file path
|
||||
glean_nodes, glean_edges = await _process_extraction_result(
|
||||
glean_result, chunk_key
|
||||
glean_result, chunk_key, file_path
|
||||
)
|
||||
|
||||
# Merge results
|
||||
@@ -637,9 +672,7 @@ async def extract_entities(
|
||||
"entity_type": dp["entity_type"],
|
||||
"content": f"{dp['entity_name']}\n{dp['description']}",
|
||||
"source_id": dp["source_id"],
|
||||
"metadata": {
|
||||
"created_at": dp.get("metadata", {}).get("created_at", time.time())
|
||||
},
|
||||
"file_path": dp.get("file_path", "unknown_source"),
|
||||
}
|
||||
for dp in all_entities_data
|
||||
}
|
||||
@@ -653,9 +686,7 @@ async def extract_entities(
|
||||
"keywords": dp["keywords"],
|
||||
"content": f"{dp['src_id']}\t{dp['tgt_id']}\n{dp['keywords']}\n{dp['description']}",
|
||||
"source_id": dp["source_id"],
|
||||
"metadata": {
|
||||
"created_at": dp.get("metadata", {}).get("created_at", time.time())
|
||||
},
|
||||
"file_path": dp.get("file_path", "unknown_source"),
|
||||
}
|
||||
for dp in all_relationships_data
|
||||
}
|
||||
@@ -1232,12 +1263,17 @@ async def _get_node_data(
|
||||
"description",
|
||||
"rank",
|
||||
"created_at",
|
||||
"file_path",
|
||||
]
|
||||
]
|
||||
for i, n in enumerate(node_datas):
|
||||
created_at = n.get("created_at", "UNKNOWN")
|
||||
if isinstance(created_at, (int, float)):
|
||||
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
||||
|
||||
# Get file path from node data
|
||||
file_path = n.get("file_path", "unknown_source")
|
||||
|
||||
entites_section_list.append(
|
||||
[
|
||||
i,
|
||||
@@ -1246,6 +1282,7 @@ async def _get_node_data(
|
||||
n.get("description", "UNKNOWN"),
|
||||
n["rank"],
|
||||
created_at,
|
||||
file_path,
|
||||
]
|
||||
)
|
||||
entities_context = list_of_list_to_csv(entites_section_list)
|
||||
@@ -1260,6 +1297,7 @@ async def _get_node_data(
|
||||
"weight",
|
||||
"rank",
|
||||
"created_at",
|
||||
"file_path",
|
||||
]
|
||||
]
|
||||
for i, e in enumerate(use_relations):
|
||||
@@ -1267,6 +1305,10 @@ async def _get_node_data(
|
||||
# Convert timestamp to readable format
|
||||
if isinstance(created_at, (int, float)):
|
||||
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
||||
|
||||
# Get file path from edge data
|
||||
file_path = e.get("file_path", "unknown_source")
|
||||
|
||||
relations_section_list.append(
|
||||
[
|
||||
i,
|
||||
@@ -1277,6 +1319,7 @@ async def _get_node_data(
|
||||
e["weight"],
|
||||
e["rank"],
|
||||
created_at,
|
||||
file_path,
|
||||
]
|
||||
)
|
||||
relations_context = list_of_list_to_csv(relations_section_list)
|
||||
@@ -1492,6 +1535,7 @@ async def _get_edge_data(
|
||||
"weight",
|
||||
"rank",
|
||||
"created_at",
|
||||
"file_path",
|
||||
]
|
||||
]
|
||||
for i, e in enumerate(edge_datas):
|
||||
@@ -1499,6 +1543,10 @@ async def _get_edge_data(
|
||||
# Convert timestamp to readable format
|
||||
if isinstance(created_at, (int, float)):
|
||||
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
||||
|
||||
# Get file path from edge data
|
||||
file_path = e.get("file_path", "unknown_source")
|
||||
|
||||
relations_section_list.append(
|
||||
[
|
||||
i,
|
||||
@@ -1509,16 +1557,23 @@ async def _get_edge_data(
|
||||
e["weight"],
|
||||
e["rank"],
|
||||
created_at,
|
||||
file_path,
|
||||
]
|
||||
)
|
||||
relations_context = list_of_list_to_csv(relations_section_list)
|
||||
|
||||
entites_section_list = [["id", "entity", "type", "description", "rank"]]
|
||||
entites_section_list = [
|
||||
["id", "entity", "type", "description", "rank", "created_at", "file_path"]
|
||||
]
|
||||
for i, n in enumerate(use_entities):
|
||||
created_at = e.get("created_at", "Unknown")
|
||||
created_at = n.get("created_at", "Unknown")
|
||||
# Convert timestamp to readable format
|
||||
if isinstance(created_at, (int, float)):
|
||||
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
||||
|
||||
# Get file path from node data
|
||||
file_path = n.get("file_path", "unknown_source")
|
||||
|
||||
entites_section_list.append(
|
||||
[
|
||||
i,
|
||||
@@ -1527,6 +1582,7 @@ async def _get_edge_data(
|
||||
n.get("description", "UNKNOWN"),
|
||||
n["rank"],
|
||||
created_at,
|
||||
file_path,
|
||||
]
|
||||
)
|
||||
entities_context = list_of_list_to_csv(entites_section_list)
|
||||
@@ -1882,13 +1938,14 @@ async def kg_query_with_keywords(
|
||||
len_of_prompts = len(encode_string_by_tiktoken(query + sys_prompt))
|
||||
logger.debug(f"[kg_query_with_keywords]Prompt Tokens: {len_of_prompts}")
|
||||
|
||||
# 6. Generate response
|
||||
response = await use_model_func(
|
||||
query,
|
||||
system_prompt=sys_prompt,
|
||||
stream=query_param.stream,
|
||||
)
|
||||
|
||||
# 清理响应内容
|
||||
# Clean up response content
|
||||
if isinstance(response, str) and len(response) > len(sys_prompt):
|
||||
response = (
|
||||
response.replace(sys_prompt, "")
|
||||
|
@@ -61,7 +61,7 @@ Text:
|
||||
```
|
||||
while Alex clenched his jaw, the buzz of frustration dull against the backdrop of Taylor's authoritarian certainty. It was this competitive undercurrent that kept him alert, the sense that his and Jordan's shared commitment to discovery was an unspoken rebellion against Cruz's narrowing vision of control and order.
|
||||
|
||||
Then Taylor did something unexpected. They paused beside Jordan and, for a moment, observed the device with something akin to reverence. “If this tech can be understood..." Taylor said, their voice quieter, "It could change the game for us. For all of us.”
|
||||
Then Taylor did something unexpected. They paused beside Jordan and, for a moment, observed the device with something akin to reverence. "If this tech can be understood..." Taylor said, their voice quieter, "It could change the game for us. For all of us."
|
||||
|
||||
The underlying dismissal earlier seemed to falter, replaced by a glimpse of reluctant respect for the gravity of what lay in their hands. Jordan looked up, and for a fleeting heartbeat, their eyes locked with Taylor's, a wordless clash of wills softening into an uneasy truce.
|
||||
|
||||
@@ -92,7 +92,7 @@ Among the hardest hit, Nexon Technologies saw its stock plummet by 7.8% after re
|
||||
|
||||
Meanwhile, commodity markets reflected a mixed sentiment. Gold futures rose by 1.5%, reaching $2,080 per ounce, as investors sought safe-haven assets. Crude oil prices continued their rally, climbing to $87.60 per barrel, supported by supply constraints and strong demand.
|
||||
|
||||
Financial experts are closely watching the Federal Reserve’s next move, as speculation grows over potential rate hikes. The upcoming policy announcement is expected to influence investor confidence and overall market stability.
|
||||
Financial experts are closely watching the Federal Reserve's next move, as speculation grows over potential rate hikes. The upcoming policy announcement is expected to influence investor confidence and overall market stability.
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -222,6 +222,7 @@ When handling relationships with timestamps:
|
||||
- Use markdown formatting with appropriate section headings
|
||||
- Please respond in the same language as the user's question.
|
||||
- Ensure the response maintains continuity with the conversation history.
|
||||
- List up to 5 most important reference sources at the end under "References" section. Clearly indicating whether each source is from Knowledge Graph (KG) or Vector Data (DC), and include the file path if available, in the following format: [KG/DC] Source content (File: file_path)
|
||||
- If you don't know the answer, just say so.
|
||||
- Do not make anything up. Do not include information not provided by the Knowledge Base."""
|
||||
|
||||
@@ -319,6 +320,7 @@ When handling content with timestamps:
|
||||
- Use markdown formatting with appropriate section headings
|
||||
- Please respond in the same language as the user's question.
|
||||
- Ensure the response maintains continuity with the conversation history.
|
||||
- List up to 5 most important reference sources at the end under "References" section. Clearly indicating whether each source is from Knowledge Graph (KG) or Vector Data (DC), and include the file path if available, in the following format: [KG/DC] Source content (File: file_path)
|
||||
- If you don't know the answer, just say so.
|
||||
- Do not include information not provided by the Document Chunks."""
|
||||
|
||||
@@ -378,8 +380,8 @@ When handling information with timestamps:
|
||||
- Use markdown formatting with appropriate section headings
|
||||
- Please respond in the same language as the user's question.
|
||||
- Ensure the response maintains continuity with the conversation history.
|
||||
- Organize answer in sesctions focusing on one main point or aspect of the answer
|
||||
- Organize answer in sections focusing on one main point or aspect of the answer
|
||||
- Use clear and descriptive section titles that reflect the content
|
||||
- List up to 5 most important reference sources at the end under "References" sesction. Clearly indicating whether each source is from Knowledge Graph (KG) or Vector Data (DC), in the following format: [KG/DC] Source content
|
||||
- List up to 5 most important reference sources at the end under "References" section. Clearly indicating whether each source is from Knowledge Graph (KG) or Vector Data (DC), and include the file path if available, in the following format: [KG/DC] Source content (File: file_path)
|
||||
- If you don't know the answer, just say so. Do not make anything up.
|
||||
- Do not include information not provided by the Data Sources."""
|
||||
|
@@ -109,15 +109,17 @@ def setup_logger(
|
||||
logger_name: str,
|
||||
level: str = "INFO",
|
||||
add_filter: bool = False,
|
||||
log_file_path: str = None,
|
||||
log_file_path: str | None = None,
|
||||
enable_file_logging: bool = True,
|
||||
):
|
||||
"""Set up a logger with console and file handlers
|
||||
"""Set up a logger with console and optionally file handlers
|
||||
|
||||
Args:
|
||||
logger_name: Name of the logger to set up
|
||||
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
add_filter: Whether to add LightragPathFilter to the logger
|
||||
log_file_path: Path to the log file. If None, will use current directory/lightrag.log
|
||||
log_file_path: Path to the log file. If None and file logging is enabled, defaults to lightrag.log in LOG_DIR or cwd
|
||||
enable_file_logging: Whether to enable logging to a file (defaults to True)
|
||||
"""
|
||||
# Configure formatters
|
||||
detailed_formatter = logging.Formatter(
|
||||
@@ -125,18 +127,6 @@ def setup_logger(
|
||||
)
|
||||
simple_formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||
|
||||
# Get log file path
|
||||
if log_file_path is None:
|
||||
log_dir = os.getenv("LOG_DIR", os.getcwd())
|
||||
log_file_path = os.path.abspath(os.path.join(log_dir, "lightrag.log"))
|
||||
|
||||
# Ensure log directory exists
|
||||
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
|
||||
|
||||
# Get log file max size and backup count from environment variables
|
||||
log_max_bytes = int(os.getenv("LOG_MAX_BYTES", 10485760)) # Default 10MB
|
||||
log_backup_count = int(os.getenv("LOG_BACKUP_COUNT", 5)) # Default 5 backups
|
||||
|
||||
logger_instance = logging.getLogger(logger_name)
|
||||
logger_instance.setLevel(level)
|
||||
logger_instance.handlers = [] # Clear existing handlers
|
||||
@@ -148,16 +138,34 @@ def setup_logger(
|
||||
console_handler.setLevel(level)
|
||||
logger_instance.addHandler(console_handler)
|
||||
|
||||
# Add file handler
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
maxBytes=log_max_bytes,
|
||||
backupCount=log_backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
file_handler.setLevel(level)
|
||||
logger_instance.addHandler(file_handler)
|
||||
# Add file handler by default unless explicitly disabled
|
||||
if enable_file_logging:
|
||||
# Get log file path
|
||||
if log_file_path is None:
|
||||
log_dir = os.getenv("LOG_DIR", os.getcwd())
|
||||
log_file_path = os.path.abspath(os.path.join(log_dir, "lightrag.log"))
|
||||
|
||||
# Ensure log directory exists
|
||||
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
|
||||
|
||||
# Get log file max size and backup count from environment variables
|
||||
log_max_bytes = int(os.getenv("LOG_MAX_BYTES", 10485760)) # Default 10MB
|
||||
log_backup_count = int(os.getenv("LOG_BACKUP_COUNT", 5)) # Default 5 backups
|
||||
|
||||
try:
|
||||
# Add file handler
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
maxBytes=log_max_bytes,
|
||||
backupCount=log_backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
file_handler.setLevel(level)
|
||||
logger_instance.addHandler(file_handler)
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Could not create log file at {log_file_path}: {str(e)}")
|
||||
logger.warning("Continuing with console logging only")
|
||||
|
||||
# Add path filter if requested
|
||||
if add_filter:
|
||||
|
@@ -40,9 +40,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.6",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-react": "^8.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -418,6 +420,8 @@
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.3", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
|
||||
@@ -566,6 +570,8 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "https://registry.npmmirror.com/cookie/-/cookie-1.0.2.tgz", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -1102,6 +1108,8 @@
|
||||
|
||||
"react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="],
|
||||
|
||||
"react-error-boundary": ["react-error-boundary@5.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="],
|
||||
|
||||
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
@@ -1114,6 +1122,10 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.3.0", "https://registry.npmmirror.com/react-router/-/react-router-7.3.0.tgz", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.3.0", "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.3.0.tgz", { "dependencies": { "react-router": "7.3.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ=="],
|
||||
|
||||
"react-select": ["react-select@5.10.0", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-k96gw+i6N3ExgDwPIg0lUPmexl1ygPe6u5BdQFNBhkpbwroIgCNXdubtIzHfThYXYYTubwOBafoMnn7ruEP1xA=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
@@ -1164,6 +1176,8 @@
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
@@ -1234,6 +1248,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo-stream": ["turbo-stream@2.4.0", "https://registry.npmmirror.com/turbo-stream/-/turbo-stream-2.4.0.tgz", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
2
lightrag_webui/env.development.smaple
Normal file
2
lightrag_webui/env.development.smaple
Normal file
@@ -0,0 +1,2 @@
|
||||
# Development environment configuration
|
||||
VITE_BACKEND_URL=/api
|
3
lightrag_webui/env.local.sample
Normal file
3
lightrag_webui/env.local.sample
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_BACKEND_URL=http://localhost:9621
|
||||
VITE_API_PROXY=true
|
||||
VITE_API_ENDPOINTS=/,/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login,/auth-status
|
@@ -5,7 +5,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag</title>
|
||||
</head>
|
||||
|
@@ -49,9 +49,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.6",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-react": "^8.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
@@ -8,7 +8,6 @@ import { healthCheckInterval } from '@/lib/constants'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useEffect } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import SiteHeader from '@/features/SiteHeader'
|
||||
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
|
||||
|
||||
@@ -27,8 +26,6 @@ function App() {
|
||||
|
||||
// Health check
|
||||
useEffect(() => {
|
||||
if (!enableHealthCheck) return
|
||||
|
||||
// Check immediately
|
||||
useBackendState.getState().check()
|
||||
|
||||
@@ -56,24 +53,24 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TabVisibilityProvider>
|
||||
<main className="flex h-screen w-screen overflow-x-hidden">
|
||||
<main className="flex h-screen w-screen overflow-hidden">
|
||||
<Tabs
|
||||
defaultValue={currentTab}
|
||||
className="!m-0 flex grow flex-col !p-0"
|
||||
className="!m-0 flex grow flex-col !p-0 overflow-hidden"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<SiteHeader />
|
||||
<div className="relative grow">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0 overflow-auto">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<RetrievalTesting />
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0">
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<ApiSite />
|
||||
</TabsContent>
|
||||
</div>
|
||||
@@ -81,7 +78,6 @@ function App() {
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
||||
{apiKeyInvalid && <ApiKeyAlert />}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TabVisibilityProvider>
|
||||
</ThemeProvider>
|
||||
|
190
lightrag_webui/src/AppRouter.tsx
Normal file
190
lightrag_webui/src/AppRouter.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { getAuthStatus } from '@/api/lightrag'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import LoginPage from '@/features/LoginPage'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
// This effect will run when the component mounts
|
||||
// and will check if authentication is required
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
// Skip check if already authenticated
|
||||
if (isAuthenticated) {
|
||||
if (isMounted) setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token
|
||||
useAuthStore.getState().login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth status:', error)
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthStatus()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Handle navigation when authentication status changes
|
||||
useEffect(() => {
|
||||
if (!isChecking && !isAuthenticated) {
|
||||
const currentPath = window.location.hash.slice(1); // Remove the '#' from hash
|
||||
const isLoginPage = currentPath === '/login';
|
||||
|
||||
if (!isLoginPage) {
|
||||
// Use navigation service for redirection
|
||||
console.log('Not authenticated, redirecting to login');
|
||||
navigationService.navigateToLogin();
|
||||
}
|
||||
}
|
||||
}, [isChecking, isAuthenticated]);
|
||||
|
||||
// Show nothing while checking auth status or when not authenticated on login page
|
||||
if (isChecking || (!isAuthenticated && window.location.hash.slice(1) === '/login')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show children only when authenticated
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const AppContent = () => {
|
||||
const [initializing, setInitializing] = useState(true)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
// Check token validity and auth configuration on app initialization
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
|
||||
|
||||
// If we have a token, we're already authenticated
|
||||
if (token && isAuthenticated) {
|
||||
if (isMounted) setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no token or not authenticated, check if auth is configured
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token
|
||||
useAuthStore.getState().login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
} else if (!token) {
|
||||
// Only logout if we don't have a token
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error)
|
||||
if (isMounted && !isAuthenticated) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setInitializing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuth()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Show nothing while initializing
|
||||
if (initializing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const AppRouter = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
<Toaster position="bottom-center" />
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRouter
|
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios'
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
|
||||
// Types
|
||||
export type LightragNodeType = {
|
||||
@@ -125,6 +126,21 @@ export type DocsStatusesResponse = {
|
||||
statuses: Record<DocStatus, DocStatusResponse[]>
|
||||
}
|
||||
|
||||
export type AuthStatusResponse = {
|
||||
auth_configured: boolean
|
||||
access_token?: string
|
||||
token_type?: string
|
||||
auth_mode?: 'enabled' | 'disabled'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type LoginResponse = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
|
||||
message?: string // Optional message
|
||||
}
|
||||
|
||||
export const InvalidApiKeyError = 'Invalid API Key'
|
||||
export const RequireApiKeError = 'API Key required'
|
||||
|
||||
@@ -136,9 +152,15 @@ const axiosInstance = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
// Interceptor:add api key
|
||||
// Interceptor: add api key and check authentication
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const apiKey = useSettingsStore.getState().apiKey
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
|
||||
|
||||
// Always include token if it exists, regardless of path
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey
|
||||
}
|
||||
@@ -150,6 +172,16 @@ axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
if (error.response?.status === 401) {
|
||||
// For login API, throw error directly
|
||||
if (error.config?.url?.includes('/login')) {
|
||||
throw error;
|
||||
}
|
||||
// For other APIs, navigate to login page
|
||||
navigationService.navigateToLogin();
|
||||
// Return a never-resolving promise to prevent further execution
|
||||
return new Promise(() => {});
|
||||
}
|
||||
throw new Error(
|
||||
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
|
||||
error.response.data
|
||||
@@ -324,3 +356,74 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
|
||||
const response = await axiosInstance.delete('/documents')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getAuthStatus = async (): Promise<AuthStatusResponse> => {
|
||||
try {
|
||||
// Add a timeout to the request to prevent hanging
|
||||
const response = await axiosInstance.get('/auth-status', {
|
||||
timeout: 5000, // 5 second timeout
|
||||
headers: {
|
||||
'Accept': 'application/json' // Explicitly request JSON
|
||||
}
|
||||
});
|
||||
|
||||
// Check if response is HTML (which indicates a redirect or wrong endpoint)
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
console.warn('Received HTML response instead of JSON for auth-status endpoint');
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
}
|
||||
|
||||
// Strict validation of the response data
|
||||
if (response.data &&
|
||||
typeof response.data === 'object' &&
|
||||
'auth_configured' in response.data &&
|
||||
typeof response.data.auth_configured === 'boolean') {
|
||||
|
||||
// For unconfigured auth, ensure we have an access token
|
||||
if (!response.data.auth_configured) {
|
||||
if (response.data.access_token && typeof response.data.access_token === 'string') {
|
||||
return response.data;
|
||||
} else {
|
||||
console.warn('Auth not configured but no valid access token provided');
|
||||
}
|
||||
} else {
|
||||
// For configured auth, just return the data
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// If response data is invalid but we got a response, log it
|
||||
console.warn('Received invalid auth status response:', response.data);
|
||||
|
||||
// Default to auth configured if response is invalid
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
} catch (error) {
|
||||
// If the request fails, assume authentication is configured
|
||||
console.error('Failed to get auth status:', errorMessage(error));
|
||||
return {
|
||||
auth_configured: true,
|
||||
auth_mode: 'enabled'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await axiosInstance.post('/login', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
@@ -5,8 +5,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { PaletteIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function AppSettings() {
|
||||
interface AppSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AppSettings({ className }: AppSettingsProps) {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -27,7 +32,7 @@ export default function AppSettings() {
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
|
||||
<PaletteIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
49
lightrag_webui/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useCallback } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
/**
|
||||
* Component that toggles the language between English and Chinese.
|
||||
*/
|
||||
export default function LanguageToggle() {
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language
|
||||
const setLanguage = useSettingsStore.use.setLanguage()
|
||||
|
||||
const setEnglish = useCallback(() => {
|
||||
i18n.changeLanguage('en')
|
||||
setLanguage('en')
|
||||
}, [i18n, setLanguage])
|
||||
|
||||
const setChinese = useCallback(() => {
|
||||
i18n.changeLanguage('zh')
|
||||
setLanguage('zh')
|
||||
}, [i18n, setLanguage])
|
||||
|
||||
if (currentLanguage === 'zh') {
|
||||
return (
|
||||
<Button
|
||||
onClick={setEnglish}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to English"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
中
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setChinese}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="切换到中文"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
EN
|
||||
</Button>
|
||||
)
|
||||
}
|
@@ -13,23 +13,37 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
|
||||
* When the selected item changes, highlighted the node and center the camera on it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const graph = sigma.getGraph();
|
||||
|
||||
if (move) {
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
gotoNode(node)
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
gotoNode(node);
|
||||
} catch (error) {
|
||||
console.error('Error focusing on node:', error);
|
||||
}
|
||||
} else {
|
||||
// If no node is selected but move is true, reset to default view
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
|
||||
sigma.setCustomBBox(null);
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false);
|
||||
} else if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting node:', error);
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false)
|
||||
} else if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (node) {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', false);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up node highlight:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import Graph from 'graphology'
|
||||
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import { AbstractGraph } from 'graphology-types'
|
||||
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useEffect } from 'react'
|
||||
@@ -25,7 +25,6 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
||||
|
||||
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
||||
const { assign: assignLayout } = useLayoutForceAtlas2({
|
||||
@@ -45,14 +44,42 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
|
||||
/**
|
||||
* When component mount or maxIterations changes
|
||||
* => load the graph and apply layout
|
||||
* => ensure graph reference and apply layout
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigmaGraph) {
|
||||
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
|
||||
assignLayout()
|
||||
if (sigmaGraph && sigma) {
|
||||
// Ensure sigma binding to sigmaGraph
|
||||
try {
|
||||
if (typeof sigma.setGraph === 'function') {
|
||||
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);
|
||||
console.log('Binding graph to sigma instance');
|
||||
} else {
|
||||
(sigma as any).graph = sigmaGraph;
|
||||
console.warn('Simgma missing setGraph function, set graph property directly');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting graph on sigma instance:', error);
|
||||
}
|
||||
|
||||
assignLayout();
|
||||
console.log('Initial layout applied to graph');
|
||||
}
|
||||
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
|
||||
}, [sigma, sigmaGraph, assignLayout, maxIterations])
|
||||
|
||||
/**
|
||||
* Ensure the sigma instance is set in the store
|
||||
* This provides a backup in case the instance wasn't set in GraphViewer
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigma) {
|
||||
// Double-check that the store has the sigma instance
|
||||
const currentInstance = useGraphStore.getState().sigmaInstance;
|
||||
if (!currentInstance) {
|
||||
console.log('Setting sigma instance from GraphControl');
|
||||
useGraphStore.getState().setSigmaInstance(sigma);
|
||||
}
|
||||
}
|
||||
}, [sigma]);
|
||||
|
||||
/**
|
||||
* When component mount
|
||||
@@ -138,14 +165,18 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
const _focusedEdge = focusedEdge || selectedEdge
|
||||
|
||||
if (_focusedNode) {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||
try {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in nodeReducer:', error);
|
||||
}
|
||||
} else if (_focusedEdge) {
|
||||
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
|
||||
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||
newData.highlighted = true
|
||||
newData.size = 3
|
||||
@@ -173,21 +204,28 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
||||
if (!disableHoverEffect) {
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
|
||||
if (_focusedNode) {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||
try {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in edgeReducer:', error);
|
||||
}
|
||||
} else {
|
||||
if (focusedEdge || selectedEdge) {
|
||||
if (edge === selectedEdge) {
|
||||
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
|
||||
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
|
||||
|
||||
if (_selectedEdge || _focusedEdge) {
|
||||
if (edge === _selectedEdge) {
|
||||
newData.color = Constants.edgeColorSelected
|
||||
} else if (edge === focusedEdge) {
|
||||
} else if (edge === _focusedEdge) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
} else if (hideUnselectedEdges) {
|
||||
newData.hidden = true
|
||||
|
@@ -2,20 +2,23 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { labelListLimit } from '@/lib/constants'
|
||||
import { labelListLimit, controlButtonVariant } from '@/lib/constants'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
const GraphLabels = () => {
|
||||
const { t } = useTranslation()
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
|
||||
const rawGraph = useGraphStore.use.rawGraph()
|
||||
const labelsLoadedRef = useRef(false)
|
||||
|
||||
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
||||
const fetchInProgressRef = useRef(false)
|
||||
|
||||
// Fetch labels once on component mount, using global flag to prevent duplicates
|
||||
// Fetch labels and trigger initial data load
|
||||
useEffect(() => {
|
||||
// Check if we've already attempted to fetch labels in this session
|
||||
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
|
||||
@@ -26,8 +29,6 @@ const GraphLabels = () => {
|
||||
// Set global flag to indicate we've attempted to fetch in this session
|
||||
useGraphStore.getState().setLabelsFetchAttempted(true)
|
||||
|
||||
console.log('Fetching graph labels (once per session)...')
|
||||
|
||||
useGraphStore.getState().fetchAllDatabaseLabels()
|
||||
.then(() => {
|
||||
labelsLoadedRef.current = true
|
||||
@@ -42,6 +43,14 @@ const GraphLabels = () => {
|
||||
}
|
||||
}, []) // Empty dependency array ensures this only runs once on mount
|
||||
|
||||
// Trigger data load when labels are loaded
|
||||
useEffect(() => {
|
||||
if (labelsLoadedRef.current) {
|
||||
// Reset the fetch attempted flag to force a new data fetch
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
}
|
||||
}, [label])
|
||||
|
||||
const getSearchEngine = useCallback(() => {
|
||||
// Create search engine
|
||||
const searchEngine = new MiniSearch({
|
||||
@@ -83,52 +92,73 @@ const GraphLabels = () => {
|
||||
[getSearchEngine]
|
||||
)
|
||||
|
||||
return (
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const handleRefresh = useCallback(() => {
|
||||
// Reset labels fetch status to allow fetching labels again
|
||||
useGraphStore.getState().setLabelsFetchAttempted(false)
|
||||
|
||||
// Reset graph data fetch status directly, not depending on allDatabaseLabels changes
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
|
||||
// Fetch all labels again
|
||||
useGraphStore.getState().fetchAllDatabaseLabels()
|
||||
.then(() => {
|
||||
// Trigger a graph data reload by changing the query label back and forth
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to refresh labels:', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*'
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{rawGraph && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRefresh}
|
||||
tooltip={t('graphPanel.graphLabels.refreshTooltip')}
|
||||
className="mr-1"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
|
||||
// Reset the fetch attempted flag to force a new data fetch
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
|
||||
// Clear current graph data to ensure complete reload when label changes
|
||||
if (newLabel !== currentLabel) {
|
||||
const graphStore = useGraphStore.getState();
|
||||
graphStore.clearSelection();
|
||||
|
||||
// Reset the graph state but preserve the instance
|
||||
if (graphStore.sigmaGraph) {
|
||||
const nodes = Array.from(graphStore.sigmaGraph.nodes());
|
||||
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*'
|
||||
}
|
||||
}
|
||||
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
// reselect the same itme means qery all
|
||||
useSettingsStore.getState().setQueryLabel('*')
|
||||
} else {
|
||||
// Handle reselecting the same label
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
newLabel = '*'
|
||||
}
|
||||
|
||||
// Update the label, which will trigger the useEffect to handle data loading
|
||||
useSettingsStore.getState().setQueryLabel(newLabel)
|
||||
}
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
/>
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useEffect, useMemo } from 'react'
|
||||
import { FC, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
NodeById,
|
||||
@@ -11,28 +11,34 @@ import { useGraphStore } from '@/stores/graph'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface OptionItem {
|
||||
// Message item identifier for search results
|
||||
export const messageId = '__message_item'
|
||||
|
||||
// Search result option item interface
|
||||
export interface OptionItem {
|
||||
id: string
|
||||
type: 'nodes' | 'edges' | 'message'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const NodeOption = ({ id }: { id: string }) => {
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
if (!graph?.hasNode(id)) {
|
||||
return null
|
||||
}
|
||||
return <NodeById id={id} />
|
||||
}
|
||||
|
||||
function OptionComponent(item: OptionItem) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === 'nodes' && <NodeById id={item.id} />}
|
||||
{item.type === 'nodes' && <NodeOption id={item.id} />}
|
||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||
{item.type === 'message' && <div>{item.message}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const messageId = '__message_item'
|
||||
// Reset this cache when graph changes to ensure fresh search results
|
||||
const lastGraph: any = {
|
||||
graph: null,
|
||||
searchEngine: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Component thats display the search input.
|
||||
@@ -48,25 +54,24 @@ export const GraphSearchInput = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
const searchEngine = useGraphStore.use.searchEngine()
|
||||
|
||||
// Force reset the cache when graph changes
|
||||
// Reset search engine when graph changes
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
// Reset cache to ensure fresh search results with new graph data
|
||||
lastGraph.graph = null;
|
||||
lastGraph.searchEngine = null;
|
||||
useGraphStore.getState().resetSearchEngine()
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
if (lastGraph.graph == graph) {
|
||||
return lastGraph.searchEngine
|
||||
// Create search engine when needed
|
||||
useEffect(() => {
|
||||
// Skip if no graph, empty graph, or search engine already exists
|
||||
if (!graph || graph.nodes().length === 0 || searchEngine) {
|
||||
return
|
||||
}
|
||||
if (!graph || graph.nodes().length == 0) return
|
||||
|
||||
lastGraph.graph = graph
|
||||
|
||||
const searchEngine = new MiniSearch({
|
||||
// Create new search engine
|
||||
const newSearchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
fields: ['label'],
|
||||
searchOptions: {
|
||||
@@ -78,16 +83,16 @@ export const GraphSearchInput = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Add documents
|
||||
// Add nodes to search engine
|
||||
const documents = graph.nodes().map((id: string) => ({
|
||||
id: id,
|
||||
label: graph.getNodeAttribute(id, 'label')
|
||||
}))
|
||||
searchEngine.addAll(documents)
|
||||
newSearchEngine.addAll(documents)
|
||||
|
||||
lastGraph.searchEngine = searchEngine
|
||||
return searchEngine
|
||||
}, [graph])
|
||||
// Update search engine in store
|
||||
useGraphStore.getState().setSearchEngine(newSearchEngine)
|
||||
}, [graph, searchEngine])
|
||||
|
||||
/**
|
||||
* Loading the options while the user is typing.
|
||||
@@ -95,22 +100,35 @@ export const GraphSearchInput = ({
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
if (!graph || !searchEngine) return []
|
||||
|
||||
// If no query, return first searchResultLimit nodes
|
||||
// Safety checks to prevent crashes
|
||||
if (!graph || !searchEngine) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Verify graph has nodes before proceeding
|
||||
if (graph.nodes().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// If no query, return some nodes for user to select
|
||||
if (!query) {
|
||||
const nodeIds = graph.nodes().slice(0, searchResultLimit)
|
||||
const nodeIds = graph.nodes()
|
||||
.filter(id => graph.hasNode(id))
|
||||
.slice(0, searchResultLimit)
|
||||
return nodeIds.map(id => ({
|
||||
id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
}
|
||||
|
||||
// If has query, search nodes
|
||||
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
|
||||
id: r.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
// If has query, search nodes and verify they still exist
|
||||
const result: OptionItem[] = searchEngine.search(query)
|
||||
.filter((r: { id: string }) => graph.hasNode(r.id))
|
||||
.map((r: { id: string }) => ({
|
||||
id: r.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
|
||||
// prettier-ignore
|
||||
return result.length <= searchResultLimit
|
||||
|
@@ -7,7 +7,7 @@ import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
||||
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
||||
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
@@ -26,43 +26,161 @@ type LayoutName =
|
||||
| 'Force Directed'
|
||||
| 'Force Atlas'
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
|
||||
// Extend WorkerLayoutControlProps to include mainLayout
|
||||
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {
|
||||
mainLayout: LayoutHook;
|
||||
}
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {
|
||||
const sigma = useSigma()
|
||||
const { stop, start, isRunning } = layout
|
||||
// Use local state to track animation running status
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
// Timer reference for animation
|
||||
const animationTimerRef = useRef<number | null>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Function to update node positions using the layout algorithm
|
||||
const updatePositions = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
try {
|
||||
const graph = sigma.getGraph()
|
||||
if (!graph || graph.order === 0) return
|
||||
|
||||
// Use mainLayout to get positions, similar to refreshLayout function
|
||||
// console.log('Getting positions from mainLayout')
|
||||
const positions = mainLayout.positions()
|
||||
|
||||
// Animate nodes to new positions
|
||||
// console.log('Updating node positions with layout algorithm')
|
||||
animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates
|
||||
} catch (error) {
|
||||
console.error('Error updating positions:', error)
|
||||
// Stop animation if there's an error
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
}, [sigma, mainLayout])
|
||||
|
||||
// Improved click handler that uses our own animation timer
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning) {
|
||||
// Stop the animation
|
||||
console.log('Stopping layout animation')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
|
||||
// Try to kill the layout algorithm if it's running
|
||||
try {
|
||||
if (typeof layout.kill === 'function') {
|
||||
layout.kill()
|
||||
console.log('Layout algorithm killed')
|
||||
} else if (typeof layout.stop === 'function') {
|
||||
layout.stop()
|
||||
console.log('Layout algorithm stopped')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping layout algorithm:', error)
|
||||
}
|
||||
|
||||
setIsRunning(false)
|
||||
} else {
|
||||
// Start the animation
|
||||
console.log('Starting layout animation')
|
||||
|
||||
// Initial position update
|
||||
updatePositions()
|
||||
|
||||
// Set up interval for continuous updates
|
||||
animationTimerRef.current = window.setInterval(() => {
|
||||
updatePositions()
|
||||
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
||||
|
||||
setIsRunning(true)
|
||||
|
||||
// Set a timeout to automatically stop the animation after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (animationTimerRef.current) {
|
||||
console.log('Auto-stopping layout animation after 3 seconds')
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
setIsRunning(false)
|
||||
|
||||
// Try to stop the layout algorithm
|
||||
try {
|
||||
if (typeof layout.kill === 'function') {
|
||||
layout.kill()
|
||||
} else if (typeof layout.stop === 'function') {
|
||||
layout.stop()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping layout algorithm:', error)
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}, [isRunning, layout, updatePositions])
|
||||
|
||||
/**
|
||||
* Init component when Sigma or component settings change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) {
|
||||
console.log('No sigma instance available')
|
||||
return
|
||||
}
|
||||
|
||||
// we run the algo
|
||||
// Auto-run if specified
|
||||
let timeout: number | null = null
|
||||
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
||||
start()
|
||||
// set a timeout to stop it
|
||||
timeout =
|
||||
autoRunFor > 0
|
||||
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
||||
: null
|
||||
}
|
||||
console.log('Auto-starting layout animation')
|
||||
|
||||
//cleaning
|
||||
return () => {
|
||||
stop()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
// Initial position update
|
||||
updatePositions()
|
||||
|
||||
// Set up interval for continuous updates
|
||||
animationTimerRef.current = window.setInterval(() => {
|
||||
updatePositions()
|
||||
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
||||
|
||||
setIsRunning(true)
|
||||
|
||||
// Set a timeout to stop it if autoRunFor > 0
|
||||
if (autoRunFor > 0) {
|
||||
timeout = window.setTimeout(() => {
|
||||
console.log('Auto-stopping layout animation after timeout')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
setIsRunning(false)
|
||||
}, autoRunFor)
|
||||
}
|
||||
}
|
||||
}, [autoRunFor, start, stop, sigma])
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// console.log('Cleaning up WorkerLayoutControl')
|
||||
if (animationTimerRef.current) {
|
||||
window.clearInterval(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
setIsRunning(false)
|
||||
}
|
||||
}, [autoRunFor, sigma, updatePositions])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => (isRunning ? stop() : start())}
|
||||
onClick={handleClick}
|
||||
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
|
||||
variant={controlButtonVariant}
|
||||
>
|
||||
@@ -85,8 +203,27 @@ const LayoutsControl = () => {
|
||||
const layoutCircular = useLayoutCircular()
|
||||
const layoutCirclepack = useLayoutCirclepack()
|
||||
const layoutRandom = useLayoutRandom()
|
||||
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
|
||||
const layoutForce = useLayoutForce({ maxIterations: maxIterations })
|
||||
const layoutNoverlap = useLayoutNoverlap({
|
||||
maxIterations: maxIterations,
|
||||
settings: {
|
||||
margin: 5,
|
||||
expansion: 1.1,
|
||||
gridSize: 1,
|
||||
ratio: 1,
|
||||
speed: 3,
|
||||
}
|
||||
})
|
||||
// Add parameters for Force Directed layout to improve convergence
|
||||
const layoutForce = useLayoutForce({
|
||||
maxIterations: maxIterations,
|
||||
settings: {
|
||||
attraction: 0.0003, // Lower attraction force to reduce oscillation
|
||||
repulsion: 0.05, // Lower repulsion force to reduce oscillation
|
||||
gravity: 0.01, // Increase gravity to make nodes converge to center faster
|
||||
inertia: 0.4, // Lower inertia to add damping effect
|
||||
maxMove: 100 // Limit maximum movement per step to prevent large jumps
|
||||
}
|
||||
})
|
||||
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
|
||||
const workerNoverlap = useWorkerLayoutNoverlap()
|
||||
const workerForce = useWorkerLayoutForce()
|
||||
@@ -130,10 +267,23 @@ const LayoutsControl = () => {
|
||||
|
||||
const runLayout = useCallback(
|
||||
(newLayout: LayoutName) => {
|
||||
console.debug(newLayout)
|
||||
console.debug('Running layout:', newLayout)
|
||||
const { positions } = layouts[newLayout].layout
|
||||
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
|
||||
setLayout(newLayout)
|
||||
|
||||
try {
|
||||
const graph = sigma.getGraph()
|
||||
if (!graph) {
|
||||
console.error('No graph available')
|
||||
return
|
||||
}
|
||||
|
||||
const pos = positions()
|
||||
console.log('Positions calculated, animating nodes')
|
||||
animateNodes(graph, pos, { duration: 400 })
|
||||
setLayout(newLayout)
|
||||
} catch (error) {
|
||||
console.error('Error running layout:', error)
|
||||
}
|
||||
},
|
||||
[layouts, sigma]
|
||||
)
|
||||
@@ -142,7 +292,10 @@ const LayoutsControl = () => {
|
||||
<>
|
||||
<div>
|
||||
{layouts[layout] && 'worker' in layouts[layout] && (
|
||||
<WorkerLayoutControl layout={layouts[layout].worker!} />
|
||||
<WorkerLayoutControl
|
||||
layout={layouts[layout].worker!}
|
||||
mainLayout={layouts[layout].layout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import Text from '@/components/ui/Text'
|
||||
import Button from '@/components/ui/Button'
|
||||
import useLightragGraph from '@/hooks/useLightragGraph'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GitBranchPlus, Scissors } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Component that view properties of elements in graph.
|
||||
@@ -88,22 +90,41 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const relationships = []
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
for (const edgeId of state.sigmaGraph.edges(node.id)) {
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
try {
|
||||
if (!state.sigmaGraph.hasNode(node.id)) {
|
||||
return {
|
||||
...node,
|
||||
relationships: []
|
||||
}
|
||||
}
|
||||
|
||||
const edges = state.sigmaGraph.edges(node.id)
|
||||
|
||||
for (const edgeId of edges) {
|
||||
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
|
||||
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
|
||||
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
|
||||
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining node properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
relationships
|
||||
@@ -112,8 +133,31 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
|
||||
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const sourceNode = state.rawGraph?.getNode(edge.source)
|
||||
const targetNode = state.rawGraph?.getNode(edge.target)
|
||||
let sourceNode: RawNodeType | undefined = undefined
|
||||
let targetNode: RawNodeType | undefined = undefined
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
try {
|
||||
if (!state.sigmaGraph.hasEdge(edge.id)) {
|
||||
return {
|
||||
...edge,
|
||||
sourceNode: undefined,
|
||||
targetNode: undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.source)) {
|
||||
sourceNode = state.rawGraph.getNode(edge.source)
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.target)) {
|
||||
targetNode = state.rawGraph.getNode(edge.target)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining edge properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
sourceNode,
|
||||
@@ -157,9 +201,40 @@ const PropertyRow = ({
|
||||
|
||||
const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleExpandNode = () => {
|
||||
useGraphStore.getState().triggerNodeExpand(node.id)
|
||||
}
|
||||
|
||||
const handlePruneNode = () => {
|
||||
useGraphStore.getState().triggerNodePrune(node.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handleExpandNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.expandNode')}
|
||||
>
|
||||
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handlePruneNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
|
||||
<PropertyRow
|
||||
@@ -171,7 +246,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
/>
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.node.properties')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(node.properties)
|
||||
.sort()
|
||||
@@ -181,7 +256,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
</div>
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-emerald-700">
|
||||
{t('graphPanel.propertiesView.node.relationships')}
|
||||
</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
@@ -208,7 +283,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">{t('graphPanel.propertiesView.edge.title')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
||||
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
||||
@@ -227,7 +302,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.edge.properties')}</label>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(edge.properties)
|
||||
.sort()
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback} from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Button from '@/components/ui/Button'
|
||||
@@ -7,10 +7,8 @@ import Input from '@/components/ui/Input'
|
||||
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
@@ -114,8 +112,6 @@ const LabeledNumberInput = ({
|
||||
*/
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const [tempApiKey, setTempApiKey] = useState<string>('')
|
||||
const refreshLayout = useGraphStore.use.refreshLayout()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
@@ -129,11 +125,6 @@ export default function Settings() {
|
||||
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
||||
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const apiKey = useSettingsStore.use.apiKey()
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(apiKey || '')
|
||||
}, [apiKey, opened])
|
||||
|
||||
const setEnableNodeDrag = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||
@@ -182,11 +173,22 @@ export default function Settings() {
|
||||
const setGraphQueryMaxDepth = useCallback((depth: number) => {
|
||||
if (depth < 1) return
|
||||
useSettingsStore.setState({ graphQueryMaxDepth: depth })
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const setGraphMinDegree = useCallback((degree: number) => {
|
||||
if (degree < 0) return
|
||||
useSettingsStore.setState({ graphMinDegree: degree })
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
useSettingsStore.getState().setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
||||
}, 300)
|
||||
|
||||
}, [])
|
||||
|
||||
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
|
||||
@@ -194,34 +196,19 @@ export default function Settings() {
|
||||
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
|
||||
}, [])
|
||||
|
||||
const setApiKey = useCallback(async () => {
|
||||
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
||||
await useBackendState.getState().check()
|
||||
setOpened(false)
|
||||
}, [tempApiKey])
|
||||
|
||||
const handleTempApiKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTempApiKey(e.target.value)
|
||||
},
|
||||
[setTempApiKey]
|
||||
)
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const saveSettings = () => setOpened(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
|
||||
size="icon"
|
||||
onClick={refreshLayout}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</Button>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.settings')}
|
||||
size="icon"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -303,30 +290,15 @@ export default function Settings() {
|
||||
onEditFinished={setGraphLayoutMaxIterations}
|
||||
/>
|
||||
<Separator />
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto px-4"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</label>
|
||||
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="w-0 flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
value={tempApiKey}
|
||||
onChange={handleTempApiKeyChange}
|
||||
placeholder={t('graphPanel.sideBar.settings.enterYourAPIkey')}
|
||||
className="max-h-full w-full min-w-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={setApiKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="max-h-full shrink-0"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
@@ -11,7 +11,7 @@ const SettingsDisplay = () => {
|
||||
const graphMinDegree = useSettingsStore.use.graphMinDegree()
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
||||
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
|
||||
</div>
|
||||
|
@@ -1,37 +1,107 @@
|
||||
import { useCamera } from '@react-sigma/core'
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Component that provides zoom controls for the graph viewer.
|
||||
*/
|
||||
const ZoomControl = () => {
|
||||
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||
const sigma = useSigma()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||
const handleResetZoom = useCallback(() => reset(), [reset])
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
try {
|
||||
// First clear any custom bounding box and refresh
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.refresh()
|
||||
|
||||
// Get graph after refresh
|
||||
const graph = sigma.getGraph()
|
||||
|
||||
// Check if graph has nodes before accessing them
|
||||
if (!graph?.order || graph.nodes().length === 0) {
|
||||
// Use reset() for empty graph case
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
sigma.getCamera().animate(
|
||||
{ x: 0.5, y: 0.5, ratio: 1.1 },
|
||||
{ duration: 1000 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error resetting zoom:', error)
|
||||
// Use reset() as fallback on error
|
||||
reset()
|
||||
}
|
||||
}, [sigma, reset])
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle + Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
const handleRotateCounterClockwise = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle - Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon">
|
||||
<ZoomInIcon />
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotateCounterClockwise}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon">
|
||||
<ZoomOutIcon />
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotate}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleResetZoom}
|
||||
tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
|
||||
size="icon"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -11,18 +11,16 @@ const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
|
@@ -38,7 +38,7 @@ const TooltipContent = React.forwardRef<
|
||||
side={side}
|
||||
align={align}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md z-60',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -15,16 +15,22 @@ export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ ch
|
||||
// Get current tab from settings store
|
||||
const currentTab = useSettingsStore.use.currentTab();
|
||||
|
||||
// Initialize visibility state with current tab as visible
|
||||
// Initialize visibility state with all tabs visible
|
||||
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
||||
[currentTab]: true
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
}));
|
||||
|
||||
// Update visibility when current tab changes
|
||||
// Keep all tabs visible because we use CSS to control TAB visibility instead of React
|
||||
useEffect(() => {
|
||||
setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
[currentTab]: true
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
}));
|
||||
}, [currentTab]);
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Table,
|
||||
@@ -27,9 +27,7 @@ export default function DocumentManager() {
|
||||
const { t } = useTranslation()
|
||||
const health = useBackendState.use.health()
|
||||
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isDocumentsTabVisible = isTabVisible('documents')
|
||||
const initialLoadRef = useRef(false)
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -45,7 +43,6 @@ export default function DocumentManager() {
|
||||
} else {
|
||||
setDocs(null)
|
||||
}
|
||||
// console.log(docs)
|
||||
} else {
|
||||
setDocs(null)
|
||||
}
|
||||
@@ -54,13 +51,12 @@ export default function DocumentManager() {
|
||||
}
|
||||
}, [setDocs, t])
|
||||
|
||||
// Only fetch documents when the tab becomes visible for the first time
|
||||
// Fetch documents when the tab becomes visible
|
||||
useEffect(() => {
|
||||
if (isDocumentsTabVisible && !initialLoadRef.current) {
|
||||
if (currentTab === 'documents') {
|
||||
fetchDocuments()
|
||||
initialLoadRef.current = true
|
||||
}
|
||||
}, [isDocumentsTabVisible, fetchDocuments])
|
||||
}, [currentTab, fetchDocuments])
|
||||
|
||||
const scanDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,9 +67,9 @@ export default function DocumentManager() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Only set up polling when the tab is visible and health is good
|
||||
// Set up polling when the documents tab is active and health is good
|
||||
useEffect(() => {
|
||||
if (!isDocumentsTabVisible || !health) {
|
||||
if (currentTab !== 'documents' || !health) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +82,7 @@ export default function DocumentManager() {
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [health, fetchDocuments, t, isDocumentsTabVisible])
|
||||
}, [health, fetchDocuments, t, currentTab])
|
||||
|
||||
return (
|
||||
<Card className="!size-full !rounded-none !border-none">
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
@@ -108,46 +107,46 @@ const GraphEvents = () => {
|
||||
const GraphViewer = () => {
|
||||
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
||||
const sigmaRef = useRef<any>(null)
|
||||
const initAttemptedRef = useRef(false)
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
const shouldRender = useGraphStore.use.shouldRender() // Rendering control state
|
||||
|
||||
// Get tab visibility
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
|
||||
// Handle component mount/unmount and tab visibility
|
||||
useEffect(() => {
|
||||
// When component mounts or tab becomes visible
|
||||
if (isGraphTabVisible && !shouldRender && !isFetching && !initAttemptedRef.current) {
|
||||
// If tab is visible but graph is not rendering, try to enable rendering
|
||||
useGraphStore.getState().setShouldRender(true)
|
||||
initAttemptedRef.current = true
|
||||
console.log('Graph viewer initialized')
|
||||
}
|
||||
|
||||
// Cleanup function when component unmounts
|
||||
return () => {
|
||||
// Only log cleanup, don't actually clean up the WebGL context
|
||||
// This allows the WebGL context to persist across tab switches
|
||||
console.log('Graph viewer cleanup')
|
||||
}
|
||||
}, [isGraphTabVisible, shouldRender, isFetching])
|
||||
|
||||
// Initialize sigma settings once on component mount
|
||||
// All dynamic settings will be updated in GraphControl using useSetSettings
|
||||
useEffect(() => {
|
||||
setSigmaSettings(defaultSigmaSettings)
|
||||
console.log('Initialized sigma settings')
|
||||
}, [])
|
||||
|
||||
// Clean up sigma instance when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// TAB is mount twice in vite dev mode, this is a workaround
|
||||
|
||||
const sigma = useGraphStore.getState().sigmaInstance;
|
||||
if (sigma) {
|
||||
try {
|
||||
// Destroy sigma,and clear WebGL context
|
||||
sigma.kill();
|
||||
useGraphStore.getState().setSigmaInstance(null);
|
||||
console.log('Cleared sigma instance on Graphviewer unmount');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up sigma instance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,
|
||||
// but testing showed it wasn't executing or having any effect, while the backup mechanism
|
||||
// in GraphControl was sufficient. This code was removed to simplify implementation
|
||||
|
||||
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
||||
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
||||
@@ -167,62 +166,51 @@ const GraphViewer = () => {
|
||||
[selectedNode]
|
||||
)
|
||||
|
||||
// Since TabsContent now forces mounting of all tabs, we need to conditionally render
|
||||
// the SigmaContainer based on visibility to avoid unnecessary rendering
|
||||
// Always render SigmaContainer but control its visibility with CSS
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Only render the SigmaContainer when the tab is visible */}
|
||||
{isGraphTabVisible ? (
|
||||
<SigmaContainer
|
||||
settings={sigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<SigmaContainer
|
||||
settings={sigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<Settings />
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
) : (
|
||||
// Placeholder when tab is not visible
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{/* Placeholder content */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<LayoutsControl />
|
||||
<ZoomControl />
|
||||
<FullScreenControl />
|
||||
<Settings />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
|
||||
{/* Loading overlay - shown when data is loading */}
|
||||
{isFetching && (
|
||||
|
177
lightrag_webui/src/features/LoginPage.tsx
Normal file
177
lightrag_webui/src/features/LoginPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { loginToServer, getAuthStatus } from '@/api/lightrag'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZapIcon } from 'lucide-react'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthenticated } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('LoginPage mounted')
|
||||
}, []);
|
||||
|
||||
// Check if authentication is configured, skip login if not
|
||||
useEffect(() => {
|
||||
let isMounted = true; // Flag to prevent state updates after unmount
|
||||
|
||||
const checkAuthConfig = async () => {
|
||||
try {
|
||||
// If already authenticated, redirect to home
|
||||
if (isAuthenticated) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Only proceed if component is still mounted
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token and redirect
|
||||
login(status.access_token, true)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
navigate('/')
|
||||
return // Exit early, no need to set checkingAuth to false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth configuration:', error)
|
||||
} finally {
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted) {
|
||||
setCheckingAuth(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthConfig()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
}
|
||||
}, [isAuthenticated, login, navigate])
|
||||
|
||||
// Don't render anything while checking auth
|
||||
if (checkingAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) {
|
||||
toast.error(t('login.errorEmptyFields'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await loginToServer(username, password)
|
||||
|
||||
// Check authentication mode
|
||||
const isGuestMode = response.auth_mode === 'disabled'
|
||||
login(response.access_token, isGuestMode)
|
||||
|
||||
if (isGuestMode) {
|
||||
// Show authentication disabled notification
|
||||
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
|
||||
} else {
|
||||
toast.success(t('login.successMessage'))
|
||||
}
|
||||
|
||||
// Navigate to home page after successful login
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Login failed...', error)
|
||||
toast.error(t('login.errorInvalidCredentials'))
|
||||
|
||||
// Clear any existing auth state
|
||||
useAuthStore.getState().logout()
|
||||
// Clear local storage
|
||||
localStorage.removeItem('LIGHTRAG-API-TOKEN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
||||
</div>
|
||||
<Card className="w-full max-w-[480px] shadow-lg mx-4">
|
||||
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="logo.png" alt="LightRAG Logo" className="h-12 w-12" />
|
||||
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8 pb-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="username" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="password" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 text-base font-medium mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('login.loggingIn') : t('login.loginButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
@@ -112,7 +112,7 @@ export default function RetrievalTesting() {
|
||||
}, [setMessages])
|
||||
|
||||
return (
|
||||
<div className="flex size-full gap-2 px-2 pb-12">
|
||||
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="relative grow">
|
||||
<div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SiteInfo } from '@/lib/constants'
|
||||
import { SiteInfo, webuiPrefix } from '@/lib/constants'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ZapIcon, GithubIcon } from 'lucide-react'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react'
|
||||
|
||||
interface NavigationTabProps {
|
||||
value: string
|
||||
@@ -54,9 +55,15 @@ function TabsNavigation() {
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { t } = useTranslation()
|
||||
const { isGuestMode } = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
navigationService.navigateToLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
|
||||
<a href="/" className="mr-6 flex items-center gap-2">
|
||||
<a href={webuiPrefix} className="mr-6 flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
||||
{/* <img src='/logo.png' className="size-4" /> */}
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
@@ -64,6 +71,11 @@ export default function SiteHeader() {
|
||||
|
||||
<div className="flex h-10 flex-1 justify-center">
|
||||
<TabsNavigation />
|
||||
{isGuestMode && (
|
||||
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
|
||||
{t('login.guestMode', 'Guest Mode')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center">
|
||||
@@ -74,6 +86,9 @@ export default function SiteHeader() {
|
||||
</a>
|
||||
</Button>
|
||||
<AppSettings />
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.logout')} onClick={handleLogout}>
|
||||
<LogOutIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import Graph, { DirectedGraph } from 'graphology'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { randomColor, errorMessage } from '@/lib/utils'
|
||||
import * as Constants from '@/lib/constants'
|
||||
import { useGraphStore, RawGraph } from '@/stores/graph'
|
||||
import { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import { toast } from 'sonner'
|
||||
import { queryGraphs } from '@/api/lightrag'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
|
||||
import seedrandom from 'seedrandom'
|
||||
|
||||
@@ -139,7 +140,13 @@ const fetchGraph = async (label: string, maxDepth: number, minDegree: number) =>
|
||||
|
||||
// Create a new graph instance with the raw graph data
|
||||
const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
// Always create a new graph instance
|
||||
// Skip graph creation if no data or empty nodes
|
||||
if (!rawGraph || !rawGraph.nodes.length) {
|
||||
console.log('No graph data available, skipping sigma graph creation');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new graph instance
|
||||
const graph = new DirectedGraph()
|
||||
|
||||
// Add nodes from raw graph data
|
||||
@@ -172,30 +179,20 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
}
|
||||
|
||||
const useLightrangeGraph = () => {
|
||||
const { t } = useTranslation()
|
||||
const queryLabel = useSettingsStore.use.queryLabel()
|
||||
const rawGraph = useGraphStore.use.rawGraph()
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
|
||||
const minDegree = useSettingsStore.use.graphMinDegree()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
|
||||
// Get tab visibility
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
||||
|
||||
// Track previous parameters to detect actual changes
|
||||
const prevParamsRef = useRef({ queryLabel, maxQueryDepth, minDegree })
|
||||
const nodeToExpand = useGraphStore.use.nodeToExpand()
|
||||
const nodeToPrune = useGraphStore.use.nodeToPrune()
|
||||
|
||||
// Use ref to track if data has been loaded and initial load
|
||||
const dataLoadedRef = useRef(false)
|
||||
const initialLoadRef = useRef(false)
|
||||
|
||||
// Check if parameters have changed
|
||||
const paramsChanged =
|
||||
prevParamsRef.current.queryLabel !== queryLabel ||
|
||||
prevParamsRef.current.maxQueryDepth !== maxQueryDepth ||
|
||||
prevParamsRef.current.minDegree !== minDegree
|
||||
|
||||
const getNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
return rawGraph?.getNode(nodeId) || null
|
||||
@@ -213,43 +210,33 @@ const useLightrangeGraph = () => {
|
||||
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
||||
const fetchInProgressRef = useRef(false)
|
||||
|
||||
// Data fetching logic - simplified but preserving TAB visibility check
|
||||
// Reset graph when query label is cleared
|
||||
useEffect(() => {
|
||||
// Skip if fetch is already in progress
|
||||
if (fetchInProgressRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// If there's no query label, reset the graph
|
||||
if (!queryLabel) {
|
||||
if (rawGraph !== null || sigmaGraph !== null) {
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
state.setGraphDataFetchAttempted(false)
|
||||
state.setLabelsFetchAttempted(false)
|
||||
}
|
||||
if (!queryLabel && (rawGraph !== null || sigmaGraph !== null)) {
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
state.setGraphDataFetchAttempted(false)
|
||||
state.setLabelsFetchAttempted(false)
|
||||
dataLoadedRef.current = false
|
||||
initialLoadRef.current = false
|
||||
}
|
||||
}, [queryLabel, rawGraph, sigmaGraph])
|
||||
|
||||
// Data fetching logic
|
||||
useEffect(() => {
|
||||
// Skip if fetch is already in progress or no query label
|
||||
if (fetchInProgressRef.current || !queryLabel) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parameters have changed
|
||||
if (!isFetching && !fetchInProgressRef.current &&
|
||||
(paramsChanged || !useGraphStore.getState().graphDataFetchAttempted)) {
|
||||
|
||||
// Only fetch data if the Graph tab is visible
|
||||
if (!isGraphTabVisible) {
|
||||
console.log('Graph tab not visible, skipping data fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only fetch data when graphDataFetchAttempted is false (avoids re-fetching on vite dev mode)
|
||||
if (!isFetching && !useGraphStore.getState().graphDataFetchAttempted) {
|
||||
// Set flags
|
||||
fetchInProgressRef.current = true
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(true)
|
||||
|
||||
const state = useGraphStore.getState()
|
||||
state.setIsFetching(true)
|
||||
state.setShouldRender(false) // Disable rendering during data loading
|
||||
|
||||
// Clear selection and highlighted nodes before fetching new graph
|
||||
state.clearSelection()
|
||||
@@ -259,9 +246,6 @@ const useLightrangeGraph = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Update parameter reference
|
||||
prevParamsRef.current = { queryLabel, maxQueryDepth, minDegree }
|
||||
|
||||
console.log('Fetching graph data...')
|
||||
|
||||
// Use a local copy of the parameters
|
||||
@@ -284,8 +268,6 @@ const useLightrangeGraph = () => {
|
||||
state.setSigmaGraph(newSigmaGraph)
|
||||
state.setRawGraph(data)
|
||||
|
||||
// No longer need to extract labels from graph data
|
||||
|
||||
// Update flags
|
||||
dataLoadedRef.current = true
|
||||
initialLoadRef.current = true
|
||||
@@ -294,8 +276,6 @@ const useLightrangeGraph = () => {
|
||||
// Reset camera view
|
||||
state.setMoveToSelectedNode(true)
|
||||
|
||||
// Enable rendering if the tab is visible
|
||||
state.setShouldRender(isGraphTabVisible)
|
||||
state.setIsFetching(false)
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching graph data:', error)
|
||||
@@ -303,29 +283,425 @@ const useLightrangeGraph = () => {
|
||||
// Reset state on error
|
||||
const state = useGraphStore.getState()
|
||||
state.setIsFetching(false)
|
||||
state.setShouldRender(isGraphTabVisible)
|
||||
dataLoadedRef.current = false
|
||||
fetchInProgressRef.current = false
|
||||
state.setGraphDataFetchAttempted(false)
|
||||
})
|
||||
}
|
||||
}, [queryLabel, maxQueryDepth, minDegree, isFetching, paramsChanged, isGraphTabVisible, rawGraph, sigmaGraph])
|
||||
}, [queryLabel, maxQueryDepth, minDegree, isFetching])
|
||||
|
||||
// Update rendering state and handle tab visibility changes
|
||||
// Handle node expansion
|
||||
useEffect(() => {
|
||||
// When tab becomes visible
|
||||
if (isGraphTabVisible) {
|
||||
// If we have data, enable rendering
|
||||
if (rawGraph) {
|
||||
useGraphStore.getState().setShouldRender(true)
|
||||
}
|
||||
const handleNodeExpand = async (nodeId: string | null) => {
|
||||
if (!nodeId || !sigmaGraph || !rawGraph) return;
|
||||
|
||||
// We no longer reset the fetch attempted flag here to prevent continuous API calls
|
||||
} else {
|
||||
// When tab becomes invisible, disable rendering
|
||||
useGraphStore.getState().setShouldRender(false)
|
||||
try {
|
||||
// Get the node to expand
|
||||
const nodeToExpand = rawGraph.getNode(nodeId);
|
||||
if (!nodeToExpand) {
|
||||
console.error('Node not found:', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the label of the node to expand
|
||||
const label = nodeToExpand.labels[0];
|
||||
if (!label) {
|
||||
console.error('Node has no label:', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the extended subgraph with depth 2
|
||||
const extendedGraph = await queryGraphs(label, 2, 0);
|
||||
|
||||
if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
|
||||
console.error('Failed to fetch extended graph');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process nodes to add required properties for RawNodeType
|
||||
const processedNodes: RawNodeType[] = [];
|
||||
for (const node of extendedGraph.nodes) {
|
||||
// Generate random color values
|
||||
seedrandom(node.id, { global: true });
|
||||
const color = randomColor();
|
||||
|
||||
// Create a properly typed RawNodeType
|
||||
processedNodes.push({
|
||||
id: node.id,
|
||||
labels: node.labels,
|
||||
properties: node.properties,
|
||||
size: 10, // Default size, will be calculated later
|
||||
x: Math.random(), // Random position, will be adjusted later
|
||||
y: Math.random(), // Random position, will be adjusted later
|
||||
color: color, // Random color
|
||||
degree: 0 // Initial degree, will be calculated later
|
||||
});
|
||||
}
|
||||
|
||||
// Process edges to add required properties for RawEdgeType
|
||||
const processedEdges: RawEdgeType[] = [];
|
||||
for (const edge of extendedGraph.edges) {
|
||||
// Create a properly typed RawEdgeType
|
||||
processedEdges.push({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type,
|
||||
properties: edge.properties,
|
||||
dynamicId: '' // Will be set when adding to sigma graph
|
||||
});
|
||||
}
|
||||
|
||||
// Store current node positions
|
||||
const nodePositions: Record<string, {x: number, y: number}> = {};
|
||||
sigmaGraph.forEachNode((node) => {
|
||||
nodePositions[node] = {
|
||||
x: sigmaGraph.getNodeAttribute(node, 'x'),
|
||||
y: sigmaGraph.getNodeAttribute(node, 'y')
|
||||
};
|
||||
});
|
||||
|
||||
// Get existing node IDs
|
||||
const existingNodeIds = new Set(sigmaGraph.nodes());
|
||||
|
||||
// Identify nodes and edges to keep
|
||||
const nodesToAdd = new Set<string>();
|
||||
const edgesToAdd = new Set<string>();
|
||||
|
||||
// Get degree range from existing graph for size calculations
|
||||
const minDegree = 1;
|
||||
let maxDegree = 0;
|
||||
sigmaGraph.forEachNode(node => {
|
||||
const degree = sigmaGraph.degree(node);
|
||||
maxDegree = Math.max(maxDegree, degree);
|
||||
});
|
||||
|
||||
// Calculate size formula parameters
|
||||
const range = maxDegree - minDegree || 1; // Avoid division by zero
|
||||
const scale = Constants.maxNodeSize - Constants.minNodeSize;
|
||||
|
||||
// First identify connectable nodes (nodes connected to the expanded node)
|
||||
for (const node of processedNodes) {
|
||||
// Skip if node already exists
|
||||
if (existingNodeIds.has(node.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this node is connected to the selected node
|
||||
const isConnected = processedEdges.some(
|
||||
edge => (edge.source === nodeId && edge.target === node.id) ||
|
||||
(edge.target === nodeId && edge.source === node.id)
|
||||
);
|
||||
|
||||
if (isConnected) {
|
||||
nodesToAdd.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate node degrees and track discarded edges in one pass
|
||||
const nodeDegrees = new Map<string, number>();
|
||||
const nodesWithDiscardedEdges = new Set<string>();
|
||||
|
||||
for (const edge of processedEdges) {
|
||||
const sourceExists = existingNodeIds.has(edge.source) || nodesToAdd.has(edge.source);
|
||||
const targetExists = existingNodeIds.has(edge.target) || nodesToAdd.has(edge.target);
|
||||
|
||||
if (sourceExists && targetExists) {
|
||||
edgesToAdd.add(edge.id);
|
||||
// Add degrees for valid edges
|
||||
if (nodesToAdd.has(edge.source)) {
|
||||
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1);
|
||||
}
|
||||
if (nodesToAdd.has(edge.target)) {
|
||||
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1);
|
||||
}
|
||||
} else {
|
||||
// Track discarded edges for both new and existing nodes
|
||||
if (sigmaGraph.hasNode(edge.source)) {
|
||||
nodesWithDiscardedEdges.add(edge.source);
|
||||
} else if (nodesToAdd.has(edge.source)) {
|
||||
nodesWithDiscardedEdges.add(edge.source);
|
||||
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); // +1 for discarded edge
|
||||
}
|
||||
if (sigmaGraph.hasNode(edge.target)) {
|
||||
nodesWithDiscardedEdges.add(edge.target);
|
||||
} else if (nodesToAdd.has(edge.target)) {
|
||||
nodesWithDiscardedEdges.add(edge.target);
|
||||
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); // +1 for discarded edge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to update node sizes
|
||||
const updateNodeSizes = (
|
||||
sigmaGraph: DirectedGraph,
|
||||
nodesWithDiscardedEdges: Set<string>,
|
||||
minDegree: number,
|
||||
range: number,
|
||||
scale: number
|
||||
) => {
|
||||
for (const nodeId of nodesWithDiscardedEdges) {
|
||||
if (sigmaGraph.hasNode(nodeId)) {
|
||||
let newDegree = sigmaGraph.degree(nodeId);
|
||||
newDegree += 1; // Add +1 for discarded edges
|
||||
|
||||
const newSize = Math.round(
|
||||
Constants.minNodeSize + scale * Math.pow((newDegree - minDegree) / range, 0.5)
|
||||
);
|
||||
|
||||
const currentSize = sigmaGraph.getNodeAttribute(nodeId, 'size');
|
||||
|
||||
if (newSize > currentSize) {
|
||||
sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If no new connectable nodes found, show toast and return
|
||||
if (nodesToAdd.size === 0) {
|
||||
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, range, scale);
|
||||
toast.info(t('graphPanel.propertiesView.node.noNewNodes'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update maxDegree with new node degrees
|
||||
for (const [, degree] of nodeDegrees.entries()) {
|
||||
maxDegree = Math.max(maxDegree, degree);
|
||||
}
|
||||
|
||||
// SAdd nodes and edges to the graph
|
||||
// Calculate camera ratio and spread factor once before the loop
|
||||
const cameraRatio = useGraphStore.getState().sigmaInstance?.getCamera().ratio || 1;
|
||||
const spreadFactor = Math.max(
|
||||
Math.sqrt(nodeToExpand.size) * 4, // Base on node size
|
||||
Math.sqrt(nodesToAdd.size) * 3 // Scale with number of nodes
|
||||
) / cameraRatio; // Adjust for zoom level
|
||||
seedrandom(Date.now().toString(), { global: true });
|
||||
const randomAngle = Math.random() * 2 * Math.PI
|
||||
|
||||
console.log('nodeSize:', nodeToExpand.size, 'nodesToAdd:', nodesToAdd.size);
|
||||
console.log('cameraRatio:', Math.round(cameraRatio*100)/100, 'spreadFactor:', Math.round(spreadFactor*100)/100);
|
||||
|
||||
// Add new nodes
|
||||
for (const nodeId of nodesToAdd) {
|
||||
const newNode = processedNodes.find(n => n.id === nodeId)!;
|
||||
const nodeDegree = nodeDegrees.get(nodeId) || 0;
|
||||
|
||||
// Calculate node size
|
||||
const nodeSize = Math.round(
|
||||
Constants.minNodeSize + scale * Math.pow((nodeDegree - minDegree) / range, 0.5)
|
||||
);
|
||||
|
||||
// Calculate angle for polar coordinates
|
||||
const angle = 2 * Math.PI * (Array.from(nodesToAdd).indexOf(nodeId) / nodesToAdd.size);
|
||||
|
||||
// Calculate final position
|
||||
const x = nodePositions[nodeId]?.x ||
|
||||
(nodePositions[nodeToExpand.id].x + Math.cos(randomAngle + angle) * spreadFactor);
|
||||
const y = nodePositions[nodeId]?.y ||
|
||||
(nodePositions[nodeToExpand.id].y + Math.sin(randomAngle + angle) * spreadFactor);
|
||||
|
||||
// Add the new node to the sigma graph with calculated position
|
||||
sigmaGraph.addNode(nodeId, {
|
||||
label: newNode.labels.join(', '),
|
||||
color: newNode.color,
|
||||
x: x,
|
||||
y: y,
|
||||
size: nodeSize,
|
||||
borderColor: Constants.nodeBorderColor,
|
||||
borderSize: 0.2
|
||||
});
|
||||
|
||||
// Add the node to the raw graph
|
||||
if (!rawGraph.getNode(nodeId)) {
|
||||
// Update node properties
|
||||
newNode.size = nodeSize;
|
||||
newNode.x = x;
|
||||
newNode.y = y;
|
||||
newNode.degree = nodeDegree;
|
||||
|
||||
// Add to nodes array
|
||||
rawGraph.nodes.push(newNode);
|
||||
// Update nodeIdMap
|
||||
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new edges
|
||||
for (const edgeId of edgesToAdd) {
|
||||
const newEdge = processedEdges.find(e => e.id === edgeId)!;
|
||||
|
||||
// Skip if edge already exists
|
||||
if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {
|
||||
continue;
|
||||
}
|
||||
if (sigmaGraph.hasEdge(newEdge.target, newEdge.source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the edge to the sigma graph
|
||||
newEdge.dynamicId = sigmaGraph.addDirectedEdge(newEdge.source, newEdge.target, {
|
||||
label: newEdge.type || undefined
|
||||
});
|
||||
|
||||
// Add the edge to the raw graph
|
||||
if (!rawGraph.getEdge(newEdge.id, false)) {
|
||||
// Add to edges array
|
||||
rawGraph.edges.push(newEdge);
|
||||
// Update edgeIdMap
|
||||
rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1;
|
||||
// Update dynamic edge map
|
||||
rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1;
|
||||
} else {
|
||||
console.error('Edge already exists in rawGraph:', newEdge.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the dynamic edge map and invalidate search cache
|
||||
rawGraph.buildDynamicMap();
|
||||
|
||||
// Reset search engine to force rebuild
|
||||
useGraphStore.getState().resetSearchEngine();
|
||||
|
||||
// Update sizes for all nodes with discarded edges
|
||||
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, range, scale);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error expanding node:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// If there's a node to expand, handle it
|
||||
if (nodeToExpand) {
|
||||
handleNodeExpand(nodeToExpand);
|
||||
// Reset the nodeToExpand state after handling
|
||||
window.setTimeout(() => {
|
||||
useGraphStore.getState().triggerNodeExpand(null);
|
||||
}, 0);
|
||||
}
|
||||
}, [isGraphTabVisible, rawGraph])
|
||||
}, [nodeToExpand, sigmaGraph, rawGraph, t]);
|
||||
|
||||
// Helper function to get all nodes that will be deleted
|
||||
const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: DirectedGraph) => {
|
||||
const nodesToDelete = new Set<string>([nodeId]);
|
||||
|
||||
// Find all nodes that would become isolated after deletion
|
||||
graph.forEachNode((node) => {
|
||||
if (node === nodeId) return; // Skip the node being deleted
|
||||
|
||||
// Get all neighbors of this node
|
||||
const neighbors = graph.neighbors(node);
|
||||
|
||||
// If this node has only one neighbor and that neighbor is the node being deleted,
|
||||
// this node will become isolated, so we should delete it too
|
||||
if (neighbors.length === 1 && neighbors[0] === nodeId) {
|
||||
nodesToDelete.add(node);
|
||||
}
|
||||
});
|
||||
|
||||
return nodesToDelete;
|
||||
}, []);
|
||||
|
||||
// Handle node pruning
|
||||
useEffect(() => {
|
||||
const handleNodePrune = (nodeId: string | null) => {
|
||||
if (!nodeId || !sigmaGraph || !rawGraph) return;
|
||||
|
||||
try {
|
||||
const state = useGraphStore.getState();
|
||||
|
||||
// 1. 检查节点是否存在
|
||||
if (!sigmaGraph.hasNode(nodeId)) {
|
||||
console.error('Node not found:', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取要删除的节点
|
||||
const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph);
|
||||
|
||||
// 3. 检查是否会删除所有节点
|
||||
if (nodesToDelete.size === sigmaGraph.nodes().length) {
|
||||
toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 清除选中状态 - 这会导致PropertiesView立即关闭
|
||||
state.clearSelection();
|
||||
|
||||
// 5. 删除节点和相关边
|
||||
for (const nodeToDelete of nodesToDelete) {
|
||||
// Remove the node from the sigma graph (this will also remove connected edges)
|
||||
sigmaGraph.dropNode(nodeToDelete);
|
||||
|
||||
// Remove the node from the raw graph
|
||||
const nodeIndex = rawGraph.nodeIdMap[nodeToDelete];
|
||||
if (nodeIndex !== undefined) {
|
||||
// Find all edges connected to this node
|
||||
const edgesToRemove = rawGraph.edges.filter(
|
||||
edge => edge.source === nodeToDelete || edge.target === nodeToDelete
|
||||
);
|
||||
|
||||
// Remove edges from raw graph
|
||||
for (const edge of edgesToRemove) {
|
||||
const edgeIndex = rawGraph.edgeIdMap[edge.id];
|
||||
if (edgeIndex !== undefined) {
|
||||
// Remove from edges array
|
||||
rawGraph.edges.splice(edgeIndex, 1);
|
||||
// Update edgeIdMap for all edges after this one
|
||||
for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) {
|
||||
if (idx > edgeIndex) {
|
||||
rawGraph.edgeIdMap[id] = idx - 1;
|
||||
}
|
||||
}
|
||||
// Remove from edgeIdMap
|
||||
delete rawGraph.edgeIdMap[edge.id];
|
||||
// Remove from edgeDynamicIdMap
|
||||
delete rawGraph.edgeDynamicIdMap[edge.dynamicId];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove node from nodes array
|
||||
rawGraph.nodes.splice(nodeIndex, 1);
|
||||
|
||||
// Update nodeIdMap for all nodes after this one
|
||||
for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) {
|
||||
if (idx > nodeIndex) {
|
||||
rawGraph.nodeIdMap[id] = idx - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from nodeIdMap
|
||||
delete rawGraph.nodeIdMap[nodeToDelete];
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the dynamic edge map and invalidate search cache
|
||||
rawGraph.buildDynamicMap();
|
||||
|
||||
// Reset search engine to force rebuild
|
||||
useGraphStore.getState().resetSearchEngine();
|
||||
|
||||
// Show notification if we deleted more than just the selected node
|
||||
if (nodesToDelete.size > 1) {
|
||||
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error pruning node:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// If there's a node to prune, handle it
|
||||
if (nodeToPrune) {
|
||||
handleNodePrune(nodeToPrune);
|
||||
// Reset the nodeToPrune state after handling
|
||||
window.setTimeout(() => {
|
||||
useGraphStore.getState().triggerNodePrune(null);
|
||||
}, 0);
|
||||
}
|
||||
}, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);
|
||||
|
||||
const lightrageGraph = useCallback(() => {
|
||||
// If we already have a graph instance, return it
|
||||
|
35
lightrag_webui/src/i18n.js
Normal file
35
lightrag_webui/src/i18n.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { useSettingsStore } from "./stores/settings";
|
||||
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
const getStoredLanguage = () => {
|
||||
try {
|
||||
const settingsString = localStorage.getItem('settings-storage');
|
||||
if (settingsString) {
|
||||
const settings = JSON.parse(settingsString);
|
||||
return settings.state?.language || 'en';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get stored language:', e);
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh }
|
||||
},
|
||||
lng: getStoredLanguage(), // 使用存储的语言设置
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
@@ -1,6 +1,7 @@
|
||||
import { ButtonVariantType } from '@/components/ui/Button'
|
||||
|
||||
export const backendBaseUrl = ''
|
||||
export const webuiPrefix = '/webui/'
|
||||
|
||||
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||
|
||||
|
@@ -12,11 +12,26 @@
|
||||
"retrieval": "Retrieval",
|
||||
"api": "API",
|
||||
"projectRepository": "Project Repository",
|
||||
"logout": "Logout",
|
||||
"themeToggle": {
|
||||
"switchToLight": "Switch to light theme",
|
||||
"switchToDark": "Switch to dark theme"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"description": "Please enter your account and password to log in to the system",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Please input a username",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Please input a password",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"successMessage": "Login succeeded",
|
||||
"errorEmptyFields": "Please enter your username and password",
|
||||
"errorInvalidCredentials": "Login failed, please check username and password",
|
||||
"authDisabled": "Authentication is disabled. Using login free mode.",
|
||||
"guestMode": "Login Free"
|
||||
},
|
||||
"documentPanel": {
|
||||
"clearDocuments": {
|
||||
"button": "Clear",
|
||||
@@ -97,12 +112,14 @@
|
||||
"zoomControl": {
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetZoom": "Reset Zoom"
|
||||
"resetZoom": "Reset Zoom",
|
||||
"rotateCamera": "Clockwise Rotate",
|
||||
"rotateCameraCounterClockwise": "Counter-Clockwise Rotate"
|
||||
},
|
||||
|
||||
"layoutsControl": {
|
||||
"startAnimation": "Start the layout animation",
|
||||
"stopAnimation": "Stop the layout animation",
|
||||
"startAnimation": "Continue layout animation",
|
||||
"stopAnimation": "Stop layout animation",
|
||||
"layoutGraph": "Layout Graph",
|
||||
"layouts": {
|
||||
"Circular": "Circular",
|
||||
@@ -151,6 +168,11 @@
|
||||
"degree": "Degree",
|
||||
"properties": "Properties",
|
||||
"relationships": "Relationships",
|
||||
"expandNode": "Expand Node",
|
||||
"pruneNode": "Prune Node",
|
||||
"deleteAllNodesError": "Refuse to delete all nodes in the graph",
|
||||
"nodesRemoved": "{{count}} nodes removed, including orphan nodes",
|
||||
"noNewNodes": "No expandable nodes found",
|
||||
"propertyNames": {
|
||||
"description": "Description",
|
||||
"entity_id": "Name",
|
||||
@@ -177,7 +199,8 @@
|
||||
"noLabels": "No labels found",
|
||||
"label": "Label",
|
||||
"placeholder": "Search labels...",
|
||||
"andOthers": "And {count} others"
|
||||
"andOthers": "And {count} others",
|
||||
"refreshTooltip": "Reload graph data"
|
||||
}
|
||||
},
|
||||
"retrievePanel": {
|
||||
|
@@ -12,11 +12,26 @@
|
||||
"retrieval": "检索",
|
||||
"api": "API",
|
||||
"projectRepository": "项目仓库",
|
||||
"logout": "退出登录",
|
||||
"themeToggle": {
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"description": "请输入您的账号和密码登录系统",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "请输入用户名",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "请输入密码",
|
||||
"loginButton": "登录",
|
||||
"loggingIn": "登录中...",
|
||||
"successMessage": "登录成功",
|
||||
"errorEmptyFields": "请输入您的用户名和密码",
|
||||
"errorInvalidCredentials": "登录失败,请检查用户名和密码",
|
||||
"authDisabled": "认证已禁用,使用无需登陆模式。",
|
||||
"guestMode": "无需登陆"
|
||||
},
|
||||
"documentPanel": {
|
||||
"clearDocuments": {
|
||||
"button": "清空",
|
||||
@@ -84,7 +99,7 @@
|
||||
"hideUnselectedEdges": "隐藏未选中的边",
|
||||
"edgeEvents": "边事件",
|
||||
"maxQueryDepth": "最大查询深度",
|
||||
"minDegree": "最小度数",
|
||||
"minDegree": "最小邻边数",
|
||||
"maxLayoutIterations": "最大布局迭代次数",
|
||||
"depth": "深度",
|
||||
"degree": "邻边",
|
||||
@@ -96,10 +111,12 @@
|
||||
"zoomControl": {
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"resetZoom": "重置缩放"
|
||||
"resetZoom": "重置缩放",
|
||||
"rotateCamera": "顺时针旋转图形",
|
||||
"rotateCameraCounterClockwise": "逆时针旋转图形"
|
||||
},
|
||||
"layoutsControl": {
|
||||
"startAnimation": "开始布局动画",
|
||||
"startAnimation": "继续布局动画",
|
||||
"stopAnimation": "停止布局动画",
|
||||
"layoutGraph": "图布局",
|
||||
"layouts": {
|
||||
@@ -108,7 +125,7 @@
|
||||
"Random": "随机",
|
||||
"Noverlaps": "无重叠",
|
||||
"Force Directed": "力导向",
|
||||
"Force Atlas": "力图"
|
||||
"Force Atlas": "力地图"
|
||||
}
|
||||
},
|
||||
"fullScreenControl": {
|
||||
@@ -148,6 +165,11 @@
|
||||
"degree": "度数",
|
||||
"properties": "属性",
|
||||
"relationships": "关系",
|
||||
"expandNode": "扩展节点",
|
||||
"pruneNode": "修剪节点",
|
||||
"deleteAllNodesError": "拒绝删除图中的所有节点",
|
||||
"nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点",
|
||||
"noNewNodes": "没有发现可以扩展的节点",
|
||||
"propertyNames": {
|
||||
"description": "描述",
|
||||
"entity_id": "名称",
|
||||
@@ -174,7 +196,8 @@
|
||||
"noLabels": "未找到标签",
|
||||
"label": "标签",
|
||||
"placeholder": "搜索标签...",
|
||||
"andOthers": "还有 {count} 个"
|
||||
"andOthers": "还有 {count} 个",
|
||||
"refreshTooltip": "重新加载图形数据"
|
||||
}
|
||||
},
|
||||
"retrievePanel": {
|
||||
|
@@ -1,5 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { Root } from '@/components/Root'
|
||||
import AppRouter from './AppRouter'
|
||||
import './i18n';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<Root />)
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppRouter />
|
||||
</StrictMode>
|
||||
)
|
||||
|
90
lightrag_webui/src/services/navigation.ts
Normal file
90
lightrag_webui/src/services/navigation.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
import { useAuthStore, useBackendState } from '@/stores/state';
|
||||
import { useGraphStore } from '@/stores/graph';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
class NavigationService {
|
||||
private navigate: NavigateFunction | null = null;
|
||||
|
||||
setNavigate(navigate: NavigateFunction) {
|
||||
this.navigate = navigate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all application state to ensure a clean environment.
|
||||
* This function should be called when:
|
||||
* 1. User logs out
|
||||
* 2. Authentication token expires
|
||||
* 3. Direct access to login page
|
||||
*/
|
||||
resetAllApplicationState() {
|
||||
console.log('Resetting all application state...');
|
||||
|
||||
// Reset graph state
|
||||
const graphStore = useGraphStore.getState();
|
||||
const sigma = graphStore.sigmaInstance;
|
||||
graphStore.reset();
|
||||
graphStore.setGraphDataFetchAttempted(false);
|
||||
graphStore.setLabelsFetchAttempted(false);
|
||||
graphStore.setSigmaInstance(null);
|
||||
graphStore.setIsFetching(false); // Reset isFetching state to prevent data loading issues
|
||||
|
||||
// Reset backend state
|
||||
useBackendState.getState().clear();
|
||||
|
||||
// Reset retrieval history while preserving other user preferences
|
||||
useSettingsStore.getState().setRetrievalHistory([]);
|
||||
|
||||
// Clear authentication state
|
||||
sessionStorage.clear();
|
||||
|
||||
if (sigma) {
|
||||
sigma.getGraph().clear();
|
||||
sigma.kill();
|
||||
useGraphStore.getState().setSigmaInstance(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle direct access to login page
|
||||
* @returns true if it's a direct access, false if navigated from another page
|
||||
*/
|
||||
handleDirectLoginAccess() {
|
||||
const isDirectAccess = !document.referrer;
|
||||
if (isDirectAccess) {
|
||||
this.resetAllApplicationState();
|
||||
}
|
||||
return isDirectAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to login page and reset application state
|
||||
* @param skipReset whether to skip state reset (used for direct access scenario where reset is already handled)
|
||||
*/
|
||||
navigateToLogin() {
|
||||
if (!this.navigate) {
|
||||
console.error('Navigation function not set');
|
||||
return;
|
||||
}
|
||||
|
||||
// First navigate to login page
|
||||
this.navigate('/login');
|
||||
|
||||
// Then reset state after navigation
|
||||
setTimeout(() => {
|
||||
this.resetAllApplicationState();
|
||||
useAuthStore.getState().logout();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
navigateToHome() {
|
||||
if (!this.navigate) {
|
||||
console.error('Navigation function not set');
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
export const navigationService = new NavigationService();
|
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { DirectedGraph } from 'graphology'
|
||||
import { getGraphLabels } from '@/api/lightrag'
|
||||
import MiniSearch from 'minisearch'
|
||||
|
||||
export type RawNodeType = {
|
||||
id: string
|
||||
@@ -66,17 +67,19 @@ interface GraphState {
|
||||
|
||||
rawGraph: RawGraph | null
|
||||
sigmaGraph: DirectedGraph | null
|
||||
sigmaInstance: any | null
|
||||
allDatabaseLabels: string[]
|
||||
|
||||
searchEngine: MiniSearch | null
|
||||
|
||||
moveToSelectedNode: boolean
|
||||
isFetching: boolean
|
||||
shouldRender: boolean
|
||||
|
||||
// Global flags to track data fetching attempts
|
||||
graphDataFetchAttempted: boolean
|
||||
labelsFetchAttempted: boolean
|
||||
|
||||
refreshLayout: () => void
|
||||
setSigmaInstance: (instance: any) => void
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
|
||||
setFocusedNode: (nodeId: string | null) => void
|
||||
setSelectedEdge: (edgeId: string | null) => void
|
||||
@@ -91,14 +94,25 @@ interface GraphState {
|
||||
setAllDatabaseLabels: (labels: string[]) => void
|
||||
fetchAllDatabaseLabels: () => Promise<void>
|
||||
setIsFetching: (isFetching: boolean) => void
|
||||
setShouldRender: (shouldRender: boolean) => void
|
||||
|
||||
// 搜索引擎方法
|
||||
setSearchEngine: (engine: MiniSearch | null) => void
|
||||
resetSearchEngine: () => void
|
||||
|
||||
// Methods to set global flags
|
||||
setGraphDataFetchAttempted: (attempted: boolean) => void
|
||||
setLabelsFetchAttempted: (attempted: boolean) => void
|
||||
|
||||
// Event trigger methods for node operations
|
||||
triggerNodeExpand: (nodeId: string | null) => void
|
||||
triggerNodePrune: (nodeId: string | null) => void
|
||||
|
||||
// Node operation state
|
||||
nodeToExpand: string | null
|
||||
nodeToPrune: string | null
|
||||
}
|
||||
|
||||
const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
const useGraphStoreBase = create<GraphState>()((set) => ({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
@@ -106,7 +120,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
|
||||
moveToSelectedNode: false,
|
||||
isFetching: false,
|
||||
shouldRender: false,
|
||||
|
||||
// Initialize global flags
|
||||
graphDataFetchAttempted: false,
|
||||
@@ -114,21 +127,13 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
|
||||
rawGraph: null,
|
||||
sigmaGraph: null,
|
||||
sigmaInstance: null,
|
||||
allDatabaseLabels: ['*'],
|
||||
|
||||
refreshLayout: () => {
|
||||
const currentGraph = get().sigmaGraph;
|
||||
if (currentGraph) {
|
||||
get().clearSelection();
|
||||
get().setSigmaGraph(null);
|
||||
setTimeout(() => {
|
||||
get().setSigmaGraph(currentGraph);
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
searchEngine: null,
|
||||
|
||||
|
||||
setIsFetching: (isFetching: boolean) => set({ isFetching }),
|
||||
setShouldRender: (shouldRender: boolean) => set({ shouldRender }),
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
|
||||
set({ selectedNode: nodeId, moveToSelectedNode }),
|
||||
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
|
||||
@@ -142,24 +147,15 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
focusedEdge: null
|
||||
}),
|
||||
reset: () => {
|
||||
// Get the existing graph
|
||||
const existingGraph = get().sigmaGraph;
|
||||
|
||||
// If we have an existing graph, clear it by removing all nodes
|
||||
if (existingGraph) {
|
||||
const nodes = Array.from(existingGraph.nodes());
|
||||
nodes.forEach(node => existingGraph.dropNode(node));
|
||||
}
|
||||
|
||||
set({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null,
|
||||
rawGraph: null,
|
||||
// Keep the existing graph instance but with cleared data
|
||||
moveToSelectedNode: false,
|
||||
shouldRender: false
|
||||
sigmaGraph: null, // to avoid other components from acccessing graph objects
|
||||
searchEngine: null,
|
||||
moveToSelectedNode: false
|
||||
});
|
||||
},
|
||||
|
||||
@@ -190,9 +186,23 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||
|
||||
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
|
||||
|
||||
setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),
|
||||
|
||||
setSearchEngine: (engine: MiniSearch | null) => set({ searchEngine: engine }),
|
||||
resetSearchEngine: () => set({ searchEngine: null }),
|
||||
|
||||
// Methods to set global flags
|
||||
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
|
||||
setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted })
|
||||
setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted }),
|
||||
|
||||
// Node operation state
|
||||
nodeToExpand: null,
|
||||
nodeToPrune: null,
|
||||
|
||||
// Event trigger methods for node operations
|
||||
triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
|
||||
triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
|
||||
|
||||
}))
|
||||
|
||||
const useGraphStore = createSelectors(useGraphStoreBase)
|
||||
|
@@ -16,6 +16,13 @@ interface BackendState {
|
||||
setErrorMessage: (message: string, messageTitle: string) => void
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isGuestMode: boolean; // Add guest mode flag
|
||||
login: (token: string, isGuest?: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useBackendStateStoreBase = create<BackendState>()((set) => ({
|
||||
health: true,
|
||||
message: null,
|
||||
@@ -57,3 +64,60 @@ const useBackendStateStoreBase = create<BackendState>()((set) => ({
|
||||
const useBackendState = createSelectors(useBackendStateStoreBase)
|
||||
|
||||
export { useBackendState }
|
||||
|
||||
// Helper function to check if token is a guest token
|
||||
const isGuestToken = (token: string): boolean => {
|
||||
try {
|
||||
// JWT tokens are in the format: header.payload.signature
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
// Check if the token has a role field with value "guest"
|
||||
return payload.role === 'guest';
|
||||
} catch (e) {
|
||||
console.error('Error parsing token:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
const initAuthState = (): { isAuthenticated: boolean; isGuestMode: boolean } => {
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
|
||||
if (!token) {
|
||||
return { isAuthenticated: false, isGuestMode: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
isGuestMode: isGuestToken(token)
|
||||
};
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>(set => {
|
||||
// Get initial state from localStorage
|
||||
const initialState = initAuthState();
|
||||
|
||||
return {
|
||||
isAuthenticated: initialState.isAuthenticated,
|
||||
isGuestMode: initialState.isGuestMode,
|
||||
|
||||
login: (token, isGuest = false) => {
|
||||
localStorage.setItem('LIGHTRAG-API-TOKEN', token);
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isGuestMode: isGuest
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('LIGHTRAG-API-TOKEN');
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isGuestMode: false
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts", "src/vite-env.d.ts"]
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import path from 'path'
|
||||
|
||||
import { webuiPrefix } from '@/lib/constants'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
@@ -12,7 +12,8 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
base: './',
|
||||
// base: import.meta.env.VITE_BASE_URL || '/webui/',
|
||||
base: webuiPrefix,
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../lightrag/api/webui'),
|
||||
emptyOutDir: true
|
||||
|
Reference in New Issue
Block a user